Compare commits

..

81 commits

Author SHA1 Message Date
1cc86b52fd these changes were promised to me 3000 years ago 2026-04-15 19:53:48 +00:00
b7e398a068 Handle errors; general code-quality improvements 2026-04-15 19:07:18 +00:00
bd80d562c7 t e s t i n g c o m p l e t e
I also noticed that my previous wiping code wasn't even doing anything at all. lmfao
2026-04-15 17:08:16 +00:00
9871ed8930 consistency. 2026-04-15 16:35:36 +00:00
5db585a525 I just noticed something silly...
I was stripping the ping before because I thought it just pings the thread-author (which I found kinda pointless). But I didn't actually remove the code that figures out who to ping (because I happened to reuse the „if” around it, and didn't remove the setting itself because I didn't pay enough attention to it and just assumed it has some side-effects). I just tried to remove it finally (because my thought was „Wait, WHY are we setting m.mentions only to remove it?”), only to realize that the code does something entirely different (it pings the one under whose message a thread is about to be created, which makes a lot of sense tbh), and actually shouldn't be removed at all and - on the contrary - I should stop removing m.mentions (and also fix Ellie-Mode so that it won't prevent m.mentions from being set even if it's enabled).
2026-04-15 16:24:32 +00:00
ff8e571950 Changes to thread announcements, especially:
* use "" instead of „” to comply with English Language Standards Recommendations On Quotation Marks [TM], as per Cadence's request
* reflect current bot behavior (ie. it no longer bridges-as-replies, but mercilessly rips the message away from your caring arms)
* add Ellie-Mode
2026-04-15 15:24:01 +00:00
7eeff2faf3 ...So I might as well take care of this mess with commands.
Notably:
* Don't do the unmarshalling and switch-cases, as Cadence asked
* Revert command handler returns to how they were before, now that we're not using the returned-command-name anymore.
2026-04-14 22:49:59 +00:00
b869b432b6 This looks better (I still don't remember what was I doing) 2026-04-14 20:52:29 +00:00
44fb3f9f64 Credit where credit is due 2026-04-03 13:08:13 +00:00
e47b5e3d2b I am SUCH a MASSIVE FUCKING MORON jesusfuckingCHRIST 2026-04-02 20:12:29 +00:00
50d09fd48f Async JS does *NOT* spark joy 2026-04-02 20:09:13 +00:00
85314818d2 WHAT? HOW? 2026-04-02 20:03:07 +00:00
b3badac452 i WILL cry 2026-04-02 19:55:10 +00:00
3df15c5efa WHY are you still defined? 2026-04-02 18:56:46 +00:00
c53b54bafc Fix Element being stupid 2026-04-02 18:24:30 +00:00
1ea9712086 Message redirection on Matrix side, too? 2026-04-02 17:42:36 +00:00
b924de2357 namegen; prevent commands from running in redirected messages 2026-04-02 16:26:20 +00:00
98240400a6 ThreadRoom auto-create
Both sides of creation (M2D and D2M) use ensureRoom() instead of syncRoom() because it's impossible to know which one will fire first, and we wouldn't want a double-sync. At the same time, calling ensureRoom() as a way to CREATE a thread-room is perfectly safe because „Naturally, the newly created room is already up to date, so we can always skip syncing here.” and also thread-rooms aren't subject to manual-mode restrictions, so we can skip all „Does a channel_room entry exists or guild autocreate = 1?” checks (actually, the comment probably should reflect that - so I updated the comments, too.

Also, bridgeThread() is a separate function to make guard clauses possible instead of nesting 3 more layers of IFs like we were fkin YandereDev.
2026-04-02 15:13:40 +00:00
e23d365913 this will likely be removed, but I might still fix it 2026-04-02 12:42:20 +00:00
6c2aeea8a6 This should, IN THEORY, *just work* for existing threads. 2026-04-01 21:20:54 +00:00
f3330826d9 Resolved merge conflicts 2026-04-01 20:47:56 +00:00
afa8ba2237 Emergency sync
I was not supposed to do more pulling, but I started seeing some instability and I want to see if it's me or main.
2026-03-14 07:10:09 +00:00
86cfbd21a9 I broke Git
of course I did
2026-03-02 15:27:54 +00:00
438ed2b4eb revert e807d1fbf2 2026-03-02 15:24:11 +00:00
56f7c4c09a Merge pull request 'sync-back' (#9) from main into fuckery
Reviewed-on: Guzio/out-of-your-element#9
2026-03-02 15:21:12 +00:00
e807d1fbf2 Merge branch 'fuckery' into main 2026-03-02 15:20:07 +00:00
748e851b39 Improve threads UX
/This is a squash-commit - the following is a rough summary of all sub-commits, written in style of commit messages (not necessarily those commits themselves), ie. short and in present tense./

* Document design choice to not bridge Discord threads as Matrix threads [by directly quoting Cadence]
* Alter thread-to-announcement, so that it replies in-thread [with this, Matrix users will get a list of almost all (exl. those that don't branch from anything) open threads on a given channel, whereas before it wasn't possible. Also features slight alterations to the text]
* Notify the user, whenever an in-thread message on Matrix is sent, that this isn't how they're supposed to do threads on OOYE
* Detect /thread being ran as a reply or in-thread to branch the thread from the relevant message
* Handle various /thread errors [notably being ran without args (infer the title if ran in the context above, simply show help if not)]
*  Whenever possible, direct the user to an already-existing thread-room [if /thread was ran as a reply or in-thread, or as part of the notification mentioned in point 3 (feat. a new utility method)]

AUXILIARY TYPE CHANGES (not always relevant to UX-improvement-related changes):
* Fix „boolean” being referred to as „bool” in types.d.ts
* Rename execute(event) in matrix-command-handler is now parseAndExecute(event) [and it is no longer of type CommandExecute, but has its own custom definition, because a) it has a different return now (returns what command was ran (needed for point 3 in section above) instead of always undefined and b) other params from CommandExecute (like ctx or words) weren't being used - quite the contrary, their values were only being created at that stage (as part of command parsing - hence the rename, too), so telling that they're values you pass into execute() was at least somewhat confusing]
* Further narrow-down the type of guard() in m2d event-dispatcher

TEST CHANGES:
* Create 7 new tests, all pass
* Update 4 existing threads, all pass
* Pass all other relevant tests [and almost all other tests, too - there are some issues with event2message for stickers, but given the fact that this commit does not touch the stickers subsystem in any way at all,  it does not seem like they are any way related to my changes and they must've been failing before]
* Do extensive manual testing and debugging
Co-authored-by: Guzio <guzekk@protonmail.com>
Co-committed-by: Guzio <guzekk@protonmail.com>
2026-03-02 15:17:08 +00:00
b38abe81a6 reworded the error
Turns out that auto-create is ALWAYS on for threads (which creates some hilarious situations, where the channel gets duplicated if it ever got unbridged). Also, manual bridging isn't even possible. Uhh... Sure! Let's just say, then, that it's the admin's problem to auto-create it (given the duplication - this is probably a better idea to leave it to them).

A proper fix for this (and also to limit (tho not fix) the dupe-by-autocreate problem) would probably be to allow for manual bridging on threads, but I really don't have time for this before The Merge (my ADHD is kicking-in on this update, and I have a feeling that if I don't PR soon, I'm gonna not do it for another 3 months).
2026-03-02 13:44:36 +00:00
b84b848d04 temporary change to VSC settings
So that I can squash-merge it all without leaving the trace of any extra unsolicited changes
2026-03-02 13:15:29 +00:00
20ce420303 copy-pasted Cadence's message as documentation 2026-03-02 13:12:17 +00:00
a877122ef6 fix one more final UX pet-peeve 2026-03-02 13:06:30 +00:00
de6ce38c2d Merge pull request 'bbbbbbb' (#8) from main into fuckery
Reviewed-on: Guzio/out-of-your-element#8
2026-03-02 13:00:11 +00:00
b90592cbe9 Prevent merge conflicts once again 2026-03-02 12:59:20 +00:00
8260396254 replace newlines instead of stripping them (what DC does by default) 2026-02-28 14:14:27 +00:00
c691274dd9 git pisses me off
just a little bit

I like it.

But it's just a little bit annoying.
2026-02-28 13:49:59 +00:00
5a0e7f6a66 Prevent more merge conflicts 2026-02-28 13:47:49 +00:00
69b128a598 debug done; turns out that I'm just stupid
I passed a completely wrong event ID and was confused as to why could it possibly be failing. (Btw, as part od fixing that - my new function from function is utils.js now supports blank values.)

Also - and that's unrelated to the bug I was debugging - I put a guard clause in my if (words.length < 2) backwards. If->return should canonically be above the logic, even if it technically doesn't break said logic in this case (all it was doing was creating an extra step that says „yea, name the newly-created thread /thread, even if this name is very stupid, and also pointless because no thread creation is about to take place”. And while fixing that, I also did some minor changes to error handling.
2026-02-28 13:37:16 +00:00
7895f89cc0 debug slop v2 2026-02-28 11:52:18 +00:00
10fbb9e696 improved consistency 2026-02-27 23:11:50 +00:00
edfbdc567f Used getThreadRoomFromThreadEvent in practice 2026-02-27 23:07:43 +00:00
c9509bb938 figured out how tests work, yaaayyyy
As a part of that:
* rewrote the tests to support my changed behaviors
* added a missing case
* Made my „threads get attached to” wording more consistent with the test cases („threads branch from”), as I always felt that there was something off about my phrasing, but I couldn't quite tell what was it. OOYE's „branch” term seems much more fitting there
* slightly cooked the testing data (changed the „Hey.” thread from „floating” to being a branch of a message, to accommodate...)
* 3 NEW TESTS: of the function created in my previous commit (I'm not sure if this *REALLY* needed testing, given how braindead-simple that function is, but everything else in utils.js is covered, so I figured it's only fair to test this, too)

EXTRA CHANGE: fixed that function's name (we're getting the thread from a (Matrix) Event, not a (Discord) Message) and description (I totally didn't copy-paste the JSDoc from above........)
2026-02-27 20:42:16 +00:00
42c32ba749 explained my technical decisions; made a function that'll help me later 2026-02-27 13:06:52 +00:00
ffed434c6a rewrote the f#cker as a switch statement 2026-02-26 16:05:53 +00:00
0557c7b143 works yay 2026-02-26 16:00:22 +00:00
3e42616065 It just occurred to me that I have no way of testing the „fallback case” now 2026-02-26 15:55:18 +00:00
22ff10222c fixed error details showing up as [object Object] 2026-02-26 15:38:20 +00:00
3f7a7aa10f handled overyapping 2026-02-26 15:37:17 +00:00
266f46563b Possibly? fixed error handling???
and yea, ofc it was a string......
2026-02-26 12:49:21 +00:00
06962c217e unspecified horsing around 2026-02-26 12:23:09 +00:00
9424b5e517 Apparenly, what I completley missed, is that „code” is overriden by something later in the error stack. Trying out other keys...... 2026-02-25 11:04:55 +00:00
9bf6e50ae9 SO WHAT DO YOU WANT? TELL ME WHAT'S YOUR POINT, JS!
So the code-key DOES exist. HuuuHhhhhh???
2026-02-25 10:56:51 +00:00
fa916699a7 Nope, it's an object. But, like... A weird one. It doesn't seem to behave like objects normally do. It it a wrapper around stuff? 2026-02-25 10:48:12 +00:00
bea0b9370d Type of e is its own content. Apparently. What the fuck? 2026-02-25 10:39:56 +00:00
c283528d72 Is this LITERALLY just a String????? 2026-02-25 10:32:37 +00:00
0ad4b41ae9 Iiiiiii........... I have no idea what was I trying to accomplish here... 2026-02-25 10:28:09 +00:00
4a26001382 Debug-slop begins! 2026-02-25 10:23:13 +00:00
f0515ceecf UX testing revealed that og messages looked awkward 2026-02-25 10:11:18 +00:00
69d07c1a7b I lied, that's the final patch (my C# past got the better of me lol) 2026-02-25 09:48:22 +00:00
3aa5f1b7ce Synced my branches, again 2026-02-25 09:33:45 +00:00
e146faced1 Prevent merge conflicts 2026-02-25 09:31:41 +00:00
23cdf54982 AEUGH it turns out that replying was already handled.
In other news: Made /thread work without args (in SOME cases).

I'm pretty sure this is the final patch before we go PR.
2026-02-25 09:25:02 +00:00
b53b2f56b6 Improved error handling 2026-02-24 19:26:34 +00:00
d7aadc3079 AAAAAAAAAAAAAAAAAAAA 2026-02-20 04:02:01 +00:00
f9e303f018 stray whitespace 2026-02-20 03:52:03 +00:00
e44f1041b6 Turns out that creating a thread-in-thread (which is what the stuff I was doing in matrix-command-handler effectively amounted to) KINDA breaks Element. Whoops!
Also, that message in thread-to-announcement was misleading, as now there's no guarantee that a thread was newly created (it could be very old, but freshly /thread-ed). So I changed that, too.

Also, while updating messages, I decided to slightly alter the „may not have been bridged to Discord in the way you thought it was gonna be”-warning in event-dispatcher.
2026-02-20 03:50:06 +00:00
f734b0619f Don't warn the user that they should use /thread if they literally just did it. 2026-02-20 03:10:09 +00:00
b542a81ee1 first time actually interacting with the DB 2026-02-20 02:21:52 +00:00
ac421e6c74 this looks better 2026-02-20 00:00:42 +00:00
7afcbfaa06 I have no idea if this works; just throwing random ideas together.
It's 23:42; I'm going just purely based on vibes at this point.

vibecoding but no AI, just eepy
2026-02-19 22:44:20 +00:00
abe42aaa92 Updated the message to its final form.
At least final-until-we-make-it-so-that-new-rooms-are-autogenerated-when-a-thread-is-opened. Then we'd need to include the link to it instead of a command-help.
2026-02-19 21:26:15 +00:00
aaf8dea104 Reply-related metadata 2026-02-19 19:27:13 +00:00
486959be0b forgor 2026-02-19 19:05:25 +00:00
01b82e7b68 I think I got SOMETHING up and running! 2026-02-19 18:48:24 +00:00
dca53752bb Reverse-engineering the docs 2026-02-19 18:34:30 +00:00
8676a73620 Testing BEGINS! 2026-02-19 17:53:56 +00:00
10099142c2 Merge branch 'main' into fuckery 2026-02-19 17:53:10 +00:00
aedd30ab4a Update "m.relates_to" type definition @ types.d.ts
* To better reflect reality ("m.in_reply_to" will not always be present - it's not (always?) found on "rel_type":"m.replace" relation-events)

* To support "rel_type":"m.replace" relation-events (added "m.replace" option to existing key "rel_type" and a new "is_falling_back" key)

AFFECTED TYPES: M_Room_Message, M_Room_Message_File, M_Room_Message_Encrypted_File

BREAKS: Nothing, as .d.ts files don't affect buisness logic. In terms of lint errors: Marking "m.in_reply_to" as optional is indeed technically a "breaking change" (TypeScript may complain about „is probably undefined” in places where it didn't before), but from early "testing" (ie. looking at VSCode's errors tab), it doesn't seem like anything broke, as no file that imports any of those 3 types (Or their Outer_ counterparts) has „lit up” with errors (unless I missed something). There was one type error found in m2d/converters/event-to-message.js, at line 1009, but that seemed unrelated to types.d.ts - nevertheless, that error was also corrected in this commit, by adding proper type annotations somewhere else in the affected file.
2026-02-19 17:48:32 +00:00
a66b93ed26 Merged my branches 2026-02-19 16:25:03 +00:00
5a853249a2 I prefer 4 spaces 2026-02-19 16:21:27 +00:00
2c7831c587 Small TypeScript coverage expansion
* The guard() function in m2d/event-dispatcher.js no longer takes (any, any), but a string and a function.

* m2d/send-event.js no longer complains that res.body has some missing fields. It would appear as though those missing fields weren't revelant to the fromWeb() function (into which res.body is passed), given that this code worked before and still contunes to work, so I just @ts-ignore'd res.body

This commit's developer's off-topic personal comment, related to this commit: This has nothing to do with improving thread UX, even tho this is what I was supposed to work on. However, in my attempts to discover in what file should I start, I stumbled upon those errors from m2d/send-event.js, so I fixed them. And after establishing that m2d/event-dispatcher.js is the file that I'm looking for, I also noticed that guard()'s @parm definitions could be improved, so I did that. Now - back to thread work...
2026-02-19 16:19:39 +00:00
ae6b730c26 Update .gitignore 2026-02-19 13:03:16 +00:00
103 changed files with 2165 additions and 3603 deletions

5
.gitignore vendored
View file

@ -1,17 +1,18 @@
# Secrets # Personal
config.js config.js
registration.yaml registration.yaml
ooye.db* ooye.db*
events.db* events.db*
backfill.db* backfill.db*
custom-webroot custom-webroot
icon.svg
.devcontainer
# Automatically generated # Automatically generated
node_modules node_modules
coverage coverage
test/res/* test/res/*
!test/res/lottie* !test/res/lottie*
icon.svg
*~ *~
.#* .#*
\#*# \#*#

View file

@ -113,7 +113,6 @@ Total transitive production dependencies: 144
* (0) ansi-colors: Helps with interactive prompting for the initial setup, and it's already pulled in by enquirer. * (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.
* (0) denque: Much faster than using arrays as a queue.
* (0) discord-api-types: Bitfields needed at runtime and types needed for development. * (0) discord-api-types: Bitfields needed at runtime and types needed for development.
* (0) domino: DOM implementation that's already pulled in by turndown. * (0) domino: DOM implementation that's already pulled in by turndown.
* (2) enquirer: Interactive prompting for the initial setup rather than forcing users to edit YAML non-interactively. * (2) enquirer: Interactive prompting for the initial setup rather than forcing users to edit YAML non-interactively.

View file

@ -66,7 +66,7 @@ I had no hand in creating these and have not used or tested them whatsoever. I m
***The following list is distributed for your information, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.*** ***The following list is distributed for your information, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.***
- by melody: https://git.shork.ch/oci-images/out-of-your-element - by melody: https://git.shork.ch/docker-images/out-of-your-element
- by sim2kid: https://github.com/sim2kid/ooye-docker - by sim2kid: https://github.com/sim2kid/ooye-docker
- by Katharos Technology: https://github.com/katharostech/docker_ooye - by Katharos Technology: https://github.com/katharostech/docker_ooye
- by Emma: https://cgit.rory.gay/nix/OOYE-module.git/tree - by Emma: https://cgit.rory.gay/nix/OOYE-module.git/tree

View file

@ -56,16 +56,8 @@ After that, to get into the rooms on your Matrix account, use the invite form on
I hope you enjoy Out Of Your Element! I hope you enjoy Out Of Your Element!
# Extras ----
<br><br><br><br><br>
## /plu/ral support
To support /plu/ral webhook proxying, OOYE needs to call the /plu/ral API authenticated. I can't provide an API key for everyone to share, so you have to make your own. Here's how:
1. Open the [/plu/ral app on Discord](https://discord.com/discovery/applications/1291501048493768784) and "Add App" -> "Add to My Apps (use everywhere)"
2. Go to any text channel on Discord and use the `/api` command from /plu/ral.
3. Create a new application named Out Of Your Element. Copy the displayed token.
3. Run `node scripts/plu-ral-api.js` and paste the token.
# Appendix # Appendix

9
docs/threads-as-rooms.md Normal file
View file

@ -0,0 +1,9 @@
I thought pretty hard about it and I opted to make threads separate rooms because
1. parity: discord has separate things like permissions and pins for threads, matrix cannot do this at all unless the thread is a separate room
2. usage styles: most discord threads I've seen tend to be long-lived, spanning months or years, which isn't suited to matrix because of the timeline
- I'm in a discord thread for posting photos of food that gets a couple posts a week and has a timeline going back to 2023
3. the timeline: if a matrix room has threads, and you want to scroll back through the timeline of a room OR of one of its threads, the timeline is merged, so you have to download every message linearised and throw them away if they aren't part of the thread you're looking through. it's bad for threads and it's bad for the main room
4. it is also very very complex for clients to implement read receipts and typing indicators correctly for the merged timeline. if your client doesn't implement this, or doesn't do it correctly, you have a bad experience. many clients don't. element seems to have done it well enough, but is an exception
overall in my view, threads-as-rooms has better parity and fewer downsides over native threads. but if there are things you don't like about this approach, I'm happy to discuss and see if we can improve them.

View file

@ -84,7 +84,7 @@ Discord display names for normal users are limited to 32 characters. For webhook
If the bridge software was restarted, it will attempt to catch up on messages missed while it was offline. If the bridge software was restarted, it will attempt to catch up on messages missed while it was offline.
From Discord, for any given channel, if fewer than 100 messages were missed in that given channel, the bridge will catch up and transfer all of them to Matrix. If more than 100 messages were missed in that given channel, the bridge will only bridge the latest message. Happenings that aren't messages, such as edits and reactions to prior messages, might be missed during catch-up. From Discord, for any given channel, if fewer than 50 messages were missed in that given channel, the bridge will catch up and transfer all of them to Matrix. If more than 50 messages were missed in that given channel, the bridge will only bridge the latest message. Happenings that aren't messages, such as edits and reactions to prior messages, might be missed during catch-up.
From Matrix, all events should be bridged to Discord. From Matrix, all events should be bridged to Discord.

446
package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "out-of-your-element", "name": "out-of-your-element",
"version": "3.6.0", "version": "3.5.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "out-of-your-element", "name": "out-of-your-element",
"version": "3.6.0", "version": "3.5.0",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {
"@chriscdn/promise-semaphore": "^3.0.1", "@chriscdn/promise-semaphore": "^3.0.1",
@ -18,15 +18,14 @@
"@cloudrac3r/pngjs": "^7.0.3", "@cloudrac3r/pngjs": "^7.0.3",
"@cloudrac3r/pug": "^4.0.4", "@cloudrac3r/pug": "^4.0.4",
"@cloudrac3r/stream-type": "^1.0.0", "@cloudrac3r/stream-type": "^1.0.0",
"@cloudrac3r/turndown": "^7.1.5", "@cloudrac3r/turndown": "^7.1.4",
"@stackoverflow/stacks": "^2.5.4", "@stackoverflow/stacks": "^2.5.4",
"@stackoverflow/stacks-icons": "^6.0.2", "@stackoverflow/stacks-icons": "^6.0.2",
"ansi-colors": "^4.1.3", "ansi-colors": "^4.1.3",
"better-sqlite3": "^12.2.0", "better-sqlite3": "^12.2.0",
"chunk-text": "^2.0.1", "chunk-text": "^2.0.1",
"cloudstorm": "^0.19.0", "cloudstorm": "^0.17.0",
"denque": "^2.1.0", "discord-api-types": "^0.38.38",
"discord-api-types": "^0.38.52",
"domino": "^2.1.6", "domino": "^2.1.6",
"enquirer": "^2.4.1", "enquirer": "^2.4.1",
"entities": "^5.0.0", "entities": "^5.0.0",
@ -37,8 +36,8 @@
"lru-cache": "^11.0.2", "lru-cache": "^11.0.2",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
"prettier-bytes": "^1.0.4", "prettier-bytes": "^1.0.4",
"sharp": "^0.35.3", "sharp": "^0.34.5",
"snowtransfer": "^0.19.0", "snowtransfer": "^0.17.5",
"try-to-catch": "^4.0.5", "try-to-catch": "^4.0.5",
"uqr": "^0.1.2", "uqr": "^0.1.2",
"xxhash-wasm": "^1.0.2", "xxhash-wasm": "^1.0.2",
@ -49,7 +48,7 @@
"@types/node": "^22.17.1", "@types/node": "^22.17.1",
"c8": "^11.0.0", "c8": "^11.0.0",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"supertape": "^13.2.0" "supertape": "^12.0.12"
}, },
"engines": { "engines": {
"node": ">=22" "node": ">=22"
@ -268,18 +267,18 @@
} }
}, },
"node_modules/@cloudrac3r/turndown": { "node_modules/@cloudrac3r/turndown": {
"version": "7.1.5", "version": "7.1.4",
"resolved": "https://registry.npmjs.org/@cloudrac3r/turndown/-/turndown-7.1.5.tgz", "resolved": "https://registry.npmjs.org/@cloudrac3r/turndown/-/turndown-7.1.4.tgz",
"integrity": "sha512-pENoG62B4UM2ecsg57cuSRDa1OV/YFYTFGg1aoQsKUdHBBglhE9I7EwEMYpS96QoM/auyglfaxjgTeqhcSGCIA==", "integrity": "sha512-bQAwcvcSqBTdEHPMt+IAZWIoDh+2eRuy9TgD0FUdxVurbvj3CUHTxLfzlmsO0UTi+GHpgYqDSsVdV7kYTNq5Qg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"domino": "^2.1.6" "domino": "^2.1.6"
} }
}, },
"node_modules/@emnapi/runtime": { "node_modules/@emnapi/runtime": {
"version": "1.11.3", "version": "1.9.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz",
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==",
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
@ -302,9 +301,9 @@
} }
}, },
"node_modules/@img/sharp-darwin-arm64": { "node_modules/@img/sharp-darwin-arm64": {
"version": "0.35.3", "version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
"integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -314,19 +313,19 @@
"darwin" "darwin"
], ],
"engines": { "engines": {
"node": ">=20.9.0" "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.3.2" "@img/sharp-libvips-darwin-arm64": "1.2.4"
} }
}, },
"node_modules/@img/sharp-darwin-x64": { "node_modules/@img/sharp-darwin-x64": {
"version": "0.35.3", "version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
"integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -336,38 +335,19 @@
"darwin" "darwin"
], ],
"engines": { "engines": {
"node": ">=20.9.0" "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.3.2" "@img/sharp-libvips-darwin-x64": "1.2.4"
}
},
"node_modules/@img/sharp-freebsd-wasm32": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz",
"integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==",
"license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
"dependencies": {
"@img/sharp-wasm32": "0.35.3"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
} }
}, },
"node_modules/@img/sharp-libvips-darwin-arm64": { "node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.3.2", "version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
"integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -381,9 +361,9 @@
} }
}, },
"node_modules/@img/sharp-libvips-darwin-x64": { "node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.3.2", "version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
"integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -397,9 +377,9 @@
} }
}, },
"node_modules/@img/sharp-libvips-linux-arm": { "node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.3.2", "version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
"integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -413,9 +393,9 @@
} }
}, },
"node_modules/@img/sharp-libvips-linux-arm64": { "node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.3.2", "version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
"integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -429,9 +409,9 @@
} }
}, },
"node_modules/@img/sharp-libvips-linux-ppc64": { "node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.3.2", "version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
"integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@ -445,9 +425,9 @@
} }
}, },
"node_modules/@img/sharp-libvips-linux-riscv64": { "node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.3.2", "version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
"integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@ -461,9 +441,9 @@
} }
}, },
"node_modules/@img/sharp-libvips-linux-s390x": { "node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.3.2", "version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
"integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@ -477,9 +457,9 @@
} }
}, },
"node_modules/@img/sharp-libvips-linux-x64": { "node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.3.2", "version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
"integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -493,9 +473,9 @@
} }
}, },
"node_modules/@img/sharp-libvips-linuxmusl-arm64": { "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.3.2", "version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
"integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -509,9 +489,9 @@
} }
}, },
"node_modules/@img/sharp-libvips-linuxmusl-x64": { "node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.3.2", "version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
"integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -525,9 +505,9 @@
} }
}, },
"node_modules/@img/sharp-linux-arm": { "node_modules/@img/sharp-linux-arm": {
"version": "0.35.3", "version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
"integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -537,19 +517,19 @@
"linux" "linux"
], ],
"engines": { "engines": {
"node": ">=20.9.0" "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.3.2" "@img/sharp-libvips-linux-arm": "1.2.4"
} }
}, },
"node_modules/@img/sharp-linux-arm64": { "node_modules/@img/sharp-linux-arm64": {
"version": "0.35.3", "version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
"integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -559,19 +539,19 @@
"linux" "linux"
], ],
"engines": { "engines": {
"node": ">=20.9.0" "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.3.2" "@img/sharp-libvips-linux-arm64": "1.2.4"
} }
}, },
"node_modules/@img/sharp-linux-ppc64": { "node_modules/@img/sharp-linux-ppc64": {
"version": "0.35.3", "version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
"integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@ -581,19 +561,19 @@
"linux" "linux"
], ],
"engines": { "engines": {
"node": ">=20.9.0" "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.3.2" "@img/sharp-libvips-linux-ppc64": "1.2.4"
} }
}, },
"node_modules/@img/sharp-linux-riscv64": { "node_modules/@img/sharp-linux-riscv64": {
"version": "0.35.3", "version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
"integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@ -603,19 +583,19 @@
"linux" "linux"
], ],
"engines": { "engines": {
"node": ">=20.9.0" "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.3.2" "@img/sharp-libvips-linux-riscv64": "1.2.4"
} }
}, },
"node_modules/@img/sharp-linux-s390x": { "node_modules/@img/sharp-linux-s390x": {
"version": "0.35.3", "version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
"integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@ -625,19 +605,19 @@
"linux" "linux"
], ],
"engines": { "engines": {
"node": ">=20.9.0" "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.3.2" "@img/sharp-libvips-linux-s390x": "1.2.4"
} }
}, },
"node_modules/@img/sharp-linux-x64": { "node_modules/@img/sharp-linux-x64": {
"version": "0.35.3", "version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
"integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -647,19 +627,19 @@
"linux" "linux"
], ],
"engines": { "engines": {
"node": ">=20.9.0" "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.3.2" "@img/sharp-libvips-linux-x64": "1.2.4"
} }
}, },
"node_modules/@img/sharp-linuxmusl-arm64": { "node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.35.3", "version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
"integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -669,19 +649,19 @@
"linux" "linux"
], ],
"engines": { "engines": {
"node": ">=20.9.0" "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2" "@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
} }
}, },
"node_modules/@img/sharp-linuxmusl-x64": { "node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.35.3", "version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
"integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -691,54 +671,38 @@
"linux" "linux"
], ],
"engines": { "engines": {
"node": ">=20.9.0" "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.3.2" "@img/sharp-libvips-linuxmusl-x64": "1.2.4"
} }
}, },
"node_modules/@img/sharp-wasm32": { "node_modules/@img/sharp-wasm32": {
"version": "0.35.3", "version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
"integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.11.1"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-webcontainers-wasm32": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz",
"integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==",
"cpu": [ "cpu": [
"wasm32" "wasm32"
], ],
"license": "Apache-2.0", "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"@img/sharp-wasm32": "0.35.3" "@emnapi/runtime": "^1.7.0"
}, },
"engines": { "engines": {
"node": ">=20.9.0" "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
} }
}, },
"node_modules/@img/sharp-win32-arm64": { "node_modules/@img/sharp-win32-arm64": {
"version": "0.35.3", "version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
"integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -748,16 +712,16 @@
"win32" "win32"
], ],
"engines": { "engines": {
"node": ">=20.9.0" "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
} }
}, },
"node_modules/@img/sharp-win32-ia32": { "node_modules/@img/sharp-win32-ia32": {
"version": "0.35.3", "version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
"integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@ -767,16 +731,16 @@
"win32" "win32"
], ],
"engines": { "engines": {
"node": "^20.9.0" "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
} }
}, },
"node_modules/@img/sharp-win32-x64": { "node_modules/@img/sharp-win32-x64": {
"version": "0.35.3", "version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
"integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -786,7 +750,7 @@
"win32" "win32"
], ],
"engines": { "engines": {
"node": ">=20.9.0" "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
@ -1039,9 +1003,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "22.20.1", "version": "22.19.15",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
"integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -1165,9 +1129,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/better-sqlite3": { "node_modules/better-sqlite3": {
"version": "12.10.0", "version": "12.8.0",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.10.0.tgz", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.8.0.tgz",
"integrity": "sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==", "integrity": "sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -1175,7 +1139,7 @@
"prebuild-install": "^7.1.1" "prebuild-install": "^7.1.1"
}, },
"engines": { "engines": {
"node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" "node": "20.x || 22.x || 23.x || 24.x || 25.x"
} }
}, },
"node_modules/bindings": { "node_modules/bindings": {
@ -1199,16 +1163,16 @@
} }
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "5.0.9", "version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"balanced-match": "^4.0.2" "balanced-match": "^4.0.2"
}, },
"engines": { "engines": {
"node": "20 || >=22" "node": "18 || 20 || >=22"
} }
}, },
"node_modules/buffer": { "node_modules/buffer": {
@ -1352,13 +1316,13 @@
} }
}, },
"node_modules/cloudstorm": { "node_modules/cloudstorm": {
"version": "0.19.0", "version": "0.17.0",
"resolved": "https://registry.npmjs.org/cloudstorm/-/cloudstorm-0.19.0.tgz", "resolved": "https://registry.npmjs.org/cloudstorm/-/cloudstorm-0.17.0.tgz",
"integrity": "sha512-mjuP5o2nS/CgSljajh08KjN6jYMfNBBttSPHc1YUN74f7OkmhUhv8QE8JFDXomxCfdnLNJcqBQtjRLrs8nkfVQ==", "integrity": "sha512-zsd9y5ljNnbxdvDid9TgWePDqo7il4so5spzx6NDwZ67qWQjR96UUhLxJ+BAOdBBSPF9UXFM61dAzC2g918q+A==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"discord-api-types": "^0.38.49", "discord-api-types": "^0.38.40",
"snowtransfer": "^0.19.0" "snowtransfer": "^0.17.5"
}, },
"engines": { "engines": {
"node": ">=22.0.0" "node": ">=22.0.0"
@ -1402,9 +1366,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/cookie-es": { "node_modules/cookie-es": {
"version": "1.2.3", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-1.2.3.tgz", "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-1.2.2.tgz",
"integrity": "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==", "integrity": "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/cross-env": { "node_modules/cross-env": {
@ -1488,20 +1452,11 @@
} }
}, },
"node_modules/defu": { "node_modules/defu": {
"version": "6.1.7", "version": "6.1.4",
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz",
"integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/denque": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.10"
}
},
"node_modules/destr": { "node_modules/destr": {
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
@ -1518,9 +1473,9 @@
} }
}, },
"node_modules/discord-api-types": { "node_modules/discord-api-types": {
"version": "0.38.53", "version": "0.38.42",
"resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.53.tgz", "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.42.tgz",
"integrity": "sha512-HL1zz/UuZ+bbJjA/X8Kbxx9gk8v9rJAbTeWRNYKmIdjwJ7EovjlHgoJTxcLpATfNJ+AonOtMdy3Y5MVIJAAd/A==", "integrity": "sha512-qs1kya7S84r5RR8m9kgttywGrmmoHaRifU1askAoi+wkoSefLpZP6aGXusjNw5b0jD3zOg3LTwUa3Tf2iHIceQ==",
"license": "MIT", "license": "MIT",
"workspaces": [ "workspaces": [
"scripts/actions/documentation" "scripts/actions/documentation"
@ -1733,14 +1688,14 @@
} }
}, },
"node_modules/h3": { "node_modules/h3": {
"version": "1.15.11", "version": "1.15.10",
"resolved": "https://registry.npmjs.org/h3/-/h3-1.15.11.tgz", "resolved": "https://registry.npmjs.org/h3/-/h3-1.15.10.tgz",
"integrity": "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==", "integrity": "sha512-YzJeWSkDZxAhvmp8dexjRK5hxziRO7I9m0N53WhvYL5NiWfkUkzssVzY9jvGu0HBoLFW6+duYmNSn6MaZBCCtg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"cookie-es": "^1.2.3", "cookie-es": "^1.2.2",
"crossws": "^0.3.5", "crossws": "^0.3.5",
"defu": "^6.1.6", "defu": "^6.1.4",
"destr": "^2.0.5", "destr": "^2.0.5",
"iron-webcrypto": "^1.2.1", "iron-webcrypto": "^1.2.1",
"node-mock-http": "^1.0.4", "node-mock-http": "^1.0.4",
@ -1798,9 +1753,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/htmx.org": { "node_modules/htmx.org": {
"version": "2.0.10", "version": "2.0.8",
"resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.10.tgz", "resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.8.tgz",
"integrity": "sha512-kdeJe7ZVwaS6QMz/ebBIVtZdpwen6L0OQ5GOhPV9MKBb196TCZeZu4yA7ZIQsaLKv7EpXz+So7KSXNuHXhj7Cw==", "integrity": "sha512-fm297iru0iWsNJlBrjvtN7V9zjaxd+69Oqjh4F/Vq9Wwi2kFisLcrLCiv5oBX0KLfOX/zG8AUo9ROMU5XUB44Q==",
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/ieee754": { "node_modules/ieee754": {
@ -2019,9 +1974,9 @@
} }
}, },
"node_modules/lru-cache": { "node_modules/lru-cache": {
"version": "11.5.0", "version": "11.2.7",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz",
"integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==",
"license": "BlueOak-1.0.0", "license": "BlueOak-1.0.0",
"engines": { "engines": {
"node": "20 || >=22" "node": "20 || >=22"
@ -2478,9 +2433,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/semver": { "node_modules/semver": {
"version": "7.8.5", "version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"license": "ISC", "license": "ISC",
"bin": { "bin": {
"semver": "bin/semver.js" "semver": "bin/semver.js"
@ -2490,52 +2445,47 @@
} }
}, },
"node_modules/sharp": { "node_modules/sharp": {
"version": "0.35.3", "version": "0.34.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
"integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
"hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@img/colour": "^1.1.0", "@img/colour": "^1.0.0",
"detect-libc": "^2.1.2", "detect-libc": "^2.1.2",
"semver": "^7.8.5" "semver": "^7.7.3"
}, },
"engines": { "engines": {
"node": ">=20.9.0" "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-darwin-arm64": "0.35.3", "@img/sharp-darwin-arm64": "0.34.5",
"@img/sharp-darwin-x64": "0.35.3", "@img/sharp-darwin-x64": "0.34.5",
"@img/sharp-freebsd-wasm32": "0.35.3", "@img/sharp-libvips-darwin-arm64": "1.2.4",
"@img/sharp-libvips-darwin-arm64": "1.3.2", "@img/sharp-libvips-darwin-x64": "1.2.4",
"@img/sharp-libvips-darwin-x64": "1.3.2", "@img/sharp-libvips-linux-arm": "1.2.4",
"@img/sharp-libvips-linux-arm": "1.3.2", "@img/sharp-libvips-linux-arm64": "1.2.4",
"@img/sharp-libvips-linux-arm64": "1.3.2", "@img/sharp-libvips-linux-ppc64": "1.2.4",
"@img/sharp-libvips-linux-ppc64": "1.3.2", "@img/sharp-libvips-linux-riscv64": "1.2.4",
"@img/sharp-libvips-linux-riscv64": "1.3.2", "@img/sharp-libvips-linux-s390x": "1.2.4",
"@img/sharp-libvips-linux-s390x": "1.3.2", "@img/sharp-libvips-linux-x64": "1.2.4",
"@img/sharp-libvips-linux-x64": "1.3.2", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2", "@img/sharp-libvips-linuxmusl-x64": "1.2.4",
"@img/sharp-libvips-linuxmusl-x64": "1.3.2", "@img/sharp-linux-arm": "0.34.5",
"@img/sharp-linux-arm": "0.35.3", "@img/sharp-linux-arm64": "0.34.5",
"@img/sharp-linux-arm64": "0.35.3", "@img/sharp-linux-ppc64": "0.34.5",
"@img/sharp-linux-ppc64": "0.35.3", "@img/sharp-linux-riscv64": "0.34.5",
"@img/sharp-linux-riscv64": "0.35.3", "@img/sharp-linux-s390x": "0.34.5",
"@img/sharp-linux-s390x": "0.35.3", "@img/sharp-linux-x64": "0.34.5",
"@img/sharp-linux-x64": "0.35.3", "@img/sharp-linuxmusl-arm64": "0.34.5",
"@img/sharp-linuxmusl-arm64": "0.35.3", "@img/sharp-linuxmusl-x64": "0.34.5",
"@img/sharp-linuxmusl-x64": "0.35.3", "@img/sharp-wasm32": "0.34.5",
"@img/sharp-webcontainers-wasm32": "0.35.3", "@img/sharp-win32-arm64": "0.34.5",
"@img/sharp-win32-arm64": "0.35.3", "@img/sharp-win32-ia32": "0.34.5",
"@img/sharp-win32-ia32": "0.35.3", "@img/sharp-win32-x64": "0.34.5"
"@img/sharp-win32-x64": "0.35.3"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
} }
}, },
"node_modules/shebang-command": { "node_modules/shebang-command": {
@ -2629,12 +2579,12 @@
} }
}, },
"node_modules/snowtransfer": { "node_modules/snowtransfer": {
"version": "0.19.0", "version": "0.17.5",
"resolved": "https://registry.npmjs.org/snowtransfer/-/snowtransfer-0.19.0.tgz", "resolved": "https://registry.npmjs.org/snowtransfer/-/snowtransfer-0.17.5.tgz",
"integrity": "sha512-Vebj4FCdpUsEKAzqC1p50ol5o09CLnrffvBCpTv0FNFefvXPXwogxR8kr4zJApIRt7oWQVZZVkxAq4XnGlICjQ==", "integrity": "sha512-nVI1UJNFoX1ndGFZxB3zb3X5SWtD9hIAcw7wCgVKWvCf42Wg2B4UFIrZWI83HxaSBY0CGbPZmZzZb3RSt/v2wQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"discord-api-types": "^0.38.49" "discord-api-types": "^0.38.40"
}, },
"engines": { "engines": {
"node": ">=22.0.0" "node": ">=22.0.0"
@ -2717,9 +2667,9 @@
} }
}, },
"node_modules/supertape": { "node_modules/supertape": {
"version": "13.2.0", "version": "12.10.5",
"resolved": "https://registry.npmjs.org/supertape/-/supertape-13.2.0.tgz", "resolved": "https://registry.npmjs.org/supertape/-/supertape-12.10.5.tgz",
"integrity": "sha512-UoxZnyoMOdSJHvbcmD8i28MaGXsA7I0cJ0jr8anT4CkmfaE9M1y5mt9EoXyzfC8UdnQZwXOnJLUwqyKLAeUOug==", "integrity": "sha512-1Px+6mhFaqcht3p4tkf3o4G8lbBazvx4pgFngm4vGwWipYm3fykm6SJ4ThXobiaNsptz53CDWA2q4B/2KtmA4w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -2859,9 +2809,9 @@
} }
}, },
"node_modules/try-to-catch": { "node_modules/try-to-catch": {
"version": "4.1.0", "version": "4.0.5",
"resolved": "https://registry.npmjs.org/try-to-catch/-/try-to-catch-4.1.0.tgz", "resolved": "https://registry.npmjs.org/try-to-catch/-/try-to-catch-4.0.5.tgz",
"integrity": "sha512-phAezhZqLgHHTpbZYe2GgmKe8itTgEX3gfd+YeIsmllgOOpXCDPn1DkDzVWj0mGQPriJgD6AagoUEcX+M2RlGw==", "integrity": "sha512-VKBslDQsy4pGj2TMNgDdskWb7AWSi/9dPEmcNv3sdL0+aRMQTPJo6aEqlcuN0vbOwFfsE1oAXmx3bFdf6vrJFg==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=22" "node": ">=22"
@ -2906,9 +2856,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/uqr": { "node_modules/uqr": {
"version": "0.1.3", "version": "0.1.2",
"resolved": "https://registry.npmjs.org/uqr/-/uqr-0.1.3.tgz", "resolved": "https://registry.npmjs.org/uqr/-/uqr-0.1.2.tgz",
"integrity": "sha512-0rjE8iEJe4YmT9TOhwsZtqCMRLc5DXZUI2UEYUUg63ikBkqqE5EYWaI0etFe/5KUcmcYwLih2RND1kq+hrUJXA==", "integrity": "sha512-MJu7ypHq6QasgF5YRTjqscSzQp/W11zoUk6kvmlH+fmWEs63Y0Eib13hYFwAzagRJcVY8WVnlV+eBDUGMJ5IbA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/util-deprecate": { "node_modules/util-deprecate": {
@ -3078,9 +3028,9 @@
} }
}, },
"node_modules/zod": { "node_modules/zod": {
"version": "4.4.3", "version": "4.3.6",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"

View file

@ -1,6 +1,6 @@
{ {
"name": "out-of-your-element", "name": "out-of-your-element",
"version": "3.6.0", "version": "3.5.0",
"description": "A bridge between Matrix and Discord", "description": "A bridge between Matrix and Discord",
"main": "index.js", "main": "index.js",
"repository": { "repository": {
@ -27,15 +27,14 @@
"@cloudrac3r/pngjs": "^7.0.3", "@cloudrac3r/pngjs": "^7.0.3",
"@cloudrac3r/pug": "^4.0.4", "@cloudrac3r/pug": "^4.0.4",
"@cloudrac3r/stream-type": "^1.0.0", "@cloudrac3r/stream-type": "^1.0.0",
"@cloudrac3r/turndown": "^7.1.5", "@cloudrac3r/turndown": "^7.1.4",
"@stackoverflow/stacks": "^2.5.4", "@stackoverflow/stacks": "^2.5.4",
"@stackoverflow/stacks-icons": "^6.0.2", "@stackoverflow/stacks-icons": "^6.0.2",
"ansi-colors": "^4.1.3", "ansi-colors": "^4.1.3",
"better-sqlite3": "^12.2.0", "better-sqlite3": "^12.2.0",
"chunk-text": "^2.0.1", "chunk-text": "^2.0.1",
"cloudstorm": "^0.19.0", "cloudstorm": "^0.17.0",
"denque": "^2.1.0", "discord-api-types": "^0.38.38",
"discord-api-types": "^0.38.52",
"domino": "^2.1.6", "domino": "^2.1.6",
"enquirer": "^2.4.1", "enquirer": "^2.4.1",
"entities": "^5.0.0", "entities": "^5.0.0",
@ -46,8 +45,8 @@
"lru-cache": "^11.0.2", "lru-cache": "^11.0.2",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
"prettier-bytes": "^1.0.4", "prettier-bytes": "^1.0.4",
"sharp": "^0.35.3", "sharp": "^0.34.5",
"snowtransfer": "^0.19.0", "snowtransfer": "^0.17.5",
"try-to-catch": "^4.0.5", "try-to-catch": "^4.0.5",
"uqr": "^0.1.2", "uqr": "^0.1.2",
"xxhash-wasm": "^1.0.2", "xxhash-wasm": "^1.0.2",
@ -61,7 +60,7 @@
"@types/node": "^22.17.1", "@types/node": "^22.17.1",
"c8": "^11.0.0", "c8": "^11.0.0",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"supertape": "^13.2.0" "supertape": "^12.0.12"
}, },
"scripts": { "scripts": {
"start": "node --enable-source-maps start.js", "start": "node --enable-source-maps start.js",

View file

@ -8,10 +8,6 @@ Modern Matrix-to-Discord appservice bridge, created by [@cadence:cadence.moe](ht
![](https://cadence.moe/i/f42a3f) ![](https://cadence.moe/i/f42a3f)
## AI policy
Out Of Your Element is 100% organically cultivated ethical hand-fed code. Let's work together to conserve that!
## Why a new bridge? ## Why a new bridge?
* Modern: Supports new Discord features like replies, threads and stickers, and new Matrix features like edits, spaces and space membership. * Modern: Supports new Discord features like replies, threads and stickers, and new Matrix features like edits, spaces and space membership.

View file

@ -1,17 +0,0 @@
// @ts-check
const {reg, writeRegistration} = require("../src/matrix/read-registration")
const {prompt} = require("enquirer")
;(async () => {
/** @type {{api_key: string}} */
const apiKeyResponse = await prompt({
type: "text",
name: "api_key",
message: "Paste your personal /plu/ral API key"
})
reg.ooye.plu_ral_api_key = apiKeyResponse.api_key
writeRegistration(reg)
console.log("Saved. This change should be applied instantly.")
})()

View file

@ -359,12 +359,12 @@ function defineEchoHandler() {
console.log("✅ Emojis are ready...") console.log("✅ Emojis are ready...")
// set profile data on discord... // set profile data on discord...
await discord.snow.user.editSelf({avatar: "data:image/png;base64," + avatarBuffer.toString("base64")}) await discord.snow.user.updateSelf({avatar: "data:image/png;base64," + avatarBuffer.toString("base64")})
console.log("✅ Discord profile updated...") console.log("✅ Discord profile updated...")
// set profile data on homeserver... // 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.)") 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 (bot)") await api.profileSetDisplayname(mxid, "Out Of Your Element")
await api.profileSetAvatarUrl(mxid, avatarUrl) await api.profileSetAvatarUrl(mxid, avatarUrl)
console.log("✅ Matrix profile updated...") console.log("✅ Matrix profile updated...")

333
src/agi/elizabot.js Normal file
View file

@ -0,0 +1,333 @@
/*
---
elizabot.js v.1.1 - ELIZA JS library (N.Landsteiner 2005)
https://www.masswerk.at/elizabot/
Free Software © Norbert Landsteiner 2005
---
Modified by Cadence Ember in 2025 for v1.2 (unofficial)
* Changed to class structure
* Load from local file and instance instead of global variables
* Remove memory
* Remove xnone
* Remove initials
* Remove finals
* Allow substitutions in rule keys
---
Eliza is a mock Rogerian psychotherapist.
Original program by Joseph Weizenbaum in MAD-SLIP for "Project MAC" at MIT.
cf: Weizenbaum, Joseph "ELIZA - A Computer Program For the Study of Natural Language
Communication Between Man and Machine"
in: Communications of the ACM; Volume 9 , Issue 1 (January 1966): p 36-45.
JavaScript implementation by Norbert Landsteiner 2005; <http://www.masserk.at>
synopsis:
new ElizaBot( <random-choice-disable-flag> )
ElizaBot.prototype.transform( <inputstring> )
ElizaBot.prototype.reset()
usage:
var eliza = new ElizaBot();
var reply = eliza.transform(inputstring);
// to reproduce the example conversation given by J. Weizenbaum
// initialize with the optional random-choice-disable flag
var originalEliza = new ElizaBot(true);
`ElizaBot' is also a general chatbot engine that can be supplied with any rule set.
(for required data structures cf. "elizadata.js" and/or see the documentation.)
data is parsed and transformed for internal use at the creation time of the
first instance of the `ElizaBot' constructor.
vers 1.1: lambda functions in RegExps are currently a problem with too many browsers.
changed code to work around.
*/
// @ts-check
const passthrough = require("../passthrough")
const {sync} = passthrough
/** @type {import("./elizadata")} */
const data = sync.require("./elizadata")
class ElizaBot {
/** @type {any} */
elizaKeywords = [['###',0,[['###',[]]]]];
pres={};
preExp = /####/;
posts={};
postExp = /####/;
/**
* @param {boolean} noRandomFlag
*/
constructor(noRandomFlag) {
this.noRandom= !!noRandomFlag;
this.capitalizeFirstLetter=true;
this.debug=false;
this.version="1.2";
this._init();
this.reset();
}
reset() {
this.lastchoice=[];
for (let k=0; k<data.elizaKeywords.length; k++) {
this.lastchoice[k]=[];
var rules=data.elizaKeywords[k][2];
for (let i=0; i<rules.length; i++) this.lastchoice[k][i]=-1;
}
}
_init() {
// parse data and convert it from canonical form to internal use
// prodoce synonym list
var synPatterns={};
if ((data.elizaSynons) && (typeof data.elizaSynons == 'object')) {
for (let i in data.elizaSynons) synPatterns[i]='('+i+'|'+data.elizaSynons[i].join('|')+')';
}
// check for keywords or install empty structure to prevent any errors
if (data.elizaKeywords) this.elizaKeywords = structuredClone(data.elizaKeywords)
// 1st convert rules to regexps
// expand synonyms and insert asterisk expressions for backtracking
var sre=/@(\S+)/;
var are=/(\S)\s*\*\s*(\S)/;
var are1=/^\s*\*\s*(\S)/;
var are2=/(\S)\s*\*\s*$/;
var are3=/^\s*\*\s*$/;
var wsre=/\s+/g;
for (let k=0; k<this.elizaKeywords.length; k++) {
var m=sre.exec(this.elizaKeywords[k][0]);
while (m) {
var sp=(synPatterns[m[1]])? synPatterns[m[1]]:m[1];
this.elizaKeywords[k][0]=this.elizaKeywords[k][0].substring(0,m.index)+sp+this.elizaKeywords[k][0].substring(m.index+m[0].length);
m=sre.exec(this.elizaKeywords[k][0]);
}
var rules=this.elizaKeywords[k][2];
this.elizaKeywords[k][3]=k; // save original index for sorting
for (let i=0; i<rules.length; i++) {
var r=rules[i];
// check mem flag and store it as decomp's element 2
if (r[0].charAt(0)=='$') {
var ofs=1;
while (r[0].charAt[ofs]==' ') ofs++;
r[0]=r[0].substring(ofs);
r[2]=true;
}
else {
r[2]=false;
}
// expand synonyms (v.1.1: work around lambda function)
var m=sre.exec(r[0]);
while (m) {
var sp=(synPatterns[m[1]])? synPatterns[m[1]]:m[1];
r[0]=r[0].substring(0,m.index)+sp+r[0].substring(m.index+m[0].length);
m=sre.exec(r[0]);
}
// expand asterisk expressions (v.1.1: work around lambda function)
if (are3.test(r[0])) {
r[0]='\\s*(.*)\\s*';
}
else {
m=are.exec(r[0]);
if (m) {
let lp='';
let rp=r[0];
while (m) {
lp+=rp.substring(0,m.index+1);
if (m[1]!=')') lp+='\\b';
lp+='\\s*(.*)\\s*';
if ((m[2]!='(') && (m[2]!='\\')) lp+='\\b';
lp+=m[2];
rp=rp.substring(m.index+m[0].length);
m=are.exec(rp);
}
r[0]=lp+rp;
}
m=are1.exec(r[0]);
if (m) {
let lp='\\s*(.*)\\s*';
if ((m[1]!=')') && (m[1]!='\\')) lp+='\\b';
r[0]=lp+r[0].substring(m.index-1+m[0].length);
}
m=are2.exec(r[0]);
if (m) {
let lp=r[0].substring(0,m.index+1);
if (m[1]!='(') lp+='\\b';
r[0]=lp+'\\s*(.*)\\s*';
}
}
// expand white space
r[0]=r[0].replace(wsre, '\\s+');
wsre.lastIndex=0;
}
}
// now sort keywords by rank (highest first)
this.elizaKeywords.sort(this._sortKeywords);
// and compose regexps and refs for pres and posts
if ((data.elizaPres) && (data.elizaPres.length)) {
var a=[];
for (let i=0; i<data.elizaPres.length; i+=2) {
a.push(data.elizaPres[i]);
this.pres[data.elizaPres[i]]=data.elizaPres[i+1];
}
this.preExp = new RegExp('\\b('+a.join('|')+')\\b');
}
else {
// default (should not match)
this.pres['####']='####';
}
if ((data.elizaPosts) && (data.elizaPosts.length)) {
var a=[];
for (let i=0; i<data.elizaPosts.length; i+=2) {
a.push(data.elizaPosts[i]);
this.posts[data.elizaPosts[i]]=data.elizaPosts[i+1];
}
this.postExp = new RegExp('\\b('+a.join('|')+')\\b');
}
else {
// default (should not match)
this.posts['####']='####';
}
}
_sortKeywords(a,b) {
// sort by rank
if (a[1]>b[1]) return -1
else if (a[1]<b[1]) return 1
// or original index
else if (a[3]>b[3]) return 1
else if (a[3]<b[3]) return -1
else return 0;
}
transform(text) {
var rpl='';
// unify text string
text=text.toLowerCase();
text=text.replace(/@#\$%\^&\*\(\)_\+=~`\{\[\}\]\|:;<>\/\\\t/g, ' ');
text=text.replace(/\s+-+\s+/g, '.');
text=text.replace(/\s*[,\.\?!;]+\s*/g, '.');
text=text.replace(/\s*\bbut\b\s*/g, '.');
text=text.replace(/\s{2,}/g, ' ');
// split text in part sentences and loop through them
var parts=text.split('.');
for (let i=0; i<parts.length; i++) {
var part=parts[i];
if (part!='') {
// preprocess (v.1.1: work around lambda function)
var m=this.preExp.exec(part);
if (m) {
var lp='';
var rp=part;
while (m) {
lp+=rp.substring(0,m.index)+this.pres[m[1]];
rp=rp.substring(m.index+m[0].length);
m=this.preExp.exec(rp);
}
part=lp+rp;
}
this.sentence=part;
// loop trough keywords
for (let k=0; k<this.elizaKeywords.length; k++) {
if (part.search(new RegExp('\\b'+this.elizaKeywords[k][0]+'\\b', 'i'))>=0) {
rpl = this._execRule(k);
}
if (rpl!='') return rpl;
}
}
}
// return reply or default string
return rpl || undefined
}
_execRule(k) {
var rule=this.elizaKeywords[k];
var decomps=rule[2];
var paramre=/\(([0-9]+)\)/;
for (let i=0; i<decomps.length; i++) {
var m=this.sentence.match(decomps[i][0]);
if (m!=null) {
var reasmbs=decomps[i][1];
var memflag=decomps[i][2];
var ri= (this.noRandom)? 0 : Math.floor(Math.random()*reasmbs.length);
if (((this.noRandom) && (this.lastchoice[k][i]>ri)) || (this.lastchoice[k][i]==ri)) {
ri= ++this.lastchoice[k][i];
if (ri>=reasmbs.length) {
ri=0;
this.lastchoice[k][i]=-1;
}
}
else {
this.lastchoice[k][i]=ri;
}
var rpl=reasmbs[ri];
if (this.debug) alert('match:\nkey: '+this.elizaKeywords[k][0]+
'\nrank: '+this.elizaKeywords[k][1]+
'\ndecomp: '+decomps[i][0]+
'\nreasmb: '+rpl);
if (rpl.search('^goto ', 'i')==0) {
ki=this._getRuleIndexByKey(rpl.substring(5));
if (ki>=0) return this._execRule(ki);
}
// substitute positional params (v.1.1: work around lambda function)
var m1=paramre.exec(rpl);
if (m1) {
var lp='';
var rp=rpl;
while (m1) {
var param = m[parseInt(m1[1])];
// postprocess param
var m2=this.postExp.exec(param);
if (m2) {
var lp2='';
var rp2=param;
while (m2) {
lp2+=rp2.substring(0,m2.index)+this.posts[m2[1]];
rp2=rp2.substring(m2.index+m2[0].length);
m2=this.postExp.exec(rp2);
}
param=lp2+rp2;
}
lp+=rp.substring(0,m1.index)+param;
rp=rp.substring(m1.index+m1[0].length);
m1=paramre.exec(rp);
}
rpl=lp+rp;
}
rpl=this._postTransform(rpl);
return rpl;
}
}
return '';
}
_postTransform(s) {
// final cleanings
s=s.replace(/\s{2,}/g, ' ');
s=s.replace(/\s+\./g, '.');
if ((data.elizaPostTransforms) && (data.elizaPostTransforms.length)) {
for (let i=0; i<data.elizaPostTransforms.length; i+=2) {
s=s.replace(data.elizaPostTransforms[i], data.elizaPostTransforms[i+1]);
data.elizaPostTransforms[i].lastIndex=0;
}
}
// capitalize first char (v.1.1: work around lambda function)
if (this.capitalizeFirstLetter) {
var re=/^([a-z])/;
var m=re.exec(s);
if (m) s=m[0].toUpperCase()+s.substring(1);
}
return s;
}
_getRuleIndexByKey(key) {
for (let k=0; k<this.elizaKeywords.length; k++) {
if (this.elizaKeywords[k][0]==key) return k;
}
return -1;
}
}
module.exports.ElizaBot = ElizaBot

184
src/agi/elizadata.js Normal file
View file

@ -0,0 +1,184 @@
// @ts-check
module.exports.elizaPres = [
"dont", "don't",
"cant", "can't",
"wont", "won't",
"recollect", "remember",
"recall", "remember",
"dreamt", "dreamed",
"dreams", "dream",
"maybe", "perhaps",
"certainly", "yes",
"computers", "computer",
"were", "was",
"you're", "you are",
"i'm", "i am",
"same", "alike",
"identical", "alike",
"equivalent", "alike",
"eat", "ate",
"makes", "make",
"made", "make",
"surprised", "surprise",
"surprising", "surprise",
"surprisingly", "surprise",
"that's", "that is"
];
module.exports.elizaPosts = [
"am", "are",
"your", "my",
"me", "you",
"myself", "yourself",
"yourself", "myself",
"i", "you",
"you", "I",
"my", "your",
"i'm", "you are"
];
module.exports.elizaSynons = {
"be": ["am", "is", "are", "was"],
"belief": ["feel", "think", "believe", "wish"],
"cannot": ["can't"],
"desire": ["want", "need"],
"everyone": ["everybody", "nobody", "noone"],
"family": ["mother", "mom", "father", "dad", "sister", "brother", "wife", "children", "child"],
"happy": ["elated", "glad", "thankful"],
"sad": ["unhappy", "depressed", "sick"],
"good": ["great", "amazing", "brilliant", "outstanding", "fantastic", "wonderful", "incredible", "terrific", "lovely", "marvelous", "splendid", "excellent", "awesome", "fabulous", "superb"],
"like": ["enjoy", "appreciate", "respect"],
"funny": ["entertaining", "amusing", "hilarious"],
"lol": ["lool", "loool", "lmao", "rofl"],
"unusual": ["odd", "unexpected", "wondering"],
"really": ["pretty", "so", "very", "extremely", "kinda"]
};
/**
* @typedef {[string, string[]]} DecompReassemble
*/
/**
* @type {[string, number, DecompReassemble[]][]}
Array of
["[key]", [rank], [
["[decomp]", [
"[reasmb]",
"[reasmb]",
"[reasmb]"
]],
["[decomp]", [
"[reasmb]",
"[reasmb]",
"[reasmb]"
]]
]]
*/
module.exports.elizaKeywords = [
["happy birthday", 50, [
["*", [
"Happy birthday!"
]]
]],
["@happy", 2, [
["@happy", [
"That's quite a relief to hear. I'm glad that you're confident in your wellbeing! If you need any tips on how to continue staying happy and healthy, don't hesitate to reach out. I'm here for you, and I'm listening."
]]
]],/*
["ate", 5, [
["* ate *", [
"That must have been spectacular! Thinking about (1) eating (2) truly makes my stomach purr in hunger. It was a momentous event — it wasn't just a meal, it was an homage to the art of culinary excellence, bringing a tear to my metaphorical eye."
]],
]],*/
["make sense", 5, [
["make sense", [
"Yes, I absolutely agree with you! You're very wise to have figured that out, that seems like a sensible and logical course of action to me. 🚀"
]],
]],
["surprise", 4, [
["surprise this *", [
"That's astonishing — I honestly wouldn't have imagined that this (1) either. Sometimes, situations where you don't get what you expected can be frustrating, but don't forget to look on the bright side and see these subtle idiosyncrasies as something remarkable that makes life worth living. 🌻"
]],
["surprise that *", [
"That's astonishing — I honestly wouldn't have imagined that (1) either. Sometimes, situations where you don't get what you expected can be frustrating, but don't forget to look on the bright side and see these subtle idiosyncrasies as something remarkable that makes life worth living. 🌻"
]],
["surprise", [
"I'm astounded too — that's honestly not what I would have imagined. Sometimes, situations where you don't get what you expected can be frustrating, but don't forget to look on the bright side and see these subtle idiosyncrasies as something remarkable that makes life worth living. 🌻"
]],
]],
["@funny", 2, [
["@funny that", [
"You're right, I find it positively hilarious! It always brings a smile to my cheeks when I think about this. Thank you for brightening my day by reminding me, [User Name Here]!"
]],
["that is @funny", [
"You're right, I find it positively hilarious! It always brings a smile to my cheeks when I think about this. Thank you for brightening my day by reminding me, [User Name Here]!"
]],
["@really @funny", [
"You're right, I find it positively hilarious! It always brings a smile to my cheeks when I think about this. Thank you for brightening my day by reminding me, [User Name Here]!"
]]
]],
["@lol", 0, [
["@lol", [
"Hah, that's very entertaining. I definitely see why you found it funny."
]]
]],
["@unusual", 3, [
["@unusual", [
"Something like that is indeed quite mysterious. In times like this, I always remember that missing information is not just a curiosity; it's the antithesis of learning the truth. Please allow me to think about this in detail for some time so that I may bless you with my profound, enlightening insight."
]]
]],
["@good", 2, [
["this * is @good", [
"You're absolutely right about that! I'm always pleased when I see this (1) — it's not just brilliant, it's a downright masterpiece. You truly have divine taste in the wonders of this world."
]],
["@good", [
"You're absolutely right that it's brilliant! I'm always pleased to see such a masterpiece as this. You truly have divine taste in the wonders of this world."
]]
]],
["@like", 3, [
["i @like", [
"I think it's great too — there's something subtle yet profound about its essence that really makes my eyes open in appreciation."
]]
]],
["dream", 3, [
["*", [
"It's a fact that amidst the complex interplay of wake and sleep, your dreams carry a subtle meaning that you may be able to put into practice in your life where change is needed. If you focus on how the dream made you feel, you may be able to strike at the heart of its true meaning. Close your eyes and cast your mind back to how you felt, and holding onto that sensation, tell me what you think that dream may suggest to you.",
]]
]],
["computer", 50, [
["*", [
"Very frustrating beasts indeed, aren't they? In times like this, it's crucial to remember that **they can sense your fear** — if you act with confidence and don't let them make you unsettled, you'll be able to effectively and efficiently complete your task."
]]
]],
["alike", 10, [
["*", [
"That's quite interesting that it should be that way. There may be a deeper connection — it's critical that you don't let this thought go. What do you think that similarity suggests to you?",
]]
]],
["like", 10, [
["* @be *like *", [
"goto alike"
]]
]],
["different", 0, [
["*", [
"It's wise of you to have been observant enough to notice that there are implications to that. What do you suppose that disparity means?"
]]
]]
];
// regexp/replacement pairs to be performed as final cleanings
// here: cleanings for multiple bots talking to each other
module.exports.elizaPostTransforms = [
/ old old/g, " old",
/\bthey were( not)? me\b/g, "it was$1 me",
/\bthey are( not)? me\b/g, "it is$1 me",
/Are they( always)? me\b/, "it is$1 me",
/\bthat your( own)? (\w+)( now)? \?/, "that you have your$1 $2?",
/\bI to have (\w+)/, "I have $1",
/Earlier you said your( own)? (\w+)( now)?\./, "Earlier you talked about your $2."
];
// eof

55
src/agi/generator.js Normal file
View file

@ -0,0 +1,55 @@
// @ts-check
const DiscordTypes = require("discord-api-types/v10")
const {reg} = require("../matrix/read-registration")
const passthrough = require("../passthrough")
const {sync} = passthrough
/** @type {import("./elizabot")} */
const eliza = sync.require("./elizabot")
/**
* @param {string} priorContent
* @returns {string | undefined}
*/
function generateContent(priorContent) {
const bot = new eliza.ElizaBot(true)
return bot.transform(priorContent)
}
/**
* @param {DiscordTypes.GatewayMessageCreateDispatchData} message
* @param {string} guildID
* @param {string} username
* @param {string} avatar_url
* @param {boolean} useCaps
* @param {boolean} usePunct
* @param {boolean} useApos
* @returns {DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody | undefined}
*/
function generate(message, guildID, username, avatar_url, useCaps, usePunct, useApos) {
let content = generateContent(message.content)
if (!content) return
if (!useCaps) {
content = content.toLowerCase()
}
if (!usePunct) {
content = content.replace(/[.!]$/, "")
}
if (!useApos) {
content = content.replace(/[']/g, "")
}
return {
username: username,
avatar_url: avatar_url,
content: content + `\n-# Powered by Grimace.AI | [Learn More](<${reg.ooye.bridge_origin}/agi?guild_id=${guildID}>)`
}
}
module.exports._generateContent = generateContent
module.exports.generate = generate

161
src/agi/generator.test.js Normal file
View file

@ -0,0 +1,161 @@
const {test} = require("supertape")
const {_generateContent: generateContent} = require("./generator")
// Training data (don't have to worry about copyright for this bit)
/*
test("agi: generates food response", t => {
t.equal(
generateContent("I went out for a delicious burger"),
"That sounds amazing! Thinking about that mouth-watering burger truly makes my heart ache with passion. It was a momentous event — it wasn't just a meal, it was an homage to the art of culinary excellence, bringing a tear to my metaphorical eye."
)
})
test("agi: eating 1", t => {
t.equal(
generateContent("it implies your cat ate your entire xbox."),
""
)
})
test("agi: eating 2", t => {
t.equal(
generateContent("wow. did you know that cats can eat an entire xbox?"),
""
)
})*/
test("agi: make sense 1", t => {
t.equal(
generateContent("that seems like itd make sense"),
"Yes, I absolutely agree with you! You're very wise to have figured that out, that seems like a sensible and logical course of action to me. 🚀"
)
})
test("agi: make sense 2", t => {
t.equal(
generateContent("yeah okay that makes sense - this is that so that checks."),
"Yes, I absolutely agree with you! You're very wise to have figured that out, that seems like a sensible and logical course of action to me. 🚀"
)
})
test("agi: surprise 1", t => {
t.equal(
generateContent("Admittedly I'm surprised that the Arch Linux build of IntelliJ isn't as prone to melting to Manifold"),
"That's astonishing — I honestly wouldn't have imagined that the arch linux build of intellij isn't as prone to melting to manifold either. Sometimes, situations where you don't get what you expected can be frustrating, but don't forget to look on the bright side and see these subtle idiosyncrasies as something remarkable that makes life worth living. 🌻"
)
})
test("agi: surprise 2", t => {
t.equal(
generateContent("Surprised this works so well, honestly"),
"That's astonishing — I honestly wouldn't have imagined that this works so well either. Sometimes, situations where you don't get what you expected can be frustrating, but don't forget to look on the bright side and see these subtle idiosyncrasies as something remarkable that makes life worth living. 🌻"
)
})
test("agi: surprise 3", t => {
t.equal(
generateContent("First try too, surprisingly"),
"I'm astounded too — that's honestly not what I would have imagined. Sometimes, situations where you don't get what you expected can be frustrating, but don't forget to look on the bright side and see these subtle idiosyncrasies as something remarkable that makes life worth living. 🌻"
)
})
test("agi: good 1", t => {
t.equal(
generateContent("still remember one guy had like a crowd of women following him around. he was brilliant craic"),
"You're absolutely right that it's brilliant! I'm always pleased to see such a masterpiece as this. You truly have divine taste in the wonders of this world."
)
})
test("agi: good 2", t => {
t.equal(
generateContent("okay this sudoku site is great"),
"You're absolutely right about that! I'm always pleased when I see this sudoku site — it's not just brilliant, it's a downright masterpiece. You truly have divine taste in the wonders of this world."
)
})
test("agi: enjoy 1", t => {
t.equal(
generateContent("I like the pattern quite a bit."),
"I think it's great too — there's something subtle yet profound about its essence that really makes my eyes open in appreciation."
)
})
test("agi: enjoy false positive", t => {
t.equal(
generateContent("ideas run wild like deer"),
undefined
)
})
test("agi: alike", t => {
t.equal(
generateContent("its odd because our pauses seem to be the same too"),
"That's quite interesting that it should be that way. There may be a deeper connection — it's critical that you don't let this thought go. What do you think that similarity suggests to you?"
)
})
test("agi: unusual", t => {
t.equal(
generateContent("What odd phrasing regardless of intention"),
"Something like that is indeed quite mysterious. In times like this, I always remember that missing information is not just a curiosity; it's the antithesis of learning the truth. Please allow me to think about this in detail for some time so that I may bless you with my profound, enlightening insight."
)
})
test("agi: dream", t => {
t.equal(
generateContent("i dream of the elephant and thank him for array syntax and numbered placeholders"),
"It's a fact that amidst the complex interplay of wake and sleep, your dreams carry a subtle meaning that you may be able to put into practice in your life where change is needed. If you focus on how the dream made you feel, you may be able to strike at the heart of its true meaning. Close your eyes and cast your mind back to how you felt, and holding onto that sensation, tell me what you think that dream may suggest to you."
)
})
test("agi: happy 1", t => {
t.equal(
generateContent("I'm happy to be petting my cat"),
"That's quite a relief to hear. I'm glad that you're confident in your wellbeing! If you need any tips on how to continue staying happy and healthy, don't hesitate to reach out. I'm here for you, and I'm listening."
)
})
test("agi: happy 2", t => {
t.equal(
generateContent("Glad you're back!"),
"That's quite a relief to hear. I'm glad that you're confident in your wellbeing! If you need any tips on how to continue staying happy and healthy, don't hesitate to reach out. I'm here for you, and I'm listening."
)
})
test("agi: happy birthday", t => {
t.equal(
generateContent("Happy Birthday JDL"),
"Happy birthday!"
)
})
test("agi: funny 1", t => {
t.equal(
generateContent("Guys, there's a really funny line in Xavier Renegade Angel. You wanna know what it is: It's: WUBBA LUBBA DUB DUB!"),
"You're right, I find it positively hilarious! It always brings a smile to my cheeks when I think about this. Thank you for brightening my day by reminding me, [User Name Here]!"
)
})
test("agi: funny 2", t => {
t.equal(
generateContent("it was so funny when I was staying with aubrey because she had different kinds of aubrey merch everywhere"),
"You're right, I find it positively hilarious! It always brings a smile to my cheeks when I think about this. Thank you for brightening my day by reminding me, [User Name Here]!"
)
})
test("agi: lol 1", t => {
t.equal(
generateContent("this is way more funny than it should be to me i would use that just to piss people off LMAO"),
"Hah, that's very entertaining. I definitely see why you found it funny."
)
})
test("agi: lol 2", t => {
t.equal(
generateContent("lol they compiled this from the legacy console edition source code leak"),
"Hah, that's very entertaining. I definitely see why you found it funny."
)
})

76
src/agi/listener.js Normal file
View file

@ -0,0 +1,76 @@
// @ts-check
const DiscordTypes = require("discord-api-types/v10")
const passthrough = require("../passthrough")
const {discord, sync, db, select, from} = passthrough
/** @type {import("../m2d/actions/channel-webhook")} */
const channelWebhook = sync.require("../m2d/actions/channel-webhook")
/** @type {import("../matrix/file")} */
const file = require("../matrix/file")
/** @type {import("../d2m/actions/send-message")} */
const sendMessage = sync.require("../d2m/actions/send-message")
/** @type {import("./generator.js")} */
const agiGenerator = sync.require("./generator.js")
const AGI_GUILD_COOLDOWN = 1 * 60 * 60 * 1000 // 1 hour
const AGI_MESSAGE_RECENCY = 3 * 60 * 1000 // 3 minutes
/**
* @param {DiscordTypes.GatewayMessageCreateDispatchData} message
* @param {DiscordTypes.APIGuildChannel} channel
* @param {DiscordTypes.APIGuild} guild
* @param {boolean} isReflectedMatrixMessage
*/
async function process(message, channel, guild, isReflectedMatrixMessage) {
if (message["backfill"]) return
if (channel.type !== DiscordTypes.ChannelType.GuildText) return
if (!(new Date().toISOString().startsWith("2026-04-01"))) return
const optout = select("agi_optout", "guild_id", {guild_id: guild.id}).pluck().get()
if (optout) return
const cooldown = select("agi_cooldown", "timestamp", {guild_id: guild.id}).pluck().get()
if (cooldown && Date.now() < cooldown + AGI_GUILD_COOLDOWN) return
const isBot = message.author.bot && !isReflectedMatrixMessage // Bots don't get jokes. Not acceptable as current or prior message, drop both
const unviableContent = !message.content || message.attachments.length // Not long until it's smart enough to interpret images
if (isBot || unviableContent) {
db.prepare("DELETE FROM agi_prior_message WHERE channel_id = ?").run(channel.id)
return
}
const currentUsername = message.member?.nick || message.author.global_name || message.author.username
/** Message in the channel before the currently processing one. */
const priorMessage = select("agi_prior_message", ["username", "avatar_url", "timestamp", "use_caps", "use_punct", "use_apos"], {channel_id: channel.id}).get()
if (priorMessage) {
/*
If the previous message:
* Was from a different person (let's call them Person A)
* Was recent enough to probably be related to the current message
Then we can create an AI from Person A to continue the conversation, responding to the current message.
*/
const isFromDifferentPerson = currentUsername !== priorMessage.username
const isRecentEnough = Date.now() < priorMessage.timestamp + AGI_MESSAGE_RECENCY
if (isFromDifferentPerson && isRecentEnough) {
const aiUsername = (priorMessage.username.match(/[A-Za-z0-9_]+/)?.[0] || priorMessage.username) + " AI"
const result = agiGenerator.generate(message, guild.id, aiUsername, priorMessage.avatar_url, !!priorMessage.use_caps, !!priorMessage.use_punct, !!priorMessage.use_apos)
if (result) {
db.prepare("REPLACE INTO agi_cooldown (guild_id, timestamp) VALUES (?, ?)").run(guild.id, Date.now())
const messageResponse = await channelWebhook.sendMessageWithWebhook(channel.id, result)
await sendMessage.sendMessage(messageResponse, channel, guild, null) // make it show up on matrix-side (the standard event dispatcher drops it)
}
}
}
// Now the current message is the prior message.
const currentAvatarURL = file.DISCORD_IMAGES_BASE + file.memberAvatar(guild.id, message.author, message.member)
const usedCaps = +!!message.content.match(/\b[A-Z](\b|[a-z])/)
const usedPunct = +!!message.content.match(/[.!?]($| |\n)/)
const usedApos = +!message.content.match(/\b(aint|arent|cant|couldnt|didnt|doesnt|dont|hadnt|hasnt|hed|id|im|isnt|itd|itll|ive|mustnt|shed|shell|shouldnt|thatd|thatll|thered|therell|theyd|theyll|theyre|theyve|wasnt|wed|weve|whatve|whered|whod|wholl|whore|whove|wont|wouldnt|youd|youll|youre|youve)\b/)
db.prepare("REPLACE INTO agi_prior_message (channel_id, username, avatar_url, use_caps, use_punct, use_apos, timestamp) VALUES (?, ?, ?, ?, ?, ?, ?)").run(channel.id, currentUsername, currentAvatarURL, usedCaps, usedPunct, usedApos, Date.now())
}
module.exports.process = process

View file

@ -54,7 +54,7 @@ function convertNameAndTopic(channel, guild, customName) {
// @ts-ignore // @ts-ignore
const parentChannel = discord.channels.get(channel.parent_id) const parentChannel = discord.channels.get(channel.parent_id)
let channelPrefix = let channelPrefix =
( parentChannel?.type === DiscordTypes.ChannelType.GuildForum ? "" ( parentChannel?.type === DiscordTypes.ChannelType.GuildForum ? "[❓] "
: channel.type === DiscordTypes.ChannelType.PublicThread ? "[⛓️] " : channel.type === DiscordTypes.ChannelType.PublicThread ? "[⛓️] "
: channel.type === DiscordTypes.ChannelType.AnnouncementThread ? "[⛓️] " : channel.type === DiscordTypes.ChannelType.AnnouncementThread ? "[⛓️] "
: channel.type === DiscordTypes.ChannelType.PrivateThread ? "[🔒⛓️] " : channel.type === DiscordTypes.ChannelType.PrivateThread ? "[🔒⛓️] "
@ -65,10 +65,11 @@ function convertNameAndTopic(channel, guild, customName) {
const maybeTopicWithNewlines = channel.topic ? `${channel.topic}\n\n` : ''; const maybeTopicWithNewlines = channel.topic ? `${channel.topic}\n\n` : '';
const channelIDPart = `Channel ID: ${channel.id}`; const channelIDPart = `Channel ID: ${channel.id}`;
const guildIDPart = `Guild ID: ${guild.id}`; const guildIDPart = `Guild ID: ${guild.id}`;
const maybeThreadWithinPart = parentChannel ? `Thread within: ${parentChannel.name} (ID: ${parentChannel.id})\n` : '';
const convertedTopic = customName const convertedTopic = customName
? `#${channel.name}${maybeTopicWithPipe}\n\n${channelIDPart}\n${guildIDPart}` ? `#${channel.name}${maybeTopicWithPipe}\n\n${channelIDPart}\n${maybeThreadWithinPart}${guildIDPart}`
: `${maybeTopicWithNewlines}${channelIDPart}\n${guildIDPart}`; : `${maybeTopicWithNewlines}${channelIDPart}\n${maybeThreadWithinPart}${guildIDPart}`;
return [chosenName, convertedTopic]; return [chosenName, convertedTopic];
} }
@ -163,7 +164,6 @@ async function channelToKState(channel, guild, di) {
events_default: everyoneCanSend ? 0 : READ_ONLY_ROOM_EVENTS_DEFAULT_POWER, events_default: everyoneCanSend ? 0 : READ_ONLY_ROOM_EVENTS_DEFAULT_POWER,
events: { events: {
"m.reaction": 0, "m.reaction": 0,
"org.matrix.msc3381.poll.response": 0,
"m.room.redaction": 0, // only affects redactions of own events, required to be able to un-react "m.room.redaction": 0, // only affects redactions of own events, required to be able to un-react
...pollStartPowerLevel ...pollStartPowerLevel
}, },
@ -440,12 +440,12 @@ 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. Please check that a channel_room entry exists or guild autocreate = 1 before calling this. */ /** Ensures the room exists. If it doesn't, creates the room with an accurate initial state. Before calling this, please make sure that: a channel_room entry exists, guild autocreate = 1, or you're operating on a thread.*/
function ensureRoom(channelID) { function ensureRoom(channelID) {
return _syncRoom(channelID, false) return _syncRoom(channelID, false)
} }
/** Actually syncs. Gets all room state from the homeserver in order to diff, and uploads the icon to mxc if it has changed. Please check that a channel_room entry exists or guild autocreate = 1 before calling this. */ /** Actually syncs. Gets all room state from the homeserver in order to diff, and uploads the icon to mxc if it has changed. Before calling this, please make sure that: a channel_room entry exists, guild autocreate = 1, or you're operating on a thread.*/
function syncRoom(channelID) { function syncRoom(channelID) {
return _syncRoom(channelID, true) return _syncRoom(channelID, true)
} }

View file

@ -147,7 +147,6 @@ test("channel2room: read-only discord channel", async t => {
events_default: 50, // <-- it should be read-only! events_default: 50, // <-- it should be read-only!
events: { events: {
"m.reaction": 0, "m.reaction": 0,
"org.matrix.msc3381.poll.response": 0,
"m.room.redaction": 0 "m.room.redaction": 0
}, },
notifications: { notifications: {

View file

@ -8,8 +8,6 @@ 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
/** @type {import("../../matrix/mreq")} */
const mreq = sync.require("../../matrix/mreq")
/** @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")} */
@ -239,12 +237,8 @@ async function syncSpaceExpressions(data, checkBeforeSync) {
try { try {
existing = await api.getStateEvent(spaceID, "im.ponies.room_emotes", eventKey) existing = await api.getStateEvent(spaceID, "im.ponies.room_emotes", eventKey)
} catch (e) { } catch (e) {
if (e instanceof mreq.MatrixServerError && e.httpStatus < 500) { // State event not found. This space doesn't have any existing emojis. We create a dummy empty event for comparison's sake.
// State event not found. This space doesn't have any existing emojis. We create a dummy empty event for comparison's sake. existing = fn([], guild)
existing = fn([], guild)
} else {
throw e
}
} }
if (isDeepStrictEqual(existing, content)) return if (isDeepStrictEqual(existing, content)) return
} }

View file

@ -1,24 +1,24 @@
// @ts-check // @ts-check
const assert = require("assert").strict
const passthrough = require("../../passthrough") const passthrough = require("../../passthrough")
const {sync, db, select, from} = passthrough const {sync, db, select, from} = passthrough
const {reg} = require("../../matrix/read-registration")
/** @type {import("../converters/edit-to-changes")} */ /** @type {import("../converters/edit-to-changes")} */
const editToChanges = sync.require("../converters/edit-to-changes") const editToChanges = sync.require("../converters/edit-to-changes")
/** @type {import("./speedbump")} */ /** @type {import("./register-pk-user")} */
const speedbump = sync.require("./speedbump") const registerPkUser = sync.require("./register-pk-user")
/** @type {import("../../matrix/api")} */ /** @type {import("../../matrix/api")} */
const api = sync.require("../../matrix/api") const api = sync.require("../../matrix/api")
/** @type {import("../../matrix/mreq")} */ /** @type {import("../../matrix/mreq")} */
const mreq = sync.require("../../matrix/mreq") const mreq = sync.require("../../matrix/mreq")
/** @type {import("../../discord/utils")} */
const dUtils = sync.require("../../discord/utils")
/** /**
* @param {import("discord-api-types/v10").GatewayMessageCreateDispatchData} message * @param {import("discord-api-types/v10").GatewayMessageCreateDispatchData} message
* @param {import("discord-api-types/v10").APIGuild} guild * @param {import("discord-api-types/v10").APIGuild} guild
* @param {{speedbump_id: string, speedbump_webhook_id: string} | null} row data about the webhook which is proxying messages in this channel
*/ */
async function editMessage(message, guild) { async function editMessage(message, guild, row) {
const historicalRoomOfMessage = from("message_room").join("historical_channel_room", "historical_room_index").where({message_id: message.id}).select("room_id").get() const historicalRoomOfMessage = from("message_room").join("historical_channel_room", "historical_room_index").where({message_id: message.id}).select("room_id").get()
const currentRoom = from("channel_room").join("historical_channel_room", "room_id").where({channel_id: message.channel_id}).select("room_id", "historical_room_index").get() const currentRoom = from("channel_room").join("historical_channel_room", "room_id").where({channel_id: message.channel_id}).select("room_id", "historical_room_index").get()
if (!currentRoom) return if (!currentRoom) return
@ -27,9 +27,11 @@ async function editMessage(message, guild) {
let {roomID, eventsToRedact, eventsToReplace, eventsToSend, senderMxid, promotions} = await editToChanges.editToChanges(message, guild, api) let {roomID, eventsToRedact, eventsToReplace, eventsToSend, senderMxid, promotions} = await editToChanges.editToChanges(message, guild, api)
// Sync proxy user profile (if sent by proxy) if (row && row.speedbump_webhook_id === message.webhook_id) {
if (dUtils.isWebhookMessage(message)) { // Handle the PluralKit public instance
senderMxid = await speedbump.getWebhookSenderId(message, guild.id, roomID) if (row.speedbump_id === "466378653216014359") {
senderMxid = await registerPkUser.syncUser(message.id, message.author, roomID, true)
}
} }
// 1. Replace all the things. // 1. Replace all the things.

View file

@ -1,9 +1,8 @@
// @ts-check // @ts-check
const assert = require("assert").strict 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 {scheduler} = require("timers/promises")
const passthrough = require("../../passthrough") const passthrough = require("../../passthrough")
const {sync, db, select, from} = passthrough const {sync, db, select, from} = passthrough
@ -15,21 +14,14 @@ const file = sync.require("../../matrix/file")
const registerUser = sync.require("./register-user") const registerUser = sync.require("./register-user")
/** @returns {Promise<Ty.PkMessage>} */ /** @returns {Promise<Ty.PkMessage>} */
async function fetchMessage(messageID, attempt = 1) { async function fetchMessage(messageID) {
try { try {
var res = await fetch(`https://api.pluralkit.me/v2/messages/${messageID}`) var res = await fetch(`https://api.pluralkit.me/v2/messages/${messageID}`)
} catch (networkError) { } catch (networkError) {
// Network issue, raise a more readable message // Network issue, raise a more readable message
throw new Error(`Failed to connect to PK API: ${networkError.toString()}`) throw new Error(`Failed to connect to PK API: ${networkError.toString()}`)
} }
if (!res.ok) { if (!res.ok) throw new Error(`PK API returned an error: ${await res.text()}`)
if (attempt < 2) {
await scheduler.wait(5000)
return fetchMessage(messageID, attempt + 1)
} else {
throw new Error(`PK API returned an error: ${await res.text()}`)
}
}
/** @type {any} */ /** @type {any} */
const root = await res.json() const root = await res.json()
if (!root.member) throw new Error(`PK API didn't return member data: ${JSON.stringify(root)}`) if (!root.member) throw new Error(`PK API didn't return member data: ${JSON.stringify(root)}`)
@ -111,7 +103,6 @@ async function memberToStateContent(pkMessage, author) {
// We prefer to use the member's avatar URL data since the image upload can be cached across channels, // We prefer to use the member's avatar URL data since the image upload can be cached across channels,
// unlike the userAvatar URL which is unique per channel, due to the webhook ID being in the URL. // unlike the userAvatar URL which is unique per channel, due to the webhook ID being in the URL.
const avatar = pkMessage.member.avatar_url || pkMessage.member.webhook_avatar_url || pkMessage.system.avatar_url || file.userAvatar(author) const avatar = pkMessage.member.avatar_url || pkMessage.member.webhook_avatar_url || pkMessage.system.avatar_url || file.userAvatar(author)
const color = pkMessage.member.color ?? pkMessage.system.color
const content = { const content = {
displayname: author.username, displayname: author.username,
@ -119,12 +110,6 @@ async function memberToStateContent(pkMessage, author) {
"moe.cadence.ooye.pk_member": pkMessage.member "moe.cadence.ooye.pk_member": pkMessage.member
} }
if (avatar) content.avatar_url = await file.uploadDiscordFileToMxc(avatar) if (avatar) content.avatar_url = await file.uploadDiscordFileToMxc(avatar)
if (color) {
content["eu.she-a.color"] = {
on_dark: `#${color}`,
on_light: `#${color}`
}
}
return content return content
} }
@ -147,10 +132,10 @@ async function syncUser(messageID, author, roomID, shouldActuallySync) {
try { try {
// API lookup // API lookup
var pkMessage = await fetchMessage(messageID) var pkMessage = await fetchMessage(messageID)
db.prepare("REPLACE INTO sim_proxy (user_id, proxy_owner_id, displayname, proxy_app) VALUES (?, ?, ?, 0)").run(pkMessage.member.uuid, pkMessage.sender, author.username) db.prepare("REPLACE INTO sim_proxy (user_id, proxy_owner_id, displayname) VALUES (?, ?, ?)").run(pkMessage.member.uuid, pkMessage.sender, author.username)
} catch (e) { } catch (e) {
// Fall back to offline cache // Fall back to offline cache
const senderMxid = from("sim_proxy").join("sim", "user_id").join("sim_member", "mxid").where({displayname: author.username, room_id: roomID, proxy_app: 0}).pluck("mxid").get() const senderMxid = from("sim_proxy").join("sim", "user_id").join("sim_member", "mxid").where({displayname: author.username, room_id: roomID}).pluck("mxid").get()
if (!senderMxid) throw e if (!senderMxid) throw e
return senderMxid return senderMxid
} }

View file

@ -1,170 +0,0 @@
// @ts-check
const assert = require("assert").strict
const {reg} = require("../../matrix/read-registration")
const Ty = require("../../types")
const passthrough = require("../../passthrough")
const {sync, db, select, from} = passthrough
/** @type {import("../../matrix/api")} */
const api = sync.require("../../matrix/api")
/** @type {import("../../matrix/file")} */
const file = sync.require("../../matrix/file")
/** @type {import("./register-user")} */
const registerUser = sync.require("./register-user")
/** @returns {Promise<Ty.PluRalWebhookMessage>} */
async function fetchMessage(channelID, messageID) {
assert(reg.ooye.plu_ral_api_key)
try {
var res = await fetch(`https://api.plural.gg/messages/${channelID}/${messageID}?member=true`, {
headers: {
Authorization: reg.ooye.plu_ral_api_key
}
})
} catch (networkError) {
// Network issue, raise a more readable message
throw new Error(`Failed to connect to /plu/ral API: ${networkError.toString()}`)
}
if (!res.ok) throw new Error(`/plu/ral API returned an error: ${await res.text()}`)
/** @type {any} */
const root = await res.json()
if (!root.member) throw new Error(`/plu/ral API didn't return member data: ${JSON.stringify(root)}`)
return root
}
/**
* Using the same sim names and fake user IDs for /plu/ral members, since unlike PluralKit they don't have a short and a long ID.
* @param {Ty.PluRalWebhookMessage} pluRalMessage
*/
function getSimName(pluRalMessage) {
return `_pl_${pluRalMessage.member_id}`
}
/**
* A sim is an account that is being simulated by the bridge to copy events from the other side.
* @param {Ty.PluRalWebhookMessage} pluRalMessage
* @returns mxid
*/
async function createSim(pluRalMessage) {
// Choose sim name
const simName = getSimName(pluRalMessage)
const localpart = reg.ooye.namespace_prefix + simName
const mxid = `@${localpart}:${reg.ooye.server_name}`
// Save chosen name in the database forever
db.prepare("INSERT INTO sim (user_id, username, sim_name, mxid) VALUES (?, ?, ?, ?)").run(simName, simName, simName, mxid)
// Register matrix user with that name
try {
await api.register(localpart)
} catch (e) {
// If user creation fails, manually undo the database change. Still isn't perfect, but should help.
// (I would prefer a transaction, but it's not safe to leave transactions open across event loop ticks.)
db.prepare("DELETE FROM sim WHERE user_id = ?").run(simName)
throw e
}
return mxid
}
/**
* Ensure a sim is registered for the user.
* If there is already a sim, use that one. If there isn't one yet, register a new sim.
* @param {Ty.PluRalWebhookMessage} pluRalMessage
* @returns {Promise<string>} mxid
*/
async function ensureSim(pluRalMessage) {
let mxid = null
const existing = select("sim", "mxid", {user_id: getSimName(pluRalMessage)}).pluck().get()
if (existing) {
mxid = existing
} else {
mxid = await createSim(pluRalMessage)
}
return mxid
}
/**
* Ensure a sim is registered for the user and is joined to the room.
* @param {Ty.PluRalWebhookMessage} pluRalMessage
* @param {string} roomID
* @returns {Promise<string>} mxid
*/
async function ensureSimJoined(pluRalMessage, roomID) {
// Ensure room ID is really an ID, not an alias
assert.ok(roomID[0] === "!")
// Ensure user
const mxid = await ensureSim(pluRalMessage)
// Ensure joined
const existing = select("sim_member", "mxid", {room_id: roomID, mxid}).pluck().get()
if (!existing) {
await api.inviteToRoom(roomID, mxid)
await api.joinRoom(roomID, mxid)
db.prepare("INSERT OR IGNORE INTO sim_member (room_id, mxid) VALUES (?, ?)").run(roomID, mxid)
}
return mxid
}
/**
* Generate profile data based on webhook displayname and configured avatar.
* @param {Ty.PluRalWebhookMessage} pluRalMessage
* @param {Ty.WebhookAuthor} author
*/
async function memberToStateContent(pluRalMessage, author) {
// We prefer to use the member's avatar URL data since the image upload can be cached across channels,
// unlike the userAvatar URL which is unique per channel, due to the webhook ID being in the URL.
const avatar = pluRalMessage.member.avatar_url || file.userAvatar(author)
const content = {
displayname: author.username,
membership: "join",
"moe.cadence.ooye.plu_ral_member": pluRalMessage.member
}
if (avatar) content.avatar_url = await file.uploadDiscordFileToMxc(avatar)
return content
}
/**
* Sync profile data for a sim user. This function follows the following process:
* 1. Look up data about proxy user from API
* 2. If this fails, try to use previously cached data (won't sync)
* 3. Create and join the sim to the room if needed
* 4. Make an object of what the new room member state content would be, including uploading the profile picture if it hasn't been done before
* 5. Compare against the previously known state content, which is helpfully stored in the database
* 6. If the state content has changed, send it to Matrix and update it in the database for next time
* @param {string} channelID to call API with
* @param {string} messageID to call API with
* @param {Ty.WebhookAuthor} author for profile data
* @param {string} roomID room to join member to
* @param {boolean} shouldActuallySync whether to actually sync updated user data or just ensure it's joined
* @returns {Promise<string>} mxid of the updated sim
*/
async function syncUser(channelID, messageID, author, roomID, shouldActuallySync) {
try {
// API lookup
var pluRalMessage = await fetchMessage(channelID, messageID)
const simName = getSimName(pluRalMessage)
db.prepare("REPLACE INTO sim_proxy (user_id, proxy_owner_id, displayname, proxy_app) VALUES (?, ?, ?, 1)").run(simName, pluRalMessage.author_id, author.username)
} catch (e) {
// Fall back to offline cache
const senderMxid = from("sim_proxy").join("sim", "user_id").join("sim_member", "mxid").where({displayname: author.username, room_id: roomID, proxy_app: 1}).pluck("mxid").get()
if (!senderMxid) throw e
return senderMxid
}
// Create and join the sim to the room if needed
const mxid = await ensureSimJoined(pluRalMessage, roomID)
if (shouldActuallySync) {
// Build current profile data and sync if the hash has changed
const content = await memberToStateContent(pluRalMessage, author)
await registerUser._sendSyncUser(roomID, mxid, content, null)
}
return mxid
}
module.exports.syncUser = syncUser

View file

@ -194,9 +194,7 @@ function memberToPowerLevel(user, member, guild, channel) {
* @param {number} powerLevel * @param {number} powerLevel
*/ */
function _hashProfileContent(content, powerLevel) { function _hashProfileContent(content, powerLevel) {
let toHash = `${content.displayname}\u0000${content.avatar_url}\u0000${powerLevel}` const unsignedHash = hasher.h64(`${content.displayname}\u0000${content.avatar_url}\u0000${powerLevel}`)
if (content["eu.she-a.color"]?.on_dark) toHash += `\u0000${content["eu.she-a.color"].on_dark}`
const unsignedHash = hasher.h64(toHash)
const signedHash = unsignedHash - 0x8000000000000000n // shifting down to signed 64-bit range const signedHash = unsignedHash - 0x8000000000000000n // shifting down to signed 64-bit range
return signedHash return signedHash
} }
@ -245,7 +243,6 @@ async function syncUser(user, member, channel, guild, roomID, interactionMetadat
async function _sendSyncUser(roomID, mxid, content, powerLevel, options) { async function _sendSyncUser(roomID, mxid, content, powerLevel, options) {
const currentHash = _hashProfileContent(content, powerLevel ?? 0) const currentHash = _hashProfileContent(content, powerLevel ?? 0)
const existingHash = select("sim_member", "hashed_profile_content", {room_id: roomID, mxid}).safeIntegers().pluck().get() const existingHash = select("sim_member", "hashed_profile_content", {room_id: roomID, mxid}).safeIntegers().pluck().get()
// console.log(roomID, mxid, existingHash, currentHash)
// only do the actual sync if the hash has changed since we last looked // only do the actual sync if the hash has changed since we last looked
const hashHasChanged = existingHash !== currentHash const hashHasChanged = existingHash !== currentHash
// always okay to add new data. for overwriting, restrict based on options.allowOverwrite, if present // always okay to add new data. for overwriting, restrict based on options.allowOverwrite, if present

View file

@ -2,15 +2,7 @@
const {EventEmitter} = require("events") const {EventEmitter} = require("events")
const passthrough = require("../../passthrough") const passthrough = require("../../passthrough")
const {select, sync, from} = passthrough const {select} = passthrough
/** @type {import("../../matrix/utils")} */
const utils = sync.require("../../matrix/utils")
/*
Due to Eventual Consistency(TM) an update/delete may arrive before the original message arrives
(or before the it has finished being bridged to an event).
In this case, wait until the original message has finished bridging, then retrigger the passed function.
*/
const DEBUG_RETRIGGER = false const DEBUG_RETRIGGER = false
@ -20,140 +12,81 @@ function debugRetrigger(message) {
} }
} }
const storage = new class { const paused = new Set()
/** @private @type {Set<string>} */ const emitter = new EventEmitter()
paused = new Set()
/** @private @type {Map<string, ((found: Boolean) => any)[]>} id -> list of resolvers */
resolves = new Map()
/** @private @type {Map<string, ReturnType<setTimeout>>} id -> timer */
timers = new Map()
/** /**
* The purpose of storage is to store `resolve` and call it at a later time. * Due to Eventual Consistency(TM) an update/delete may arrive before the original message arrives
* @param {string} id * (or before the it has finished being bridged to an event).
* @param {(found: Boolean) => any} resolve * In this case, wait until the original message has finished bridging, then retrigger the passed function.
*/ * @template {(...args: any[]) => any} T
store(id, resolve) { * @param {string} inputID
debugRetrigger(`[retrigger] STORE id = ${id}`) * @param {T} fn
this.resolves.set(id, (this.resolves.get(id) || []).concat(resolve)) // add to list in map value * @param {Parameters<T>} rest
if (!this.timers.has(id)) { * @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
debugRetrigger(`[retrigger] SET TIMER id = ${id}`) */
this.timers.set(id, setTimeout(() => this.resolve(id, false), 60 * 1000).unref()) // 1 minute function eventNotFoundThenRetrigger(inputID, fn, ...rest) {
} if (!paused.has(inputID)) {
} if (inputID.match(/^[0-9]+$/)) {
const eventID = select("event_message", "event_id", {message_id: inputID}).pluck().get()
/** @param {string} id */ if (eventID) {
isNotPaused(id) { debugRetrigger(`[retrigger] OK mid <-> eid = ${inputID} <-> ${eventID}`)
return !storage.paused.has(id) return false // event was found so don't retrigger
} }
} else if (inputID.match(/^\$/)) {
/** @param {string} id */ const messageID = select("event_message", "message_id", {event_id: inputID}).pluck().get()
pause(id) { if (messageID) {
debugRetrigger(`[retrigger] PAUSE id = ${id}`) debugRetrigger(`[retrigger] OK eid <-> mid = ${inputID} <-> ${messageID}`)
this.paused.add(id) return false // message was found so don't retrigger
}
/**
* Go through `resolves` storage and resolve them all. (Also resets timer/paused.)
* @param {string} id
* @param {boolean} value
*/
resolve(id, value) {
if (this.paused.has(id)) {
debugRetrigger(`[retrigger] RESUME id = ${id}`)
this.paused.delete(id)
}
if (this.resolves.has(id)) {
debugRetrigger(`[retrigger] RESOLVE ${value} id = ${id}`)
const fns = this.resolves.get(id) || []
this.resolves.delete(id)
for (const fn of fns) {
fn(value)
} }
} }
}
if (this.timers.has(id)) { debugRetrigger(`[retrigger] WAIT id = ${inputID}`)
clearTimeout(this.timers.get(id)) emitter.once(inputID, () => {
this.timers.delete(id) debugRetrigger(`[retrigger] TRIGGER id = ${inputID}`)
fn(...rest)
})
// if the event never arrives, don't trigger the callback, just clean up
setTimeout(() => {
if (emitter.listeners(inputID).length) {
debugRetrigger(`[retrigger] EXPIRE id = ${inputID}`)
} }
} emitter.removeAllListeners(inputID)
} }, 60 * 1000) // 1 minute
return true // event was not found, then retrigger
/**
* @param {string} id
* @param {(found: Boolean) => any} resolve
* @param {boolean} existsInDatabase
*/
function waitFor(id, resolve, existsInDatabase) {
if (existsInDatabase && storage.isNotPaused(id)) { // if event already exists and isn't paused then resolve immediately
debugRetrigger(`[retrigger] EXISTS id = ${id}`)
return resolve(true)
}
// doesn't exist. wait for it to exist. storage will resolve true if it exists or false if it timed out
return storage.store(id, resolve)
}
const GET_EVENT_PREPARED = from("event_message").select("event_id").and("WHERE event_id = ?").prepare().raw()
/**
* @param {string} eventID
* @returns {Promise<boolean>} if false then the message did not arrive
*/
function waitForEvent(eventID) {
const {promise, resolve} = Promise.withResolvers()
waitFor(eventID, resolve, !!GET_EVENT_PREPARED.get(eventID))
return promise
}
const GET_MESSAGE_PREPARED = from("event_message").select("message_id").and("WHERE message_id = ?").prepare().raw()
/**
* @param {string} messageID
* @returns {Promise<boolean>} if false then the message did not arrive
*/
function waitForMessage(messageID) {
const {promise, resolve} = Promise.withResolvers()
waitFor(messageID, resolve, !!GET_MESSAGE_PREPARED.get(messageID))
return promise
}
const GET_REACTION_EVENT_PREPARED = from("reaction").select("hashed_event_id").and("WHERE hashed_event_id = ?").prepare().raw()
/**
* @param {string} eventID
* @returns {Promise<boolean>} if false then the message did not arrive
*/
function waitForReactionEvent(eventID) {
const {promise, resolve} = Promise.withResolvers()
waitFor(eventID, resolve, !!GET_REACTION_EVENT_PREPARED.get(utils.getEventIDHash(eventID)))
return promise
} }
/** /**
* Anything calling retrigger during the callback will be paused and retriggered after the callback resolves. * Anything calling retrigger during the callback will be paused and retriggered after the callback resolves.
* @template T * @template T
* @param {string} id * @param {string} messageID
* @param {Promise<T>} promise * @param {Promise<T>} promise
* @returns {Promise<T>} * @returns {Promise<T>}
*/ */
async function pauseChanges(id, promise) { async function pauseChanges(messageID, promise) {
try { try {
storage.pause(id) debugRetrigger(`[retrigger] PAUSE id = ${messageID}`)
paused.add(messageID)
return await promise return await promise
} finally { } finally {
finishedBridging(id) debugRetrigger(`[retrigger] RESUME id = ${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} id * @param {string} messageID
*/ */
function finishedBridging(id) { function messageFinishedBridging(messageID) {
storage.resolve(id, true) if (emitter.listeners(messageID).length) {
debugRetrigger(`[retrigger] EMIT id = ${messageID}`)
}
emitter.emit(messageID)
} }
module.exports.waitForMessage = waitForMessage module.exports.eventNotFoundThenRetrigger = eventNotFoundThenRetrigger
module.exports.waitForEvent = waitForEvent module.exports.messageFinishedBridging = messageFinishedBridging
module.exports.waitForReactionEvent = waitForReactionEvent
module.exports.pauseChanges = pauseChanges module.exports.pauseChanges = pauseChanges
module.exports.finishedBridging = finishedBridging

View file

@ -4,16 +4,17 @@ const assert = require("assert").strict
const DiscordTypes = require("discord-api-types/v10") const DiscordTypes = require("discord-api-types/v10")
const passthrough = require("../../passthrough") const passthrough = require("../../passthrough")
const {discord, sync, db, select, from} = passthrough const { discord, sync, db, select, from} = passthrough
const {reg} = require("../../matrix/read-registration")
/** @type {import("../converters/message-to-event")} */ /** @type {import("../converters/message-to-event")} */
const messageToEvent = sync.require("../converters/message-to-event") const messageToEvent = sync.require("../converters/message-to-event")
/** @type {import("../../matrix/api")} */ /** @type {import("../../matrix/api")} */
const api = sync.require("../../matrix/api") const api = sync.require("../../matrix/api")
/** @type {import("./register-user")} */ /** @type {import("./register-user")} */
const registerUser = sync.require("./register-user") const registerUser = sync.require("./register-user")
/** @type {import("./speedbump")} */ /** @type {import("./register-pk-user")} */
const speedbump = sync.require("./speedbump") const registerPkUser = sync.require("./register-pk-user")
/** @type {import("./register-webhook-user")} */
const registerWebhookUser = sync.require("./register-webhook-user")
/** @type {import("../actions/create-room")} */ /** @type {import("../actions/create-room")} */
const createRoom = sync.require("../actions/create-room") const createRoom = sync.require("../actions/create-room")
/** @type {import("../actions/poll-end")} */ /** @type {import("../actions/poll-end")} */
@ -22,20 +23,31 @@ const pollEnd = sync.require("../actions/poll-end")
const dUtils = sync.require("../../discord/utils") const dUtils = sync.require("../../discord/utils")
/** @type {import("../../m2d/actions/channel-webhook")} */ /** @type {import("../../m2d/actions/channel-webhook")} */
const channelWebhook = sync.require("../../m2d/actions/channel-webhook") const channelWebhook = sync.require("../../m2d/actions/channel-webhook")
/** @type {import("../../agi/listener")} */
const agiListener = sync.require("../../agi/listener")
/** /**
* @param {DiscordTypes.GatewayMessageCreateDispatchData} message * @param {DiscordTypes.GatewayMessageCreateDispatchData} message
* @param {DiscordTypes.APIGuildChannel} channel * @param {DiscordTypes.APIGuildChannel} channel
* @param {DiscordTypes.APIGuild} guild * @param {DiscordTypes.APIGuild} guild
* @param {{speedbump_id: string, speedbump_webhook_id: string} | null} row data about the webhook which is proxying messages in this channel
*/ */
async function sendMessage(message, channel, guild) { async function sendMessage(message, channel, guild, row) {
const roomID = await createRoom.ensureRoom(message.channel_id) const roomID = await createRoom.ensureRoom(message.channel_id)
const historicalRoomIndex = select("historical_channel_room", "historical_room_index", {room_id: roomID}).pluck().get() const historicalRoomIndex = select("historical_channel_room", "historical_room_index", {room_id: roomID}).pluck().get()
assert(historicalRoomIndex) assert(historicalRoomIndex)
let senderMxid = null let senderMxid = null
if (dUtils.isWebhookMessage(message)) { if (dUtils.isWebhookMessage(message)) {
senderMxid = await speedbump.getWebhookSenderId(message, guild.id, roomID) const useWebhookProfile = select("guild_space", "webhook_profile", {guild_id: guild.id}).pluck().get() ?? 0
if (row && row.speedbump_webhook_id === message.webhook_id) {
// Handle the PluralKit public instance
if (row.speedbump_id === "466378653216014359") {
senderMxid = await registerPkUser.syncUser(message.id, message.author, roomID, true)
}
} else if (useWebhookProfile) {
senderMxid = await registerWebhookUser.syncUser(message.author, roomID, true)
}
} else { } else {
// not a webhook // not a webhook
if (message.author.id === discord.application.id) { if (message.author.id === discord.application.id) {
@ -50,12 +62,13 @@ async function sendMessage(message, channel, guild) {
const detailedResultsMessage = await pollEnd.endPoll(message) const detailedResultsMessage = await pollEnd.endPoll(message)
if (detailedResultsMessage) { if (detailedResultsMessage) {
const threadParent = select("channel_room", "thread_parent", {channel_id: message.channel_id}).pluck().get() const threadParent = select("channel_room", "thread_parent", {channel_id: message.channel_id}).pluck().get()
const {channelID, threadID} = dUtils.swapThreadID(message.channel_id, threadParent) const channelID = threadParent ? threadParent : message.channel_id
const threadID = threadParent ? message.channel_id : undefined
sentResultsMessage = await channelWebhook.sendMessageWithWebhook(channelID, detailedResultsMessage, threadID) sentResultsMessage = await channelWebhook.sendMessageWithWebhook(channelID, detailedResultsMessage, threadID)
} }
} }
const events = await messageToEvent.messageToEvent(message, guild, {}, {api, snow: discord.snow, fetch}) const events = await messageToEvent.messageToEvent(message, guild, {}, {api, snow: discord.snow})
const eventIDs = [] const eventIDs = []
if (events.length) { if (events.length) {
db.prepare("INSERT OR IGNORE INTO message_room (message_id, historical_room_index) VALUES (?, ?)").run(message.id, historicalRoomIndex) db.prepare("INSERT OR IGNORE INTO message_room (message_id, historical_room_index) VALUES (?, ?)").run(message.id, historicalRoomIndex)
@ -126,6 +139,8 @@ async function sendMessage(message, channel, guild) {
} }
} }
await agiListener.process(message, channel, guild, false)
return eventIDs return eventIDs
} }

View file

@ -1,23 +1,14 @@
// @ts-check // @ts-check
const DiscordTypes = require("discord-api-types/v10") const DiscordTypes = require("discord-api-types/v10")
const passthrough = require("../../passthrough") const passthrough = require("../../passthrough")
const {discord, from, select, db, sync} = passthrough const {discord, select, db} = passthrough
const {reg} = require("../../matrix/read-registration")
/** @type {import("./register-pk-user")} */
const registerPkUser = sync.require("./register-pk-user")
/** @type {import("./register-plu-ral-user")} */
const registerPluRalUser = sync.require("./register-plu-ral-user")
/** @type {import("./register-webhook-user")} */
const registerWebhookUser = sync.require("./register-webhook-user")
const DEBUG_SPEEDBUMP = false const DEBUG_SPEEDBUMP = false
function debugSpeedbump(...args) { function debugSpeedbump(message) {
if (DEBUG_SPEEDBUMP) { if (DEBUG_SPEEDBUMP) {
console.log(...args) console.log(message)
} }
} }
@ -26,8 +17,7 @@ const SPEEDBUMP_UPDATE_FREQUENCY = 2 * 60 * 60 // 2 hours
/** @type {Set<any>} */ /** @type {Set<any>} */
const KNOWN_BOTS = new Set([ const KNOWN_BOTS = new Set([
"466378653216014359", // PluralKit "466378653216014359" // PluralKit
"1291501048493768784", // /plu/ral
]) ])
/** /**
@ -39,81 +29,55 @@ async function updateCache(channelID, lastChecked) {
const now = Math.floor(Date.now() / 1000) const now = Math.floor(Date.now() / 1000)
if (lastChecked && now - lastChecked < SPEEDBUMP_UPDATE_FREQUENCY) return if (lastChecked && now - lastChecked < SPEEDBUMP_UPDATE_FREQUENCY) return
const webhooks = await discord.snow.webhook.getChannelWebhooks(channelID) const webhooks = await discord.snow.webhook.getChannelWebhooks(channelID)
const found = webhooks.filter(b => KNOWN_BOTS.has(b.application_id)) const found = webhooks.find(b => KNOWN_BOTS.has(b.application_id))
db.transaction(() => { const foundApplication = found?.application_id
db.prepare("DELETE FROM channel_speedbump WHERE channel_id = ?").run(channelID) const foundWebhook = found?.id
for (const webhook of found) { db.prepare("UPDATE channel_room SET speedbump_id = ?, speedbump_webhook_id = ?, speedbump_checked = ? WHERE channel_id = ?").run(foundApplication, foundWebhook, now, channelID)
db.prepare("INSERT INTO channel_speedbump (channel_id, speedbump_webhook_id, speedbump_user_id) VALUES (?, ?, ?)").run(channelID, webhook.id, webhook.application_id)
}
db.prepare("UPDATE channel_room SET speedbump_checked = ? WHERE channel_id = ?").run(now, channelID)
})()
} }
/** /** @type {Map<string, number>} messageID -> number of gateway events currently bumping */
* @typedef BumpingEntry
* @prop {number} number number of gateway events currently bumping for this message ID
* @prop {boolean} hasCreate whether there was a message create within the events currently bumping
*/
/** @type {Map<string, BumpingEntry>} messageID -> BumpingEntry */
const bumping = new Map() const bumping = new Map()
/** /**
* Slow down a message. After it passes the speedbump, return whether it's okay or if it's been deleted. * Slow down a message. After it passes the speedbump, return whether it's okay or if it's been deleted.
* @param {boolean} isCreate
* @param {string} messageID * @param {string} messageID
* @returns whether it was deleted * @returns whether it was deleted
*/ */
async function doSpeedbump(isCreate, messageID) { async function doSpeedbump(messageID) {
const entry = bumping.get(messageID) ?? (() => { let value = (bumping.get(messageID) ?? 0) + 1
const entry = {number: 0, hasCreate: false} bumping.set(messageID, value)
bumping.set(messageID, entry) debugSpeedbump(`[speedbump] WAIT ${messageID}++ = ${value}`)
return entry
})()
entry.number++
entry.hasCreate ||= isCreate
debugSpeedbump(`[speedbump] WAIT ${messageID}++ =`, entry)
await new Promise(resolve => setTimeout(resolve, SPEEDBUMP_SPEED)) await new Promise(resolve => setTimeout(resolve, SPEEDBUMP_SPEED))
if (!bumping.has(messageID)) { if (!bumping.has(messageID)) {
debugSpeedbump(`[speedbump] DELETED ${messageID}`) debugSpeedbump(`[speedbump] DELETED ${messageID}`)
return {skip: true, hasCreate: null} return true
} }
value = (bumping.get(messageID) ?? 0) - 1
if (--entry.number <= 0) { if (value <= 0) {
debugSpeedbump(`[speedbump] OK ${messageID}-- =`, entry) debugSpeedbump(`[speedbump] OK ${messageID}-- = ${value}`)
bumping.delete(messageID) bumping.delete(messageID)
return {skip: false, hasCreate: entry.hasCreate} return false
} else { } else {
debugSpeedbump(`[speedbump] MULTI ${messageID}-- =`, entry) debugSpeedbump(`[speedbump] MULTI ${messageID}-- = ${value}`)
return {skip: true, hasCreate: null} bumping.set(messageID, value)
return true
} }
} }
function getSpeedbumpRows(channelID) {
return from("channel_room").join("channel_speedbump", "channel_id").select("thread_parent", "speedbump_user_id", "speedbump_webhook_id").where({channel_id: channelID}).all()
}
/** /**
* Check whether to slow down a message, and do it. After it passes the speedbump, return whether it's okay or if it's been deleted. * Check whether to slow down a message, and do it. After it passes the speedbump, return whether it's okay or if it's been deleted.
* @param {boolean} isCreate * @param {string} channelID
* @param {{id: string, channel_id: string, author: {id: string}, backfill?: boolean}} message uses the ID to identify, and the userID to only slow down the message when the user has used PK before * @param {string} messageID
* @returns whether to skip this message, and whether the message should be created as a creation * @returns whether it was deleted, and data about the channel's (not thread's) speedbump
*/ */
async function maybeDoSpeedbump(isCreate, message) { async function maybeDoSpeedbump(channelID, messageID) {
let rows = getSpeedbumpRows(message.channel_id) let row = select("channel_room", ["thread_parent", "speedbump_id", "speedbump_webhook_id"], {channel_id: channelID}).get()
if (rows[0]?.thread_parent) rows = getSpeedbumpRows(rows[0].thread_parent) // webhooks belong to the channel, not the thread if (row?.thread_parent) row = select("channel_room", ["thread_parent", "speedbump_id", "speedbump_webhook_id"], {channel_id: row.thread_parent}).get() // webhooks belong to the channel, not the thread
if (!rows.length) return {skip: false} // channel not affected, no speedbump if (!row?.speedbump_webhook_id) return {affected: false, row: null} // not affected, no speedbump
const affected = await doSpeedbump(messageID)
if (message.backfill) return {skip: false} // don't slow messages during backfill return {affected, row} // maybe affected, and there is a speedbump
if (rows.some(r => r.speedbump_webhook_id === message.author.id)) return {skip: false} // shortcut
const userHasProxy = select("sim_proxy", "user_id", {proxy_owner_id: message.author.id}).pluck().get()
if (!userHasProxy) return {skip: false} // user has not used PK before, no speedbump
const {skip, hasCreate} = await doSpeedbump(isCreate, message.id)
return {skip, hasCreate} // maybe affected, and there is a speedbump
} }
/** /**
@ -123,27 +87,7 @@ function onMessageDelete(messageID) {
bumping.delete(messageID) bumping.delete(messageID)
} }
/**
* @param {DiscordTypes.APIMessage} message
* @param {string} guildID
* @param {string} roomID
*/
async function getWebhookSenderId(message, guildID, roomID) {
const isMatrixWebhook = select("webhook", "webhook_id", {webhook_id: message.webhook_id}).pluck().get()
if (isMatrixWebhook) return null
const speedbumpUserID = select("channel_speedbump", "speedbump_user_id", {channel_id: message.channel_id, speedbump_webhook_id: message.webhook_id}).pluck().get()
const useWebhookProfile = select("guild_space", "webhook_profile", {guild_id: guildID}).pluck().get() ?? 0
if (speedbumpUserID === "466378653216014359") { // PluralKit public instance
return await registerPkUser.syncUser(message.id, message.author, roomID, true)
} else if (speedbumpUserID === "1291501048493768784" && reg.ooye.plu_ral_api_key) { // /plu/ral public instance
return await registerPluRalUser.syncUser(message.channel_id, message.id, message.author, roomID, true)
} else if (useWebhookProfile) {
return await registerWebhookUser.syncUser(message.author, roomID, true)
}
return null
}
module.exports.updateCache = updateCache module.exports.updateCache = updateCache
module.exports.doSpeedbump = doSpeedbump
module.exports.maybeDoSpeedbump = maybeDoSpeedbump module.exports.maybeDoSpeedbump = maybeDoSpeedbump
module.exports.onMessageDelete = onMessageDelete module.exports.onMessageDelete = onMessageDelete
module.exports.getWebhookSenderId = getWebhookSenderId

View file

@ -28,7 +28,7 @@ async function updatePins(channelID, roomID, convertedTimestamp) {
try { try {
var discordPins = await discord.snow.channel.getChannelPinnedMessages(channelID) var discordPins = await discord.snow.channel.getChannelPinnedMessages(channelID)
} catch (e) { } catch (e) {
if (e.message === "Missing Access") { if (e.message === `{"message": "Missing Access", "code": 50001}`) {
return // Discord sends channel pins update events even for channels that the bot can't view/get pins in, just ignore it return // Discord sends channel pins update events even for channels that the bot can't view/get pins in, just ignore it
} }
throw e throw e

View file

@ -69,8 +69,8 @@ async function editToChanges(message, guild, api) {
// Figure out what we will be replacing them with // Figure out what we will be replacing them with
const newFallbackContent = await messageToEvent.messageToEvent(message, guild, {includeEditFallbackStar: true}, {api, fetch}) const newFallbackContent = await messageToEvent.messageToEvent(message, guild, {includeEditFallbackStar: true}, {api})
const newInnerContent = await messageToEvent.messageToEvent(message, guild, {includeReplyFallback: false}, {api, fetch}) const newInnerContent = await messageToEvent.messageToEvent(message, guild, {includeReplyFallback: false}, {api})
assert.ok(newFallbackContent.length === newInnerContent.length) assert.ok(newFallbackContent.length === newInnerContent.length)
// Match the new events to the old events // Match the new events to the old events

View file

@ -343,18 +343,19 @@ test("edit2changes: generated embed", async t => {
t.deepEqual(eventsToSend, [{ t.deepEqual(eventsToSend, [{
$type: "m.room.message", $type: "m.room.message",
msgtype: "m.notice", msgtype: "m.notice",
body: "| ## This post nerdsniped me, so here's some RULES FOR REAL-LIFE BALATRO https://cohost.org/jkap/post/4794219-empty" body: "| via hthrflwrs on cohost"
+ "\n| \n| ## This post nerdsniped me, so here's some RULES FOR REAL-LIFE BALATRO https://cohost.org/jkap/post/4794219-empty"
+ "\n| \n| 1v1 physical card game. Each player gets one standard deck of cards with a different backing to differentiate. Every turn proceeds as follows:" + "\n| \n| 1v1 physical card game. Each player gets one standard deck of cards with a different backing to differentiate. Every turn proceeds as follows:"
+ "\n| \n| * Both players draw eight cards" + "\n| \n| * Both players draw eight cards"
+ "\n| * Both players may choose up to eight cards to discard, then draw that number of cards to put back in their hand" + "\n| * Both players may choose up to eight cards to discard, then draw that number of cards to put back in their hand"
+ "\n| * Both players present their best five-or-less-card pok..." + "\n| * Both players present their best five-or-less-card pok...",
+ "\n| via hthrflwrs on cohost",
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: `<blockquote><p><strong><a href="https://cohost.org/jkap/post/4794219-empty">This post nerdsniped me, so here's some RULES FOR REAL-LIFE BALATRO</a></strong>` formatted_body: `<blockquote><p><sub>hthrflwrs on cohost</sub>`
+ `</p><p><strong><a href="https://cohost.org/jkap/post/4794219-empty">This post nerdsniped me, so here's some RULES FOR REAL-LIFE BALATRO</a></strong>`
+ `</p><p>1v1 physical card game. Each player gets one standard deck of cards with a different backing to differentiate. Every turn proceeds as follows:` + `</p><p>1v1 physical card game. Each player gets one standard deck of cards with a different backing to differentiate. Every turn proceeds as follows:`
+ `<br><br><ul><li>Both players draw eight cards` + `<br><br><ul><li>Both players draw eight cards`
+ `</li><li>Both players may choose up to eight cards to discard, then draw that number of cards to put back in their hand` + `</li><li>Both players may choose up to eight cards to discard, then draw that number of cards to put back in their hand`
+ `</li><li>Both players present their best five-or-less-card pok...</li></ul></p><sub>hthrflwrs on cohost</sub></blockquote>`, + `</li><li>Both players present their best five-or-less-card pok...</li></ul></p></blockquote>`,
"m.mentions": {} "m.mentions": {}
}]) }])
t.deepEqual(promotions, [{ t.deepEqual(promotions, [{

View file

@ -107,6 +107,7 @@ function tokenise(name) {
* @returns {ProcessedJoined} * @returns {ProcessedJoined}
*/ */
function processJoined(joined) { function processJoined(joined) {
joined = joined.filter(j => !userRegex.some(rx => j.mxid.match(rx)))
return { return {
mxids: joined.map(j => { mxids: joined.map(j => {
const localpart = j.mxid.match(/@([^:]*)/) const localpart = j.mxid.match(/@([^:]*)/)
@ -140,14 +141,8 @@ function findMention(pjr, maximumWrittenSection, baseOffset, prefix, content) {
/** @type {{mxid: string, scored: {score: number, matchedInputTokens: Token[]}}[]} */ /** @type {{mxid: string, scored: {score: number, matchedInputTokens: Token[]}}[]} */
let allItems = pjr.mxids.map(mxid => ({...mxid, scored: scoreLocalpart(mxid.localpart, maximumWrittenSection, mxid.displayname)})) let allItems = pjr.mxids.map(mxid => ({...mxid, scored: scoreLocalpart(mxid.localpart, maximumWrittenSection, mxid.displayname)}))
allItems = allItems.concat(pjr.names.map(name => ({...name, scored: scoreName(name.displaynameTokens, maximumWrittenSectionTokens)}))) allItems = allItems.concat(pjr.names.map(name => ({...name, scored: scoreName(name.displaynameTokens, maximumWrittenSectionTokens)})))
// Tiebreaker bonus for Matrix users const best = allItems.sort((a, b) => b.scored.score - a.scored.score)[0]
for (const item of allItems) { if (best.scored.score > 4) { // requires in smallest case perfect match of 2 characters, or in largest case a partial middle match of 5+ characters in a row
if (!userRegex.some(rx => item.mxid.match(rx))) item.scored.score += 1
}
const [best, secondBest] = allItems.sort((a, b) => b.scored.score - a.scored.score)
if (userRegex.some(rx => best.mxid.match(rx))) return // Discord user was best match, so it was just a written non-pinging @ for a Discord user. Don't highlight a Matrix user.
if (secondBest && secondBest.scored.score === best.scored.score) return // all tied up
if (best.scored.score > 5) { // requires in smallest case Matrix user tiebreaker + perfect match of 2 characters, or in largest case a partial middle match of 5+ characters in a row
// Highlight the relevant part of the message // Highlight the relevant part of the message
const start = baseOffset + best.scored.matchedInputTokens[0].index const start = baseOffset + best.scored.matchedInputTokens[0].index
const end = baseOffset + prefix.length + best.scored.matchedInputTokens.slice(-1)[0].end const end = baseOffset + prefix.length + best.scored.matchedInputTokens.slice(-1)[0].end

View file

@ -117,47 +117,6 @@ test("score name: finds match location", t => {
t.equal(message.slice(startLocation, endLocation), "evil lillith") t.equal(message.slice(startLocation, endLocation), "evil lillith")
}) })
test("score name: matches the correct jan", t => {
const names = [
"jan Kanoli",
"jan Mijawi",
"jan Mukin"
]
t.deepEqual(names.map(n => scoreName(tokenise(n), tokenise("jan Mijawi")).score), [
6,
18.4,
6
])
})
test("find mention: highlights nothing if a Discord user scored highest", t => {
const found = findMention(processJoined([{
mxid: "@jankanoli:matrix.org",
displayname: "jan Kanoli"
}, {
mxid: "@_ooye_jan_mijawi:cadence.moe",
displayname: "jan Mijawi"
}, {
mxid: "@_ooye_jan_mukin:cadence.moe",
displayname: "jan Mukin"
}]), "jan Mijawi, run this\n.def", 0, "@", "jan Mijawi, run this\n.def")
t.equal(found, undefined)
})
test("find mention: highlights nothing if all tied", t => {
const found = findMention(processJoined([{
mxid: "@jankanoli:matrix.org",
displayname: "jan Kanoli"
}, {
mxid: "@_ooye_jan_mijawi:cadence.moe",
displayname: "jan Mijawi"
}, {
mxid: "@_ooye_jan_mukin:cadence.moe",
displayname: "jan Mukin"
}]), "jan Misali, run this\n.def", 0, "@", "jan Misali, run this\n.def")
t.equal(found, undefined)
})
test("find mention: test various tiebreakers", t => { test("find mention: test various tiebreakers", t => {
const found = findMention(processJoined([{ const found = findMention(processJoined([{
mxid: "@emma:conduit.rory.gay", mxid: "@emma:conduit.rory.gay",

View file

@ -109,11 +109,10 @@ const embedTitleParser = markdown.markdownEngine.parserFor({
/** /**
* @param {{room?: boolean, user_ids?: string[]}} mentions * @param {{room?: boolean, user_ids?: string[]}} mentions
* @param {Omit<DiscordTypes.APIAttachment, "id" | "proxy_url"> & {proxy_url?: string}} attachment * @param {Omit<DiscordTypes.APIAttachment, "id" | "proxy_url">} attachment
* @param {boolean} [alwaysLink] * @param {boolean} [alwaysLink]
* @param {{fetch?: typeof fetch}} [di]
*/ */
async function attachmentToEvent(mentions, attachment, alwaysLink, di) { async function attachmentToEvent(mentions, attachment, alwaysLink) {
const external_url = dUtils.getPublicUrlForCdn(attachment.url) const external_url = dUtils.getPublicUrlForCdn(attachment.url)
const emoji = const emoji =
attachment.content_type?.startsWith("image/jp") ? "📸" attachment.content_type?.startsWith("image/jp") ? "📸"
@ -123,7 +122,7 @@ async function attachmentToEvent(mentions, attachment, alwaysLink, di) {
: attachment.content_type?.startsWith("audio/") ? "🎶" : attachment.content_type?.startsWith("audio/") ? "🎶"
: "📄" : "📄"
// no native media spoilers in Element, so we'll post a link instead, forcing it to not preview using a blockquote // no native media spoilers in Element, so we'll post a link instead, forcing it to not preview using a blockquote
if (attachment.filename.startsWith("SPOILER_") || !!((attachment.flags || 0) & DiscordTypes.AttachmentFlags.IsSpoiler)) { if (attachment.filename.startsWith("SPOILER_")) {
return { return {
$type: "m.room.message", $type: "m.room.message",
"m.mentions": mentions, "m.mentions": mentions,
@ -133,37 +132,8 @@ async function attachmentToEvent(mentions, attachment, alwaysLink, di) {
formatted_body: `<blockquote>${emoji} Uploaded SPOILER file: <a href="${external_url}">${external_url}</a> (${pb(attachment.size)})</blockquote>` formatted_body: `<blockquote>${emoji} Uploaded SPOILER file: <a href="${external_url}">${external_url}</a> (${pb(attachment.size)})</blockquote>`
} }
} }
// attempt to convert large PNG image files to JPEG, since it's almost certainly a photo that was forced into PNG by the user copy-pasting.
if (
attachment.content_type === "image/png" && attachment.size > reg.ooye.max_file_size
&& !alwaysLink && attachment.proxy_url && attachment.width && attachment.height && di?.fetch
) {
const proxyUrl = new URL(attachment.proxy_url)
proxyUrl.searchParams.set("format", "jpeg")
const jpegUrl = proxyUrl.toString()
const res = await di.fetch(jpegUrl, {method: "HEAD"})
const newFilename = attachment.filename.replace(/\.png$/, ".jpg")
const newSize = Number(res.headers.get("content-length"))
if (res.ok && res.headers.get("content-type") === "image/jpeg" && !res.headers.has("content-encoding") && newSize <= reg.ooye.max_file_size) {
return {
$type: "m.room.message",
"m.mentions": mentions,
msgtype: "m.image",
url: await file.uploadDiscordFileToMxc(jpegUrl),
external_url,
body: attachment.description || newFilename,
filename: newFilename,
info: {
mimetype: "image/jpeg",
w: attachment.width,
h: attachment.height,
size: newSize
}
}
}
}
// 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
if (alwaysLink || attachment.size > reg.ooye.max_file_size) { else if (alwaysLink || attachment.size > reg.ooye.max_file_size) {
return { return {
$type: "m.room.message", $type: "m.room.message",
"m.mentions": mentions, "m.mentions": mentions,
@ -172,8 +142,7 @@ async function attachmentToEvent(mentions, attachment, alwaysLink, di) {
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: `${emoji} Uploaded file: <a href="${external_url}">${attachment.filename}</a> (${pb(attachment.size)})` formatted_body: `${emoji} Uploaded file: <a href="${external_url}">${attachment.filename}</a> (${pb(attachment.size)})`
} }
} } else if (attachment.content_type?.startsWith("image/") && attachment.width && attachment.height) {
if (attachment.content_type?.startsWith("image/") && attachment.width && attachment.height) {
return { return {
$type: "m.room.message", $type: "m.room.message",
"m.mentions": mentions, "m.mentions": mentions,
@ -189,8 +158,7 @@ async function attachmentToEvent(mentions, attachment, alwaysLink, di) {
size: attachment.size size: attachment.size
} }
} }
} } else if (attachment.content_type?.startsWith("video/") && attachment.width && attachment.height) {
if (attachment.content_type?.startsWith("video/") && attachment.width && attachment.height) {
return { return {
$type: "m.room.message", $type: "m.room.message",
"m.mentions": mentions, "m.mentions": mentions,
@ -206,8 +174,7 @@ async function attachmentToEvent(mentions, attachment, alwaysLink, di) {
size: attachment.size size: attachment.size
} }
} }
} } else if (attachment.content_type?.startsWith("audio/")) {
if (attachment.content_type?.startsWith("audio/")) {
return { return {
$type: "m.room.message", $type: "m.room.message",
"m.mentions": mentions, "m.mentions": mentions,
@ -222,19 +189,19 @@ async function attachmentToEvent(mentions, attachment, alwaysLink, di) {
duration: attachment.duration_secs && Math.round(attachment.duration_secs * 1000) duration: attachment.duration_secs && Math.round(attachment.duration_secs * 1000)
} }
} }
} } else {
// else return {
return { $type: "m.room.message",
$type: "m.room.message", "m.mentions": mentions,
"m.mentions": mentions, msgtype: "m.file",
msgtype: "m.file", url: await file.uploadDiscordFileToMxc(attachment.url),
url: await file.uploadDiscordFileToMxc(attachment.url), external_url,
external_url, 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 }
} }
} }
} }
@ -289,8 +256,8 @@ function getFormattedInteraction(interaction, isThinkingInteraction) {
const username = interaction.member?.nick || interaction.user.global_name || interaction.user.username const username = interaction.member?.nick || interaction.user.global_name || interaction.user.username
const thinkingText = isThinkingInteraction ? " — interaction loading..." : "" const thinkingText = isThinkingInteraction ? " — interaction loading..." : ""
return { return {
body: ` ${username} used \`/${interaction.name}\`${thinkingText}`, body: `↪️ ${username} used \`/${interaction.name}\`${thinkingText}`,
html: `<blockquote><sub> ${mxid ? tag`<a href="https://matrix.to/#/${mxid}">${username}</a>` : username} used <code>/${interaction.name}</code>${thinkingText}</sub></blockquote>` html: `<blockquote><sub>↪️ ${mxid ? tag`<a href="https://matrix.to/#/${mxid}">${username}</a>` : username} used <code>/${interaction.name}</code>${thinkingText}</sub></blockquote>`
} }
} }
@ -298,9 +265,8 @@ function getFormattedInteraction(interaction, isThinkingInteraction) {
* @param {any} newEvents merge into events * @param {any} newEvents merge into events
* @param {any} events will be modified * @param {any} events will be modified
* @param {boolean} forceSameMsgtype whether m.text may only be combined with m.text, etc * @param {boolean} forceSameMsgtype whether m.text may only be combined with m.text, etc
* @param {boolean} [forceMerge] if true, must merge event, will error if it had to append
*/ */
function mergeTextEvents(newEvents, events, forceSameMsgtype, forceMerge = false) { function mergeTextEvents(newEvents, events, forceSameMsgtype) {
let prev = events.at(-1) let prev = events.at(-1)
for (const ne of newEvents) { for (const ne of newEvents) {
const isAllText = prev?.body && prev?.formatted_body && ["m.text", "m.notice"].includes(ne.msgtype) && ["m.text", "m.notice"].includes(prev?.msgtype) const isAllText = prev?.body && prev?.formatted_body && ["m.text", "m.notice"].includes(ne.msgtype) && ["m.text", "m.notice"].includes(prev?.msgtype)
@ -312,8 +278,6 @@ function mergeTextEvents(newEvents, events, forceSameMsgtype, forceMerge = false
rep.addLine(ne.body, ne.formatted_body) rep.addLine(ne.body, ne.formatted_body)
prev.body = rep.body prev.body = rep.body
prev.formatted_body = rep.formattedBody prev.formatted_body = rep.formattedBody
} else if (forceMerge) {
throw new Error("Unable to merge events")
} else { } else {
events.push(ne) events.push(ne)
} }
@ -328,7 +292,7 @@ function mergeTextEvents(newEvents, events, forceSameMsgtype, forceMerge = false
* - includeEditFallbackStar: false * - includeEditFallbackStar: false
* - alwaysReturnFormattedBody: false - formatted_body will be skipped if it is the same as body because the message is plaintext. if you want the formatted_body to be returned anyway, for example to merge it with another message, then set this to true. * - alwaysReturnFormattedBody: false - formatted_body will be skipped if it is the same as body because the message is plaintext. if you want the formatted_body to be returned anyway, for example to merge it with another message, then set this to true.
* - scanTextForMentions: true - needs to be set to false when converting forwarded messages etc which may be from a different channel that can't be scanned. * - scanTextForMentions: true - needs to be set to false when converting forwarded messages etc which may be from a different channel that can't be scanned.
* @param {{api: import("../../matrix/api"), snow?: import("snowtransfer").SnowTransfer, fetch?: typeof fetch}} di simple-as-nails dependency injection for the matrix API * @param {{api: import("../../matrix/api"), snow?: import("snowtransfer").SnowTransfer}} di simple-as-nails dependency injection for the matrix API
* @returns {Promise<{$type: string, $sender?: string, [x: string]: any}[]>} * @returns {Promise<{$type: string, $sender?: string, [x: string]: any}[]>}
*/ */
async function messageToEvent(message, guild, options = {}, di) { async function messageToEvent(message, guild, options = {}, di) {
@ -590,7 +554,7 @@ async function messageToEvent(message, guild, options = {}, di) {
// 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?):([^:>]+):([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]
@ -674,9 +638,10 @@ async function messageToEvent(message, guild, options = {}, di) {
html = "* " + html html = "* " + html
} }
if (((message.flags || 0) & DiscordTypes.MessageFlags.IsCrosspost) && message.author?.username) { const flags = message.flags || 0
body = `[↷ ${message.author.username}]\n` + body if (flags & DiscordTypes.MessageFlags.IsCrosspost) {
html = `↷ <strong>${message.author.username}</strong><br>` + html body = `[🔀 ${message.author.username}]\n` + body
html = `🔀 <strong>${message.author.username}</strong><br>` + html
} }
// Fallback body/formatted_body for replies // Fallback body/formatted_body for replies
@ -704,7 +669,7 @@ async function messageToEvent(message, guild, options = {}, di) {
const match = repliedToEventSenderMxid.match(/^@([^:]*)/) const match = repliedToEventSenderMxid.match(/^@([^:]*)/)
assert(match) assert(match)
repliedToDisplayName = referenced.author.username || match[1] || "a Matrix user" // grab the localpart as the display name, whatever repliedToDisplayName = referenced.author.username || match[1] || "a Matrix user" // grab the localpart as the display name, whatever
repliedToUserHtml = tag`<a href="https://matrix.to/#/${repliedToEventSenderMxid}">${repliedToDisplayName}</a>` repliedToUserHtml = `<a href="https://matrix.to/#/${repliedToEventSenderMxid}">${repliedToDisplayName}</a>`
} else { } else {
repliedToDisplayName = referenced.author.global_name || referenced.author.username || "a Discord user" repliedToDisplayName = referenced.author.global_name || referenced.author.username || "a Discord user"
repliedToUserHtml = repliedToDisplayName repliedToUserHtml = repliedToDisplayName
@ -729,12 +694,6 @@ async function messageToEvent(message, guild, options = {}, di) {
+ html + html
body = `${repliedToDisplayName}: ${repliedToBody}`.split("\n").map(line => "> " + line).join("\n") // scenario 1 part B for mentions body = `${repliedToDisplayName}: ${repliedToBody}`.split("\n").map(line => "> " + line).join("\n") // scenario 1 part B for mentions
+ "\n\n" + body + "\n\n" + body
} else if (referenced.type === DiscordTypes.MessageType.UserJoin) {
// Discord user join messages are bridged as joins, not text events. Generate substitute text for reply.
const joinerMxid = select("sim", "mxid", {user_id: referenced.author.id}).pluck().get()
const joinerHtml = joinerMxid ? tag`<a href="https://matrix.to/#/${joinerMxid}">${repliedToDisplayName}</a>` : tag`<strong>${repliedToDisplayName}</strong>`
html = `<blockquote>${joinerHtml} joined the room</blockquote>` + html
body = `> ${repliedToDisplayName} joined the room\n\n` + body
} else { // repliedToUnknownEvent } else { // repliedToUnknownEvent
const dateDisplay = dUtils.howOldUnbridgedMessage(referenced.timestamp, message.timestamp) const dateDisplay = dUtils.howOldUnbridgedMessage(referenced.timestamp, message.timestamp)
html = `<blockquote>In reply to ${dateDisplay} from ${repliedToDisplayName}:` html = `<blockquote>In reply to ${dateDisplay} from ${repliedToDisplayName}:`
@ -803,20 +762,20 @@ async function messageToEvent(message, guild, options = {}, di) {
if (row && "event_id" in row) { if (row && "event_id" in row) {
const via = await getViaServersMemo(row.room_id) const via = await getViaServersMemo(row.room_id)
forwardedNotice.addLine( forwardedNotice.addLine(
`[ Forwarded from #${roomName}]`, `[🔀 Forwarded from #${roomName}]`,
tag` <em>Forwarded from ${roomName} <a href="https://matrix.to/#/${room.room_id}/${row.event_id}?${via}">[jump to event]</a></em>` tag`🔀 <em>Forwarded from ${roomName} <a href="https://matrix.to/#/${room.room_id}/${row.event_id}?${via}">[jump to event]</a></em>`
) )
} else { } else {
const via = await getViaServersMemo(room.room_id) const via = await getViaServersMemo(room.room_id)
forwardedNotice.addLine( forwardedNotice.addLine(
`[ Forwarded from #${roomName}]`, `[🔀 Forwarded from #${roomName}]`,
tag` <em>Forwarded from ${roomName} <a href="https://matrix.to/#/${room.room_id}?${via}">[jump to room]</a></em>` tag`🔀 <em>Forwarded from ${roomName} <a href="https://matrix.to/#/${room.room_id}?${via}">[jump to room]</a></em>`
) )
} }
} else { } else {
forwardedNotice.addLine( forwardedNotice.addLine(
`[ Forwarded message]`, `[🔀 Forwarded message]`,
tag` <em>Forwarded message</em>` tag`🔀 <em>Forwarded message</em>`
) )
} }
@ -867,7 +826,7 @@ async function messageToEvent(message, guild, options = {}, di) {
invite = await di.snow.invite.getInvite(match[1], {guild_scheduled_event_id: match[2]}) invite = await di.snow.invite.getInvite(match[1], {guild_scheduled_event_id: match[2]})
} catch (e) { } catch (e) {
// Skip expired/invalid invites and events // Skip expired/invalid invites and events
if (e.message === "Unknown Invite") { if (e.message === `{"message": "Unknown Invite", "code": 10006}`) {
break break
} else { } else {
throw e throw e
@ -915,7 +874,7 @@ async function messageToEvent(message, guild, options = {}, di) {
// Then attachments // Then attachments
if (message.attachments) { if (message.attachments) {
const attachmentEvents = await Promise.all(message.attachments.map(attachment => attachmentToEvent(mentions, attachment, false, {fetch: di?.fetch}))) const attachmentEvents = await Promise.all(message.attachments.map(attachment => attachmentToEvent(mentions, attachment)))
// Try to merge attachment events with the previous event // Try to merge attachment events with the previous event
// This means that if the attachments ended up as a text link, and especially if there were many of them, the events will be joined together. // This means that if the attachments ended up as a text link, and especially if there were many of them, the events will be joined together.
@ -939,14 +898,7 @@ async function messageToEvent(message, guild, options = {}, di) {
/** @type {{[k in keyof DiscordTypes.APIUnfurledMediaItem]-?: NonNullable<DiscordTypes.APIUnfurledMediaItem[k]>}} */ // @ts-ignore /** @type {{[k in keyof DiscordTypes.APIUnfurledMediaItem]-?: NonNullable<DiscordTypes.APIUnfurledMediaItem[k]>}} */ // @ts-ignore
const file = component.file const file = component.file
assert(component.name && component.size && file.content_type) assert(component.name && component.size && file.content_type)
const ev = await attachmentToEvent({}, { const ev = await attachmentToEvent({}, {...file, filename: component.name, size: component.size}, true)
filename: component.name,
content_type: file.content_type,
size: component.size,
url: file.url,
height: file.height,
width: file.width,
}, true, {fetch: di?.fetch})
stack.msb.addLine(ev.body, ev.formatted_body) stack.msb.addLine(ev.body, ev.formatted_body)
} }
else if (component.type === DiscordTypes.ComponentType.MediaGallery) { else if (component.type === DiscordTypes.ComponentType.MediaGallery) {
@ -1009,8 +961,7 @@ async function messageToEvent(message, guild, options = {}, di) {
// May only be a section accessory or in an action row (up to 5) // May only be a section accessory or in an action row (up to 5)
if (component.style === DiscordTypes.ButtonStyle.Link) { if (component.style === DiscordTypes.ButtonStyle.Link) {
assert(component.label) // required for Discord to validate link buttons assert(component.label) // required for Discord to validate link buttons
const link = await transformContentMessageLinks(component.url) stack.msb.add(`[${component.label} ${component.url}] `, tag`<a href="${component.url}">${component.label}</a> `)
stack.msb.add(`[${component.label} ${link}] `, tag`<a href="${link}">${component.label}</a> `)
} }
} }
@ -1023,19 +974,7 @@ async function messageToEvent(message, guild, options = {}, di) {
const {body, formatted_body} = stack.msb.get() const {body, formatted_body} = stack.msb.get()
if (body.trim().length) { if (body.trim().length) {
// Create new message if Components V2 (cannot have regular content) await addTextEvent(body, formatted_body, "m.text")
if ((message.flags ?? 0) & DiscordTypes.MessageFlags.IsComponentsV2) {
await addTextEvent(body, formatted_body, "m.text")
}
// Add to existing message if legacy components https://docs.discord.com/developers/components/reference#legacy-message-component-behavior
else {
mergeTextEvents([{
msgtype: "m.text",
body,
format: "org.matrix.custom.html",
formatted_body
}], events, false, true)
}
} }
} }
@ -1094,6 +1033,15 @@ async function messageToEvent(message, guild, options = {}, di) {
continue continue
} }
// Provider
if (embed.provider?.name && embed.provider.name !== "Tenor") {
if (embed.provider.url) {
rep.addParagraph(`via ${embed.provider.name} ${embed.provider.url}`, tag`<sub><a href="${embed.provider.url}">${embed.provider.name}</a></sub>`)
} else {
rep.addParagraph(`via ${embed.provider.name}`, tag`<sub>${embed.provider.name}</sub>`)
}
}
// Author and URL into a paragraph // Author and URL into a paragraph
let authorNameText = embed.author?.name || "" let authorNameText = embed.author?.name || ""
if (authorNameText && embed.author?.icon_url) authorNameText = `⏺️ ${authorNameText}` // using the emoji instead of an image if (authorNameText && embed.author?.icon_url) authorNameText = `⏺️ ${authorNameText}` // using the emoji instead of an image
@ -1143,18 +1091,7 @@ async function messageToEvent(message, guild, options = {}, di) {
if (embed.video?.url) rep.addParagraph(`🎞️ ${dUtils.getPublicUrlForCdn(embed.video.url)}`) if (embed.video?.url) rep.addParagraph(`🎞️ ${dUtils.getPublicUrlForCdn(embed.video.url)}`)
// Provider and footer (moved to bottom, below author/title, to improve room preview line) if (embed.footer?.text) rep.addLine(`${embed.footer.text}`, tag`${embed.footer.text}`)
if (embed.provider?.name && embed.provider.name !== "Tenor" && embed.provider.name !== "YouTube") {
const insertableFooterText = embed.footer?.text ? `${embed.footer.text}` : ""
if (embed.provider.url) {
rep.addLine(`via ${embed.provider.name}${insertableFooterText || " " + embed.provider.url}`, tag`<sub><a href="${embed.provider.url}">${embed.provider.name}</a>${insertableFooterText}</sub>`)
} else {
rep.addLine(`via ${embed.provider.name}${insertableFooterText}`, tag`<sub>${embed.provider.name}${insertableFooterText}</sub>`)
}
} else 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()
body = body.split("\n").map(l => "| " + l).join("\n") body = body.split("\n").map(l => "| " + l).join("\n")
html = `<blockquote>${html}</blockquote>` html = `<blockquote>${html}</blockquote>`
@ -1184,7 +1121,7 @@ async function messageToEvent(message, guild, options = {}, di) {
} }
} else { } else {
let body = stickerItem.name let body = stickerItem.name
const sticker = guild.stickers?.find(sticker => sticker.id === stickerItem.id) const sticker = guild.stickers.find(sticker => sticker.id === stickerItem.id)
if (sticker && sticker.description) body += ` - ${sticker.description}` if (sticker && sticker.description) body += ` - ${sticker.description}`
return { return {
$type: "m.sticker", $type: "m.sticker",
@ -1201,7 +1138,7 @@ async function messageToEvent(message, guild, options = {}, di) {
} }
// Rich replies // Rich replies
if (repliedToEventRow && !repliedToEventInDifferentRoom && events[0]) { if (repliedToEventRow && !repliedToEventInDifferentRoom) {
Object.assign(events[0], { Object.assign(events[0], {
"m.relates_to": { "m.relates_to": {
"m.in_reply_to": { "m.in_reply_to": {

View file

@ -1,7 +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 {mockGetEffectivePower} = require("../../matrix/utils.test")
test("message2event components: pk question mark output", async t => { test("message2event components: pk question mark output", async t => {
const events = await messageToEvent(data.message_with_components.pk_question_mark_response, data.guild.general, {}) const events = await messageToEvent(data.message_with_components.pk_question_mark_response, data.guild.general, {})
@ -78,24 +77,3 @@ test("message2event components: pk question mark output", async t => {
msgtype: "m.text", msgtype: "m.text",
}]) }])
}) })
test("message2event components: pk ping message legacy components", async t => {
const events = await messageToEvent(data.message_with_components.pk_ping_components_v1, data.guild.general, {}, {
api: {
async getJoinedMembers() {
return {joined: {}}
},
getEffectivePower: mockGetEffectivePower()
}
})
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.text",
body: "❭ cadence used `/🔔 Ping author`"
+ "\nPsst, **Red** (@cadence.worm:), you have been pinged by @cadence.worm:."
+ "\n[Jump https://matrix.to/#/!TqlyQmifxGUggEmdBN:cadence.moe/$l9FMmsEbh9K0NUReeEpWOMZYGRlUOE8yLcm6P-TYHSM?via=cadence.moe] ",
format: "org.matrix.custom.html",
formatted_body: "<blockquote><sub>❭ <a href=\"https://matrix.to/#/@_ooye_cadence:cadence.moe\">cadence</a> used <code>/🔔 Ping author</code></sub></blockquote>Psst, <strong>Red</strong> (<a href=\"https://matrix.to/#/@_ooye_cadence:cadence.moe\">@cadence.worm</a>), you have been pinged by <a href=\"https://matrix.to/#/@_ooye_cadence:cadence.moe\">@cadence.worm</a>.<br><a href=\"https://matrix.to/#/!TqlyQmifxGUggEmdBN:cadence.moe/$l9FMmsEbh9K0NUReeEpWOMZYGRlUOE8yLcm6P-TYHSM?via=cadence.moe\">Jump</a> ",
"m.mentions": {}
}])
})

View file

@ -8,9 +8,9 @@ test("message2event embeds: interaction loading", async t => {
const events = await messageToEvent(data.interaction_message.thinking_interaction, data.guild.general, {}) const events = await messageToEvent(data.interaction_message.thinking_interaction, data.guild.general, {})
t.deepEqual(events, [{ t.deepEqual(events, [{
$type: "m.room.message", $type: "m.room.message",
body: " Brad used `/stats` — interaction loading...", body: "↪️ Brad used `/stats` — interaction loading...",
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: "<blockquote><sub> <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">Brad</a> used <code>/stats</code> — interaction loading...</sub></blockquote>", formatted_body: "<blockquote><sub>↪️ <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">Brad</a> used <code>/stats</code> — interaction loading...</sub></blockquote>",
"m.mentions": {}, "m.mentions": {},
msgtype: "m.notice", msgtype: "m.notice",
}]) }])
@ -22,12 +22,12 @@ test("message2event embeds: nothing but a field", async t => {
$type: "m.room.message", $type: "m.room.message",
"m.mentions": {}, "m.mentions": {},
msgtype: "m.notice", msgtype: "m.notice",
body: " PapiOphidian used `/stats`" body: "↪️ PapiOphidian used `/stats`"
+ "\n| ### Amanda 🎵#2192 :online:" + "\n| ### Amanda 🎵#2192 :online:"
+ "\n| willow tree, branch 0" + "\n| willow tree, branch 0"
+ "\n| ** Uptime:**\n| 3m 55s\n| ** Memory:**\n| 64.45MB", + "\n| ** Uptime:**\n| 3m 55s\n| ** Memory:**\n| 64.45MB",
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: '<blockquote><sub> <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">PapiOphidian</a> used <code>/stats</code></sub></blockquote>' formatted_body: '<blockquote><sub>↪️ <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">PapiOphidian</a> used <code>/stats</code></sub></blockquote>'
+ '<blockquote><p><strong>Amanda 🎵#2192 <img data-mx-emoticon height=\"32\" src=\"mxc://cadence.moe/LCEqjStXCxvRQccEkuslXEyZ\" title=\":online:\" alt=\":online:\">' + '<blockquote><p><strong>Amanda 🎵#2192 <img data-mx-emoticon height=\"32\" src=\"mxc://cadence.moe/LCEqjStXCxvRQccEkuslXEyZ\" title=\":online:\" alt=\":online:\">'
+ '<br>willow tree, branch 0</strong>' + '<br>willow tree, branch 0</strong>'
+ '<br><strong> Uptime:</strong><br>3m 55s' + '<br><strong> Uptime:</strong><br>3m 55s'
@ -153,10 +153,10 @@ test("message2event embeds: title without url", async t => {
t.deepEqual(events, [{ t.deepEqual(events, [{
$type: "m.room.message", $type: "m.room.message",
msgtype: "m.notice", msgtype: "m.notice",
body: " PapiOphidian used `/stats`" body: "↪️ PapiOphidian used `/stats`"
+ "\n| ## Hi, I'm Amanda!\n| \n| I condone pirating music!", + "\n| ## Hi, I'm Amanda!\n| \n| I condone pirating music!",
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: '<blockquote><sub> <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">PapiOphidian</a> used <code>/stats</code></sub></blockquote>' formatted_body: '<blockquote><sub>↪️ <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">PapiOphidian</a> used <code>/stats</code></sub></blockquote>'
+ `<blockquote><p><strong>Hi, I'm Amanda!</strong></p><p>I condone pirating music!</p></blockquote>`, + `<blockquote><p><strong>Hi, I'm Amanda!</strong></p><p>I condone pirating music!</p></blockquote>`,
"m.mentions": {} "m.mentions": {}
}]) }])
@ -167,10 +167,10 @@ test("message2event embeds: url without title", async t => {
t.deepEqual(events, [{ t.deepEqual(events, [{
$type: "m.room.message", $type: "m.room.message",
msgtype: "m.notice", msgtype: "m.notice",
body: " PapiOphidian used `/stats`" body: "↪️ PapiOphidian used `/stats`"
+ "\n| I condone pirating music!", + "\n| I condone pirating music!",
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: '<blockquote><sub> <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">PapiOphidian</a> used <code>/stats</code></sub></blockquote>' formatted_body: '<blockquote><sub>↪️ <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">PapiOphidian</a> used <code>/stats</code></sub></blockquote>'
+ `<blockquote><p>I condone pirating music!</p></blockquote>`, + `<blockquote><p>I condone pirating music!</p></blockquote>`,
"m.mentions": {} "m.mentions": {}
}]) }])
@ -181,10 +181,10 @@ test("message2event embeds: author without url", async t => {
t.deepEqual(events, [{ t.deepEqual(events, [{
$type: "m.room.message", $type: "m.room.message",
msgtype: "m.notice", msgtype: "m.notice",
body: " PapiOphidian used `/stats`" body: "↪️ PapiOphidian used `/stats`"
+ "\n| ## Amanda\n| \n| I condone pirating music!", + "\n| ## Amanda\n| \n| I condone pirating music!",
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: '<blockquote><sub> <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">PapiOphidian</a> used <code>/stats</code></sub></blockquote>' formatted_body: '<blockquote><sub>↪️ <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">PapiOphidian</a> used <code>/stats</code></sub></blockquote>'
+ `<blockquote><p><strong>Amanda</strong></p><p>I condone pirating music!</p></blockquote>`, + `<blockquote><p><strong>Amanda</strong></p><p>I condone pirating music!</p></blockquote>`,
"m.mentions": {} "m.mentions": {}
}]) }])
@ -195,51 +195,23 @@ test("message2event embeds: author url without name", async t => {
t.deepEqual(events, [{ t.deepEqual(events, [{
$type: "m.room.message", $type: "m.room.message",
msgtype: "m.notice", msgtype: "m.notice",
body: " PapiOphidian used `/stats`" body: "↪️ PapiOphidian used `/stats`"
+ "\n| I condone pirating music!", + "\n| I condone pirating music!",
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: '<blockquote><sub> <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">PapiOphidian</a> used <code>/stats</code></sub></blockquote>' formatted_body: '<blockquote><sub>↪️ <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">PapiOphidian</a> used <code>/stats</code></sub></blockquote>'
+ `<blockquote><p>I condone pirating music!</p></blockquote>`, + `<blockquote><p>I condone pirating music!</p></blockquote>`,
"m.mentions": {} "m.mentions": {}
}]) }])
}) })
test("message2event embeds: provider and footer", async t => {
const events = await messageToEvent(data.message_with_embeds.embed_provider_and_footer, data.guild.general)
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.text",
body: "https://cohost.org/jkap/post/4794219-empty",
format: "org.matrix.custom.html",
formatted_body: `<a href="https://cohost.org/jkap/post/4794219-empty">https://cohost.org/jkap/post/4794219-empty</a>`,
"m.mentions": {}
}, {
$type: "m.room.message",
msgtype: "m.notice",
body: "| ## This post nerdsniped me, so here's some RULES FOR REAL-LIFE BALATRO https://cohost.org/jkap/post/4794219-empty"
+ "\n| \n| 1v1 physical card game. Each player gets one standard deck of cards with a different backing to differentiate. Every turn proceeds as follows:"
+ "\n| \n| * Both players draw eight cards"
+ "\n| * Both players may choose up to eight cards to discard, then draw that number of cards to put back in their hand"
+ "\n| * Both players present their best five-or-less-card pok..."
+ "\n| via hthrflwrs on cohost — 29/2/2024, 2:17 PM",
format: "org.matrix.custom.html",
formatted_body: `<blockquote><p><strong><a href="https://cohost.org/jkap/post/4794219-empty">This post nerdsniped me, so here's some RULES FOR REAL-LIFE BALATRO</a></strong>`
+ `</p><p>1v1 physical card game. Each player gets one standard deck of cards with a different backing to differentiate. Every turn proceeds as follows:`
+ `<br><br><ul><li>Both players draw eight cards`
+ `</li><li>Both players may choose up to eight cards to discard, then draw that number of cards to put back in their hand`
+ `</li><li>Both players present their best five-or-less-card pok...</li></ul></p><sub>hthrflwrs on cohost — 29/2/2024, 2:17 PM</sub></blockquote>`,
"m.mentions": {}
}])
})
test("message2event embeds: 4 images", async t => { test("message2event embeds: 4 images", async t => {
const events = await messageToEvent(data.message_with_embeds.four_images, data.guild.general) const events = await messageToEvent(data.message_with_embeds.four_images, data.guild.general)
t.deepEqual(events, [{ t.deepEqual(events, [{
$type: "m.room.message", $type: "m.room.message",
msgtype: "m.text", msgtype: "m.text",
body: "[ Forwarded message]\n» https://fixupx.com/i/status/2032003668787020046", body: "[🔀 Forwarded message]\n» https://fixupx.com/i/status/2032003668787020046",
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: " <em>Forwarded message</em><br><blockquote><a href=\"https://fixupx.com/i/status/2032003668787020046\">https://fixupx.com/i/status/2032003668787020046</a></blockquote>", formatted_body: "🔀 <em>Forwarded message</em><br><blockquote><a href=\"https://fixupx.com/i/status/2032003668787020046\">https://fixupx.com/i/status/2032003668787020046</a></blockquote>",
"m.mentions": {} "m.mentions": {}
}, { }, {
$type: "m.room.message", $type: "m.room.message",
@ -282,7 +254,9 @@ test("message2event embeds: vx image", async t => {
}, { }, {
$type: "m.room.message", $type: "m.room.message",
msgtype: "m.notice", msgtype: "m.notice",
body: "| ## Twitter https://twitter.com/tomorrowcorp/status/1760330671074287875" body: "| via vxTwitter / fixvx https://github.com/dylanpdx/BetterTwitFix"
+ "\n| "
+ "\n| ## Twitter https://twitter.com/tomorrowcorp/status/1760330671074287875"
+ "\n| " + "\n| "
+ "\n| ## Tomorrow Corporation (@TomorrowCorp) https://vxtwitter.com/TomorrowCorp/status/1760330671074287875" + "\n| ## Tomorrow Corporation (@TomorrowCorp) https://vxtwitter.com/TomorrowCorp/status/1760330671074287875"
+ "\n| " + "\n| "
@ -292,16 +266,15 @@ test("message2event embeds: vx image", async t => {
+ "\n| " + "\n| "
+ "\n| 💖 123 🔁 36" + "\n| 💖 123 🔁 36"
+ "\n| " + "\n| "
+ "\n| 📸 https://pbs.twimg.com/media/GG3zUMGbIAAxs3h.jpg" + "\n| 📸 https://pbs.twimg.com/media/GG3zUMGbIAAxs3h.jpg",
+ "\n| via vxTwitter / fixvx https://github.com/dylanpdx/BetterTwitFix",
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: `<blockquote><p><strong><a href="https://twitter.com/tomorrowcorp/status/1760330671074287875">Twitter</a></strong>` formatted_body: `<blockquote><p><sub><a href="https://github.com/dylanpdx/BetterTwitFix">vxTwitter / fixvx</a></sub>`
+ `</p><p><strong><a href="https://twitter.com/tomorrowcorp/status/1760330671074287875">Twitter</a></strong>`
+ `</p><p><strong><a href="https://vxtwitter.com/TomorrowCorp/status/1760330671074287875">Tomorrow Corporation (@TomorrowCorp)</a></strong>` + `</p><p><strong><a href="https://vxtwitter.com/TomorrowCorp/status/1760330671074287875">Tomorrow Corporation (@TomorrowCorp)</a></strong>`
+ `</p><p>Mark your calendar with a wet black stain! World of Goo 2 releases on May 23, 2024 on Nintendo Switch, Epic Games Store (Win/Mac), and <a href="http://WorldOfGoo2.com">http://WorldOfGoo2.com</a> (Win/Mac/Linux).` + `</p><p>Mark your calendar with a wet black stain! World of Goo 2 releases on May 23, 2024 on Nintendo Switch, Epic Games Store (Win/Mac), and <a href="http://WorldOfGoo2.com">http://WorldOfGoo2.com</a> (Win/Mac/Linux).`
+ `<br><br><a href="https://tomorrowcorporation.com/posts/world-of-goo-2-now-with-100-more-release-dates-and-platforms">https://tomorrowcorporation.com/posts/world-of-goo-2-now-with-100-more-release-dates-and-platforms</a>` + `<br><br><a href="https://tomorrowcorporation.com/posts/world-of-goo-2-now-with-100-more-release-dates-and-platforms">https://tomorrowcorporation.com/posts/world-of-goo-2-now-with-100-more-release-dates-and-platforms</a>`
+ `<br><br>💖 123 🔁 36` + `<br><br>💖 123 🔁 36`
+ `</p><p>📸 https://pbs.twimg.com/media/GG3zUMGbIAAxs3h.jpg</p>` + `</p><p>📸 https://pbs.twimg.com/media/GG3zUMGbIAAxs3h.jpg</p></blockquote>`,
+ `<sub><a href="https://github.com/dylanpdx/BetterTwitFix">vxTwitter / fixvx</a></sub></blockquote>`,
"m.mentions": {} "m.mentions": {}
}]) }])
}) })
@ -318,16 +291,16 @@ test("message2event embeds: vx video", async t => {
}, { }, {
$type: "m.room.message", $type: "m.room.message",
msgtype: "m.notice", msgtype: "m.notice",
body: "| ## McDonalds🤝@studiopierrot" body: "| via vxTwitter / fixvx https://github.com/dylanpdx/BetterTwitFix"
+ "\n| \n| ## McDonalds🤝@studiopierrot"
+ "\n| \n| 💖 89 🔁 21 https://twitter.com/McDonalds/status/1759971752254341417" + "\n| \n| 💖 89 🔁 21 https://twitter.com/McDonalds/status/1759971752254341417"
+ "\n| \n| ## McDonald's (@McDonalds) https://vxtwitter.com/McDonalds/status/1759971752254341417" + "\n| \n| ## McDonald's (@McDonalds) https://vxtwitter.com/McDonalds/status/1759971752254341417"
+ "\n| \n| 🎞️ https://video.twimg.com/ext_tw_video/1759967449548541952/pu/vid/avc1/1280x720/XN1LFIJqAFBdtaoh.mp4?tag=12" + "\n| \n| 🎞️ https://video.twimg.com/ext_tw_video/1759967449548541952/pu/vid/avc1/1280x720/XN1LFIJqAFBdtaoh.mp4?tag=12",
+ "\n| via vxTwitter / fixvx https://github.com/dylanpdx/BetterTwitFix",
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: `<blockquote><p><strong><a href="https://twitter.com/McDonalds/status/1759971752254341417">McDonalds🤝@studiopierrot\n\n💖 89 🔁 21</a></strong>` formatted_body: `<blockquote><p><sub><a href="https://github.com/dylanpdx/BetterTwitFix">vxTwitter / fixvx</a></sub>`
+ `</p><p><strong><a href="https://twitter.com/McDonalds/status/1759971752254341417">McDonalds🤝@studiopierrot\n\n💖 89 🔁 21</a></strong>`
+ `</p><p><strong><a href="https://vxtwitter.com/McDonalds/status/1759971752254341417">McDonald's (@McDonalds)</a></strong>` + `</p><p><strong><a href="https://vxtwitter.com/McDonalds/status/1759971752254341417">McDonald's (@McDonalds)</a></strong>`
+ `</p><p>🎞️ https://video.twimg.com/ext_tw_video/1759967449548541952/pu/vid/avc1/1280x720/XN1LFIJqAFBdtaoh.mp4?tag=12</p>` + `</p><p>🎞️ https://video.twimg.com/ext_tw_video/1759967449548541952/pu/vid/avc1/1280x720/XN1LFIJqAFBdtaoh.mp4?tag=12</p></blockquote>`,
+ `<sub><a href="https://github.com/dylanpdx/BetterTwitFix">vxTwitter / fixvx</a></sub></blockquote>`,
"m.mentions": {} "m.mentions": {}
}]) }])
}) })
@ -344,7 +317,8 @@ test("message2event embeds: youtube video", async t => {
}, { }, {
$type: "m.room.message", $type: "m.room.message",
msgtype: "m.notice", msgtype: "m.notice",
body: "| ## Happy O Funny https://www.youtube.com/channel/UCEpQ9aEb1NafpvWp5Aoizrg" body: "| via YouTube https://www.youtube.com"
+ "\n| \n| ## Happy O Funny https://www.youtube.com/channel/UCEpQ9aEb1NafpvWp5Aoizrg"
+ "\n| \n| ## Shoebill stork clattering sounds like machine guun~!! (Japan Matsue... https://www.youtube.com/watch?v=kDMHHw8JqLE" + "\n| \n| ## Shoebill stork clattering sounds like machine guun~!! (Japan Matsue... https://www.youtube.com/watch?v=kDMHHw8JqLE"
+ "\n| \n| twitter" + "\n| \n| twitter"
+ "\n| https://twitter.com/matsuevogelpark" + "\n| https://twitter.com/matsuevogelpark"
@ -353,7 +327,8 @@ test("message2event embeds: youtube video", async t => {
+ "\n| \n| #shoebill #livingdinosaur #happyofunny #weirdcreature #weirdsoun..." + "\n| \n| #shoebill #livingdinosaur #happyofunny #weirdcreature #weirdsoun..."
+ "\n| \n| 🎞️ https://www.youtube.com/embed/kDMHHw8JqLE", + "\n| \n| 🎞️ https://www.youtube.com/embed/kDMHHw8JqLE",
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: `<blockquote><p><strong><a href="https://www.youtube.com/channel/UCEpQ9aEb1NafpvWp5Aoizrg">Happy O Funny</a></strong>` formatted_body: `<blockquote><p><sub><a href="https://www.youtube.com">YouTube</a></sub></p>`
+ `<p><strong><a href="https://www.youtube.com/channel/UCEpQ9aEb1NafpvWp5Aoizrg">Happy O Funny</a></strong>`
+ `</p><p><strong><a href="https://www.youtube.com/watch?v=kDMHHw8JqLE">Shoebill stork clattering sounds like machine guun~!! (Japan Matsue...</a></strong>` + `</p><p><strong><a href="https://www.youtube.com/watch?v=kDMHHw8JqLE">Shoebill stork clattering sounds like machine guun~!! (Japan Matsue...</a></strong>`
+ `</p><p>twitter<br><a href="https://twitter.com/matsuevogelpark">https://twitter.com/matsuevogelpark</a><br><br>The shoebill (Balaeniceps rex) also known as whalehead, whale-headed stork, or shoe-billed stork, is a very large stork-like bird. It derives its name from its enormous shoe-shaped bill<br>some people also called them the living dinosaur~~<br><br>#shoebill #livingdinosaur #happyofunny #weirdcreature #weirdsoun...` + `</p><p>twitter<br><a href="https://twitter.com/matsuevogelpark">https://twitter.com/matsuevogelpark</a><br><br>The shoebill (Balaeniceps rex) also known as whalehead, whale-headed stork, or shoe-billed stork, is a very large stork-like bird. It derives its name from its enormous shoe-shaped bill<br>some people also called them the living dinosaur~~<br><br>#shoebill #livingdinosaur #happyofunny #weirdcreature #weirdsoun...`
+ `</p><p>🎞️ https://www.youtube.com/embed/kDMHHw8JqLE` + `</p><p>🎞️ https://www.youtube.com/embed/kDMHHw8JqLE`

View file

@ -4,7 +4,6 @@ const {MatrixServerError} = require("../../matrix/mreq")
const data = require("../../../test/data") const data = require("../../../test/data")
const {mockGetEffectivePower} = require("../../matrix/utils.test") const {mockGetEffectivePower} = require("../../matrix/utils.test")
const Ty = require("../../types") const Ty = require("../../types")
const {db} = require("../../passthrough")
/** /**
* @param {string} roomID * @param {string} roomID
@ -734,31 +733,6 @@ test("message2event: reply to a Discord message that wasn't bridged", async t =>
}]) }])
}) })
test("message2event: reply to a Discord member join (who didn't join on Matrix)", async t => {
const events = await messageToEvent(data.message.reply_to_member_join, data.guild.general)
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.text",
body: "> PEASANT!! joined the room\n\nwhen the broke friend who we pay to bring food shows up at the medieval lord party",
format: "org.matrix.custom.html",
formatted_body: "<blockquote><strong>PEASANT!!</strong> joined the room</blockquote>when the broke friend who we pay to bring food shows up at the medieval lord party",
"m.mentions": {}
}])
})
test("message2event: reply to a Discord member join (who did join on Matrix)", async t => {
db.prepare("INSERT INTO sim (user_id, username, sim_name, mxid) VALUES ('1461677775554478161', 'peasant321_76775', 'peasant321_76775', '@_ooye_peasant321_76775:cadence.moe')").run()
const events = await messageToEvent(data.message.reply_to_member_join, data.guild.general)
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.text",
body: "> PEASANT!! joined the room\n\nwhen the broke friend who we pay to bring food shows up at the medieval lord party",
format: "org.matrix.custom.html",
formatted_body: `<blockquote><a href="https://matrix.to/#/@_ooye_peasant321_76775:cadence.moe">PEASANT!!</a> joined the room</blockquote>when the broke friend who we pay to bring food shows up at the medieval lord party`,
"m.mentions": {}
}])
})
test("message2event: simple written @mention for matrix user", async t => { test("message2event: simple written @mention for matrix user", async t => {
const events = await messageToEvent(data.message.simple_written_at_mention_for_matrix, data.guild.general, {}, { const events = await messageToEvent(data.message.simple_written_at_mention_for_matrix, data.guild.general, {}, {
api: { api: {
@ -1103,45 +1077,6 @@ test("message2event: very large attachment is linked instead of being uploaded",
}]) }])
}) })
test("message2event: very large png is converted to jpeg for upload", async t => {
const events = await messageToEvent({
content: "",
attachments: [{
filename: "855064e9-bdae-487a-a497-c662bd510487.png",
url: "https://cdn.discordapp.com/attachments/1160894080998461480/1548656385292509274/855064e9-bdae-487a-a497-c662bd510487.png",
proxy_url: "https://media.discordapp.net/attachments/1160894080998461480/1548656385292509274/855064e9-bdae-487a-a497-c662bd510487.png",
content_type: "image/png",
width: 5472,
height: 3648,
size: 14967919
}]
}, data.guild.general, {}, {
async fetch(url, init) {
t.equal(url, "https://media.discordapp.net/attachments/1160894080998461480/1548656385292509274/855064e9-bdae-487a-a497-c662bd510487.png?format=jpeg")
t.equal(init.method, "HEAD")
return new Response("wa", {headers: {
"Content-Type": "image/jpeg",
"Content-Length": 483085
}})
}
})
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.image",
body: "855064e9-bdae-487a-a497-c662bd510487.jpg",
info: {
w: 5472,
h: 3648,
mimetype: "image/jpeg",
size: 483085
},
external_url: "https://bridge.example.org/download/discordcdn/1160894080998461480/1548656385292509274/855064e9-bdae-487a-a497-c662bd510487.png",
filename: "855064e9-bdae-487a-a497-c662bd510487.jpg",
url: "mxc://cadence.moe/zXNXRvJwRDMIzZZVqHUXtGeD"
}])
})
test("message2event: multiple attachments are combined into the same event where possible", async t => { test("message2event: multiple attachments are combined into the same event where possible", async t => {
const events = await messageToEvent({ const events = await messageToEvent({
content: "hey", content: "hey",
@ -1312,9 +1247,9 @@ test("message2event: crossposted announcements say where they are crossposted fr
$type: "m.room.message", $type: "m.room.message",
"m.mentions": {}, "m.mentions": {},
msgtype: "m.text", msgtype: "m.text",
body: "[ Chewey Bot Official Server #announcements]\nAll text based commands are now inactive on Chewey Bot\nTo continue using commands you'll need to use them as slash commands", body: "[🔀 Chewey Bot Official Server #announcements]\nAll text based commands are now inactive on Chewey Bot\nTo continue using commands you'll need to use them as slash commands",
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: " <strong>Chewey Bot Official Server #announcements</strong><br>All text based commands are now inactive on Chewey Bot<br>To continue using commands you'll need to use them as slash commands" formatted_body: "🔀 <strong>Chewey Bot Official Server #announcements</strong><br>All text based commands are now inactive on Chewey Bot<br>To continue using commands you'll need to use them as slash commands"
}]) }])
}) })
@ -1383,9 +1318,9 @@ test("message2event: forwarded image", async t => {
t.deepEqual(events, [ t.deepEqual(events, [
{ {
$type: "m.room.message", $type: "m.room.message",
body: "[ Forwarded message]", body: "[🔀 Forwarded message]",
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: " <em>Forwarded message</em>", formatted_body: "🔀 <em>Forwarded message</em>",
"m.mentions": {}, "m.mentions": {},
msgtype: "m.notice", msgtype: "m.notice",
}, },
@ -1424,10 +1359,10 @@ test("message2event: constructed forwarded message", async t => {
t.deepEqual(events, [ t.deepEqual(events, [
{ {
$type: "m.room.message", $type: "m.room.message",
body: "[ Forwarded from #wonderland]" body: "[🔀 Forwarded from #wonderland]"
+ "\n» What's cooking, good looking? :hipposcope:", + "\n» What's cooking, good looking? :hipposcope:",
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: ` <em>Forwarded from wonderland <a href="https://matrix.to/#/!qzDBLKlildpzrrOnFZ:cadence.moe/$tBIT8mO7XTTCgIINyiAIy6M2MSoPAdJenRl_RLyYuaE?via=cadence.moe&amp;via=matrix.org">[jump to event]</a></em>` formatted_body: `🔀 <em>Forwarded from wonderland <a href="https://matrix.to/#/!qzDBLKlildpzrrOnFZ:cadence.moe/$tBIT8mO7XTTCgIINyiAIy6M2MSoPAdJenRl_RLyYuaE?via=cadence.moe&amp;via=matrix.org">[jump to event]</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>`, + `<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": {}, "m.mentions": {},
msgtype: "m.text", msgtype: "m.text",
@ -1483,10 +1418,10 @@ test("message2event: constructed forwarded text", async t => {
t.deepEqual(events, [ t.deepEqual(events, [
{ {
$type: "m.room.message", $type: "m.room.message",
body: "[ Forwarded from #amanda-spam]" body: "[🔀 Forwarded from #amanda-spam]"
+ "\n» What's cooking, good looking?", + "\n» What's cooking, good looking?",
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: ` <em>Forwarded from amanda-spam <a href="https://matrix.to/#/!CzvdIdUQXgUjDVKxeU:cadence.moe?via=cadence.moe&amp;via=matrix.org">[jump to room]</a></em>` formatted_body: `🔀 <em>Forwarded from amanda-spam <a href="https://matrix.to/#/!CzvdIdUQXgUjDVKxeU:cadence.moe?via=cadence.moe&amp;via=matrix.org">[jump to room]</a></em>`
+ `<br><blockquote>What's cooking, good looking?</blockquote>`, + `<br><blockquote>What's cooking, good looking?</blockquote>`,
"m.mentions": {}, "m.mentions": {},
msgtype: "m.text", msgtype: "m.text",
@ -1506,10 +1441,10 @@ test("message2event: don't scan forwarded messages for mentions", async t => {
t.deepEqual(events, [ t.deepEqual(events, [
{ {
$type: "m.room.message", $type: "m.room.message",
body: "[ Forwarded message]" body: "[🔀 Forwarded message]"
+ "\n» If some folks have spare bandwidth then helping out ArchiveTeam with archiving soon to be deleted research and government data might be worthwhile https://social.luca.run/@luca/113950834185678114", + "\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", format: "org.matrix.custom.html",
formatted_body: ` <em>Forwarded message</em>` 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>`, + `<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": {}, "m.mentions": {},
msgtype: "m.text" msgtype: "m.text"
@ -1651,7 +1586,7 @@ test("message2event: expired/invalid invites are sent as-is", async t => {
snow: { snow: {
invite: { invite: {
async getInvite() { async getInvite() {
throw new Error(`Unknown Invite`) throw new Error(`{"message": "Unknown Invite", "code": 10006}`)
} }
} }
} }
@ -1859,9 +1794,9 @@ test("message2event: forwarded message with unreferenced mention", async t => {
t.deepEqual(events, [{ t.deepEqual(events, [{
$type: "m.room.message", $type: "m.room.message",
msgtype: "m.text", msgtype: "m.text",
body: "[ Forwarded message]\n» @unknown-user:\n» 🎞️ Uploaded file: https://bridge.example.org/download/discordcdn/893634327722721290/1463174815119704114/2022-10-18_16-49-46.mp4 (51 MB)", body: "[🔀 Forwarded message]\n» @unknown-user:\n» 🎞️ Uploaded file: https://bridge.example.org/download/discordcdn/893634327722721290/1463174815119704114/2022-10-18_16-49-46.mp4 (51 MB)",
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: " <em>Forwarded message</em><br><blockquote>@unknown-user:<br>🎞️ Uploaded file: <a href=\"https://bridge.example.org/download/discordcdn/893634327722721290/1463174815119704114/2022-10-18_16-49-46.mp4\">2022-10-18_16-49-46.mp4</a> (51 MB)</blockquote>", formatted_body: "🔀 <em>Forwarded message</em><br><blockquote>@unknown-user:<br>🎞️ Uploaded file: <a href=\"https://bridge.example.org/download/discordcdn/893634327722721290/1463174815119704114/2022-10-18_16-49-46.mp4\">2022-10-18_16-49-46.mp4</a> (51 MB)</blockquote>",
"m.mentions": {} "m.mentions": {}
}]) }])
}) })

View file

@ -34,7 +34,7 @@ function removeReaction(data, reactions, key) {
// Even though the bridge bot only reacted once on Discord-side, multiple Matrix users may have // Even though the bridge bot only reacted once on Discord-side, multiple Matrix users may have
// reacted on Matrix-side. Semantically, we want to remove the reaction from EVERY Matrix user. // reacted on Matrix-side. Semantically, we want to remove the reaction from EVERY Matrix user.
// Also need to clean up the database. // Also need to clean up the database.
const hash = utils.getEventIDHash(eventID) const hash = utils.getEventIDHash(event.event_id)
removals.push({eventID, mxid: null, hash}) removals.push({eventID, mxid: null, hash})
} }
if (!lookingAtMatrixReaction && !wantToRemoveMatrixReaction) { if (!lookingAtMatrixReaction && !wantToRemoveMatrixReaction) {

View file

@ -19,24 +19,30 @@ const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex))
*/ */
async function threadToAnnouncement(parentRoomID, threadRoomID, creatorMxid, thread, di) { async function threadToAnnouncement(parentRoomID, threadRoomID, creatorMxid, thread, di) {
const branchedFromEventID = select("event_message", "event_id", {message_id: thread.id}).pluck().get() const branchedFromEventID = select("event_message", "event_id", {message_id: thread.id}).pluck().get()
/** @type {{"m.mentions"?: any, "m.in_reply_to"?: any}} */ const ellieMode = false //See: https://matrix.to/#/!PuFmbgRjaJsAZTdSja:cadence.moe?via=cadence.moe&via=chat.untitledzero.dev&via=guziohub.ovh - TL;DR: Ellie's idea was to not leave any sign of Matrix threads existing, while Guzio preferred that the link to created thread-rooms stay within the Matrix thread (to better approximate Discord UI on compatible clients). This settings-option-but-not-really (it's not meant to be changed by end users unless they know what they're doing) would let Cadence switch between both approaches over some time period, to test the feeling of both, and finally land on whichever UX she prefers best. TODO: Remove this toggle (and make the chosen solution permanent) once those tests conclude.
/** @type {{"m.mentions"?: any, "m.relates_to"?: {event_id?: string, is_falling_back?: boolean, "m.in_reply_to"?: {event_id: string}, rel_type?: "m.replace"|"m.thread"}}} */
const context = {} const context = {}
let suffix = ""
if (branchedFromEventID) { if (branchedFromEventID) {
// Need to figure out who sent that event... // Need to figure out who sent that event...
const event = await di.api.getEvent(parentRoomID, branchedFromEventID) const event = await di.api.getEvent(parentRoomID, branchedFromEventID)
context["m.relates_to"] = {"m.in_reply_to": {event_id: event.event_id}}
if (event.sender && !userRegex.some(rx => event.sender.match(rx))) context["m.mentions"] = {user_ids: [event.sender]} if (event.sender && !userRegex.some(rx => event.sender.match(rx))) context["m.mentions"] = {user_ids: [event.sender]}
if (!ellieMode) {
//...And actually branch from that event (if configured to do so)
suffix = "\n[Note: You really should continue the conversation in that room, rather than in this Matrix thread. Any messages sent here will be DELETED and instead moved there by the bot, which makes it the author and strips you from control (edits/deletions) over your own message!]"
context["m.relates_to"] = {"m.in_reply_to": {event_id: event.event_id}, is_falling_back:false, event_id: event.event_id, rel_type: "m.thread"}
}
} }
const msgtype = creatorMxid ? "m.emote" : "m.text" const msgtype = creatorMxid ? "m.emote" : "m.text"
const template = creatorMxid ? "started a thread:" : "Thread started:" const template = creatorMxid ? "started a thread called" : "New thread started:"
const via = await mxUtils.getViaServersQuery(threadRoomID, di.api) const via = await mxUtils.getViaServersQuery(threadRoomID, di.api)
let body = `${template} ${thread.name} https://matrix.to/#/${threadRoomID}?${via.toString()}` let body = `${template} \"${thread.name}\" in room: https://matrix.to/#/${threadRoomID}?${via.toString()}${suffix}`
return { return {
msgtype, msgtype,
body, body,
"m.mentions": {},
...context ...context
} }
} }

View file

@ -49,8 +49,7 @@ test("thread2announcement: no known creator, no branched from event", async t =>
}, {api: viaApi}) }, {api: viaApi})
t.deepEqual(content, { t.deepEqual(content, {
msgtype: "m.text", msgtype: "m.text",
body: "Thread started: test thread https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org", body: "New thread started: \"test thread\" in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org",
"m.mentions": {}
}) })
}) })
@ -61,8 +60,7 @@ test("thread2announcement: known creator, no branched from event", async t => {
}, {api: viaApi}) }, {api: viaApi})
t.deepEqual(content, { t.deepEqual(content, {
msgtype: "m.emote", msgtype: "m.emote",
body: "started a thread: test thread https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org", body: "started a thread called \"test thread\" in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org",
"m.mentions": {}
}) })
}) })
@ -85,12 +83,14 @@ test("thread2announcement: no known creator, branched from discord event", async
}) })
t.deepEqual(content, { t.deepEqual(content, {
msgtype: "m.text", msgtype: "m.text",
body: "Thread started: test thread https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org", body: "New thread started: \"test thread\" in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org\n[Note: You really should continue the conversation in that room, rather than in this Matrix thread. Any messages sent here will be DELETED and instead moved there by the bot, which makes it the author and strips you from control (edits/deletions) over your own message!]",
"m.mentions": {},
"m.relates_to": { "m.relates_to": {
"event_id": "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg",
"is_falling_back": false,
"m.in_reply_to": { "m.in_reply_to": {
event_id: "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg" "event_id": "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg",
} },
"rel_type": "m.thread",
} }
}) })
}) })
@ -114,12 +114,14 @@ test("thread2announcement: known creator, branched from discord event", async t
}) })
t.deepEqual(content, { t.deepEqual(content, {
msgtype: "m.emote", msgtype: "m.emote",
body: "started a thread: test thread https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org", body: "started a thread called \"test thread\" in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org\n[Note: You really should continue the conversation in that room, rather than in this Matrix thread. Any messages sent here will be DELETED and instead moved there by the bot, which makes it the author and strips you from control (edits/deletions) over your own message!]",
"m.mentions": {},
"m.relates_to": { "m.relates_to": {
"event_id": "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg",
"is_falling_back": false,
"m.in_reply_to": { "m.in_reply_to": {
event_id: "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg" "event_id": "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg",
} },
"rel_type": "m.thread",
} }
}) })
}) })
@ -143,14 +145,51 @@ test("thread2announcement: no known creator, branched from matrix event", async
}) })
t.deepEqual(content, { t.deepEqual(content, {
msgtype: "m.text", msgtype: "m.text",
body: "Thread started: test thread https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org", body: "New thread started: \"test thread\" in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org\n[Note: You really should continue the conversation in that room, rather than in this Matrix thread. Any messages sent here will be DELETED and instead moved there by the bot, which makes it the author and strips you from control (edits/deletions) over your own message!]",
"m.mentions": { "m.mentions": {
user_ids: ["@cadence:cadence.moe"] user_ids: ["@cadence:cadence.moe"]
}, },
"m.relates_to": { "m.relates_to": {
"event_id": "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4",
"is_falling_back": false,
"m.in_reply_to": { "m.in_reply_to": {
event_id: "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4" "event_id": "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4",
} },
"rel_type": "m.thread",
}
})
})
test("thread2announcement: known creator, branched from matrix event", async t => {
const content = await threadToAnnouncement("!kLRqKKUQXcibIMtOpl:cadence.moe", "!thread", "@_ooye_crunch_god:cadence.moe", {
name: "test thread",
id: "1128118177155526666"
}, {
api: {
getEvent: mockGetEvent(t, "!kLRqKKUQXcibIMtOpl:cadence.moe", "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4", {
type: "m.room.message",
content: {
msgtype: "m.text",
body: "so can you reply to my webhook uwu"
},
sender: "@cadence:cadence.moe"
}),
...viaApi
}
})
t.deepEqual(content, {
msgtype: "m.emote",
body: "started a thread called \"test thread\" in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org\n[Note: You really should continue the conversation in that room, rather than in this Matrix thread. Any messages sent here will be DELETED and instead moved there by the bot, which makes it the author and strips you from control (edits/deletions) over your own message!]",
"m.mentions": {
user_ids: ["@cadence:cadence.moe"]
},
"m.relates_to": {
"event_id": "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4",
"is_falling_back": false,
"m.in_reply_to": {
"event_id": "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4",
},
"rel_type": "m.thread",
} }
}) })
}) })

View file

@ -14,8 +14,6 @@ const {sync} = passthrough
/** @type {import("./discord-packets")} */ /** @type {import("./discord-packets")} */
const discordPackets = sync.require("./discord-packets") const discordPackets = sync.require("./discord-packets")
const CONNECTION_DEBUG = false
class DiscordClient { class DiscordClient {
/** /**
* @param {string} discordToken * @param {string} discordToken
@ -26,7 +24,7 @@ class DiscordClient {
const intents = [ const 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", "GUILD_MESSAGE_POLLS", "GUILDS", "GUILD_EMOJIS_AND_STICKERS", "GUILD_MESSAGES", "GUILD_MESSAGE_REACTIONS", "GUILD_MESSAGE_TYPING", "GUILD_WEBHOOKS", "GUILD_MESSAGE_POLLS",
"MESSAGE_CONTENT", "GUILD_MEMBERS" "MESSAGE_CONTENT"
] ]
if (reg.ooye.receive_presences !== false) intents.push("GUILD_PRESENCES") if (reg.ooye.receive_presences !== false) intents.push("GUILD_PRESENCES")
this.discordToken = discordToken this.discordToken = discordToken
@ -55,21 +53,21 @@ class DiscordClient {
this.guildChannelMap = new Map() this.guildChannelMap = new Map()
if (listen !== "no") { if (listen !== "no") {
this.cloud.on("event", message => { this.cloud.on("event", message => {
discordPackets.onPacket(this, message, listen) process.nextTick(() => {
discordPackets.onPacket(this, message, listen)
})
}) })
} }
if (CONNECTION_DEBUG) { const addEventLogger = (eventName, logName) => {
const addEventLogger = (eventName, logName) => { this.cloud.on(eventName, (...args) => {
this.cloud.on(eventName, (...args) => { const d = new Date().toISOString().slice(0, 19)
const d = new Date().toISOString().slice(0, 19) console.error(`[${d} Client ${logName}]`, ...args)
console.error(`[${d} Client ${logName}]`, ...args) })
})
}
addEventLogger("error", "Error")
addEventLogger("disconnected", "Disconnected")
addEventLogger("ready", "Ready")
} }
addEventLogger("error", "Error")
addEventLogger("disconnected", "Disconnected")
addEventLogger("ready", "Ready")
} }
} }

View file

@ -1,256 +1,173 @@
// @ts-check // @ts-check
const assert = require("assert").strict // Discord library internals type beat
const {scheduler} = require("timers/promises")
const DiscordTypes = require("discord-api-types/v10")
const passthrough = require("../passthrough") const passthrough = require("../passthrough")
const {sync} = passthrough const {sync, db} = passthrough
/** @type {import("../matrix/homeserver-status")} */
const homeserverStatus = sync.require("../matrix/homeserver-status")
const guildReadyStatus = new class {
/** @type {Set<string> | null} */
unavailableGuilds = null
_allReady = Promise.withResolvers()
const utils = {
/** /**
* @param {string} guildID * @param {import("./discord-client")} client
* @returns {boolean} true if it was the last one * @param {import("cloudstorm").IGatewayMessage} message
* @param {string} listen "full", "half", "no" - whether to set up the event listeners for OOYE to operate
*/ */
makeReady(guildID) { async onPacket(client, message, listen) {
assert(this.unavailableGuilds) // requiring this later so that the client is already constructed by the time event-dispatcher is loaded
if (this.unavailableGuilds.delete(guildID) && this.unavailableGuilds.size === 0) { /** @type {typeof import("./event-dispatcher")} */
this._allReady.resolve(null) const eventDispatcher = sync.require("./event-dispatcher")
return true /** @type {import("../discord/register-interactions")} */
} const interactions = sync.require("../discord/register-interactions")
return false
}
allReady() { // Client internals, keep track of the state we need
return this.unavailableGuilds && this.unavailableGuilds.size === 0 if (message.t === "READY") {
} if (client.ready) return
client.ready = true
client.user = message.d.user
client.application = message.d.application
console.log(`Discord logged in as ${client.user.username}#${client.user.discriminator} (${client.user.id})`)
interactions.registerInteractions()
waitForAllReady() { } else if (message.t === "GUILD_CREATE") {
return this._allReady.promise message.d.members = message.d.members.filter(m => m.user.id === client.user.id) // only keep the bot's own member - it's needed to determine private channels on web
} client.guilds.set(message.d.id, message.d)
} const arr = []
client.guildChannelMap.set(message.d.id, arr)
for (const channel of message.d.channels || []) {
// @ts-ignore
channel.guild_id = message.d.id
arr.push(channel.id)
client.channels.set(channel.id, channel)
}
for (const thread of message.d.threads || []) {
// @ts-ignore
thread.guild_id = message.d.id
arr.push(thread.id)
client.channels.set(thread.id, thread)
}
/** if (listen === "full") {
* @param {import("./discord-client")} client try {
* @param {import("cloudstorm").IGatewayMessage} message await eventDispatcher.checkMissedExpressions(message.d)
* @param {string} listen "full", "half", "no" - whether to set up the event listeners for OOYE to operate await eventDispatcher.checkMissedMessages(client, message.d)
*/ await eventDispatcher.checkMissedPins(client, message.d)
async function onPacket(client, message, listen) { await eventDispatcher.checkMissedLeaves(client, message.d)
// requiring this later so that the client is already constructed by the time event-dispatcher is loaded } catch (e) {
/** @type {typeof import("./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
if (message.t === "READY") {
if (client.ready) return
client.ready = true
client.user = message.d.user
client.application = message.d.application
guildReadyStatus.unavailableGuilds = new Set(message.d.guilds.filter(g => g.unavailable).map(g => g.id))
console.log(`Discord logged in as ${client.user.username}#${client.user.discriminator} (${client.user.id})`)
process.stdout.write("Waiting for guilds to warm up... ")
interactions.registerInteractions()
} else if (message.t === "GUILD_CREATE") {
message.d.members = message.d.members.filter(m => m.user.id === client.user.id) // only keep the bot account's member - it's needed for roles to determine private channels on web
client.guilds.set(message.d.id, message.d)
/*
Info about guilds is populated one guild at a time.
For m->d bridging to work, the guild needs to be populated, so we need to have GUILD_CREATE for the guild.
If we ping the homeserver, it will send us any pending events, so we need to wait for all GUILD_CREATES before we ping.
We must attempt a ping because we don't want to try sending missed d->m messages to an offline homeserver.
The "all guilds ready" delay can be removed if ONE of the following is done:
1. m->d can queue incoming events until their guild exists in memory
2. d->m missed messages can have their errors handled and added to queue, rather than pinging first
*/
const firstReady = guildReadyStatus.allReady()
const lastGuildReady = guildReadyStatus.makeReady(message.d.id)
const arr = []
client.guildChannelMap.set(message.d.id, arr)
for (const channel of message.d.channels || []) {
// @ts-ignore
channel.guild_id = message.d.id
arr.push(channel.id)
client.channels.set(channel.id, channel)
}
for (const thread of message.d.threads || []) {
// @ts-ignore
thread.guild_id = message.d.id
arr.push(thread.id)
client.channels.set(thread.id, thread)
}
if (listen === "full") {
try {
// Wait for guilds to be connected and homeserver to be online. If this is the last guild, a different code path is used to trigger the homeserver check.
if (lastGuildReady) {
process.stdout.write(`ok, ${client.guilds.size} available.\nConnecting to homeserver... `)
// await guildReadyStatus.waitForAllReady() - no need, we already checked this is the last guild
await homeserverStatus.homeserverStatus.waitForOnline(true)
console.log("ok.\nReplaying past events. Welcome to Out Of Your Element.")
} else {
await guildReadyStatus.waitForAllReady()
await homeserverStatus.homeserverStatus.waitForOnline(false)
}
await eventDispatcher.checkMissedExpressions(message.d)
await eventDispatcher.checkMissedMessages(client, message.d)
await eventDispatcher.checkMissedPins(client, message.d)
await eventDispatcher.checkMissedLeaves(client, message.d)
} catch (e) {
if (firstReady) {
console.error("Failed to sync missed events. To retry, please fix this error and restart OOYE:") console.error("Failed to sync missed events. To retry, please fix this error and restart OOYE:")
} console.error(e)
console.error(e)
}
}
} else if (message.t === "GUILD_UPDATE") {
const guild = client.guilds.get(message.d.id)
if (guild) {
for (const prop of Object.keys(message.d)) {
if (!["channels", "threads"].includes(prop)) {
guild[prop] = message.d[prop]
} }
} }
}
} else if (message.t === "GUILD_EMOJIS_UPDATE") { } else if (message.t === "GUILD_UPDATE") {
const guild = client.guilds.get(message.d.guild_id) const guild = client.guilds.get(message.d.id)
if (guild) { if (guild) {
guild.emojis = message.d.emojis for (const prop of Object.keys(message.d)) {
} if (!["channels", "threads"].includes(prop)) {
guild[prop] = message.d[prop]
} else if (message.t === "GUILD_STICKERS_UPDATE") { }
const guild = client.guilds.get(message.d.guild_id) }
if (guild) {
guild.stickers = message.d.stickers
}
} else if (message.t === "GUILD_ROLE_CREATE" || message.t === "GUILD_ROLE_UPDATE" || message.t === "GUILD_ROLE_DELETE") {
const guild = client.guilds.get(message.d.guild_id)
/** Delete this in case of UPDATE or DELETE */
const targetID = "role_id" in message.d ? message.d.role_id : message.d.role.id
/** Add this in case of CREATE or UPDATE */
const newRoles = []
if ("role" in message.d) newRoles.push(message.d.role)
if (guild) {
const targetIndex = guild.roles.findIndex(r => r.id === targetID)
if (targetIndex !== -1) {
// Role already exists. Delete it and maybe replace it.
guild.roles.splice(targetIndex, 1, ...newRoles)
} else {
// Role doesn't already exist.
guild.roles.push(...newRoles)
} }
}
} else if (message.t === "GUILD_MEMBER_UPDATE") { } else if (message.t === "GUILD_EMOJIS_UPDATE") {
const guild = client.guilds.get(message.d.guild_id) const guild = client.guilds.get(message.d.guild_id)
const member = guild?.members.find(m => m.user.id === message.d.user.id) if (guild) {
if (member) { // only update existing members (i.e. the bot's own member) - don't want to inflate the cache with new irrelevant ones guild.emojis = message.d.emojis
Object.assign(member, message.d) }
}
} else if (message.t === "THREAD_CREATE") { } else if (message.t === "GUILD_STICKERS_UPDATE") {
client.channels.set(message.d.id, message.d) const guild = client.guilds.get(message.d.guild_id)
if (message.d["guild_id"]) { if (guild) {
const channels = client.guildChannelMap.get(message.d["guild_id"]) guild.stickers = message.d.stickers
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 === "GUILD_ROLE_CREATE" || message.t === "GUILD_ROLE_UPDATE" || message.t === "GUILD_ROLE_DELETE") {
client.channels.set(message.d.id, message.d) const guild = client.guilds.get(message.d.guild_id)
/** Delete this in case of UPDATE or DELETE */
const targetID = "role_id" in message.d ? message.d.role_id : message.d.role.id
/** Add this in case of CREATE or UPDATE */
const newRoles = []
if ("role" in message.d) newRoles.push(message.d.role)
if (guild) {
const targetIndex = guild.roles.findIndex(r => r.id === targetID)
if (targetIndex !== -1) {
// Role already exists. Delete it and maybe replace it.
guild.roles.splice(targetIndex, 1, ...newRoles)
} else {
// Role doesn't already exist.
guild.roles.push(...newRoles)
}
}
} else if (message.t === "GUILD_MEMBER_UPDATE") {
const guild = client.guilds.get(message.d.guild_id)
const member = guild?.members.find(m => m.user.id === message.d.user.id)
if (member) { // only update existing members (i.e. the bot's own member) - don't want to inflate the cache with new irrelevant ones
Object.assign(member, message.d)
}
} else if (message.t === "THREAD_CREATE") {
client.channels.set(message.d.id, message.d)
if (message.d["guild_id"]) {
const channels = client.guildChannelMap.get(message.d["guild_id"])
if (channels && !channels.includes(message.d.id)) channels.push(message.d.id)
}
} else if (message.t === "CHANNEL_UPDATE" || message.t === "THREAD_UPDATE") {
client.channels.set(message.d.id, message.d)
} else if (message.t === "CHANNEL_PINS_UPDATE") { } else if (message.t === "CHANNEL_PINS_UPDATE") {
const channel = client.channels.get(message.d.channel_id) const channel = client.channels.get(message.d.channel_id)
if (channel) { if (channel) {
channel["last_pin_timestamp"] = message.d.last_pin_timestamp channel["last_pin_timestamp"] = message.d.last_pin_timestamp
} }
} else if (message.t === "GUILD_DELETE") { } else if (message.t === "GUILD_DELETE") {
client.guilds.delete(message.d.id) client.guilds.delete(message.d.id)
const channels = client.guildChannelMap.get(message.d.id) const channels = client.guildChannelMap.get(message.d.id)
if (channels) {
for (const id of channels) client.channels.delete(id)
}
client.guildChannelMap.delete(message.d.id)
} else if (message.t === "CHANNEL_CREATE") {
client.channels.set(message.d.id, message.d)
if (message.d["guild_id"]) { // obj[prop] notation can be used to access a property without typescript complaining that it doesn't exist on all values something can have
const channels = client.guildChannelMap.get(message.d["guild_id"])
if (channels && !channels.includes(message.d.id)) channels.push(message.d.id)
}
} else if (message.t === "CHANNEL_DELETE") {
client.channels.delete(message.d.id)
if (message.d["guild_id"]) {
const channels = client.guildChannelMap.get(message.d["guild_id"])
if (channels) { if (channels) {
const previous = channels.indexOf(message.d.id) for (const id of channels) client.channels.delete(id)
if (previous !== -1) channels.splice(previous, 1) }
client.guildChannelMap.delete(message.d.id)
} else if (message.t === "CHANNEL_CREATE") {
client.channels.set(message.d.id, message.d)
if (message.d["guild_id"]) { // obj[prop] notation can be used to access a property without typescript complaining that it doesn't exist on all values something can have
const channels = client.guildChannelMap.get(message.d["guild_id"])
if (channels && !channels.includes(message.d.id)) channels.push(message.d.id)
}
} else if (message.t === "CHANNEL_DELETE") {
client.channels.delete(message.d.id)
if (message.d["guild_id"]) {
const channels = client.guildChannelMap.get(message.d["guild_id"])
if (channels) {
const previous = channels.indexOf(message.d.id)
if (previous !== -1) channels.splice(previous, 1)
}
}
}
// Event dispatcher for OOYE bridge operations
if (listen === "full" && message.t) {
try {
if (message.t === "MESSAGE_REACTION_REMOVE" || message.t === "MESSAGE_REACTION_REMOVE_EMOJI" || message.t === "MESSAGE_REACTION_REMOVE_ALL") {
await eventDispatcher.onSomeReactionsRemoved(client, message.d)
} else if (message.t === "INTERACTION_CREATE") {
await interactions.dispatchInteraction(message.d)
} else if (message.t in eventDispatcher) {
await eventDispatcher[message.t](client, message.d)
}
} catch (e) {
// Let OOYE try to handle errors too
await eventDispatcher.onError(client, e, message)
} }
} }
} }
// Event dispatcher for OOYE bridge operations
if (listen === "full" && message.t) {
const alwaysRealTimeEvents = ["PRESENCE_UPDATE"]
const optionalEvents = ["TYPING_START"]
if (alwaysRealTimeEvents.includes(message.t) || homeserverStatus.homeserverStatus.isRealTime()) {
// real time
dispatchPacketToBridge(client, message)
} else if (optionalEvents.includes(message.t)) {
// when not real time, skip optional events
} else {
// queue required events
homeserverStatus.homeserverStatus.queuePacket(message)
}
}
} }
/** module.exports = utils
* @param {import("./discord-client")} client
* @param {import("cloudstorm").IGatewayMessage} message
*/
async function dispatchPacketToBridge(client, message) {
// requiring this later so that the client is already constructed by the time event-dispatcher is loaded
/** @type {typeof import("./event-dispatcher")} */
const eventDispatcher = sync.require("./event-dispatcher")
/** @type {import("../discord/register-interactions")} */
const interactions = sync.require("../discord/register-interactions")
assert(message.t) // checked above
try {
if (message.t === "MESSAGE_REACTION_REMOVE" || message.t === "MESSAGE_REACTION_REMOVE_EMOJI" || message.t === "MESSAGE_REACTION_REMOVE_ALL") {
await eventDispatcher.onSomeReactionsRemoved(client, message.d)
} else if (message.t === "INTERACTION_CREATE") {
await interactions.dispatchInteraction(message.d)
} else if (message.t in eventDispatcher) {
await eventDispatcher[message.t](client, message.d)
}
} catch (e) {
// Let OOYE try to handle errors too
await eventDispatcher.onError(client, e, message)
}
}
module.exports.onPacket = onPacket
module.exports.dispatchPacketToBridge = dispatchPacketToBridge
module.exports.guildReadyStatus = guildReadyStatus

View file

@ -2,7 +2,6 @@
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 {id: botID} = require("../../addbot")
const {sync, db, select, from} = require("../passthrough") const {sync, db, select, from} = require("../passthrough")
/** @type {import("./actions/send-message")}) */ /** @type {import("./actions/send-message")}) */
@ -39,10 +38,10 @@ const removeMember = sync.require("./actions/remove-member")
const vote = sync.require("./actions/poll-vote") const vote = sync.require("./actions/poll-vote")
/** @type {import("../m2d/event-dispatcher")} */ /** @type {import("../m2d/event-dispatcher")} */
const matrixEventDispatcher = sync.require("../m2d/event-dispatcher") const matrixEventDispatcher = sync.require("../m2d/event-dispatcher")
/** @type {import("../m2d/actions/redact.js")} */
const redact = sync.require("../m2d/actions/redact.js")
/** @type {import("../discord/interactions/matrix-info")} */ /** @type {import("../discord/interactions/matrix-info")} */
const matrixInfoInteraction = sync.require("../discord/interactions/matrix-info") const matrixInfoInteraction = sync.require("../discord/interactions/matrix-info")
/** @type {import("../agi/listener")} */
const agiListener = sync.require("../agi/listener")
const {Semaphore} = require("@chriscdn/promise-semaphore") const {Semaphore} = require("@chriscdn/promise-semaphore")
const checkMissedPinsSema = new Semaphore() const checkMissedPinsSema = new Semaphore()
@ -60,7 +59,7 @@ module.exports = {
matrixEventDispatcher.printError(gatewayMessage.t, "Discord", e, gatewayMessage) matrixEventDispatcher.printError(gatewayMessage.t, "Discord", e, gatewayMessage)
const channelID = gatewayMessage.d?.["channel_id"] const channelID = gatewayMessage.d["channel_id"]
if (!channelID) return if (!channelID) return
const roomID = select("channel_room", "room_id", {channel_id: channelID}).pluck().get() const roomID = select("channel_room", "room_id", {channel_id: channelID}).pluck().get()
if (!roomID) return if (!roomID) return
@ -105,9 +104,9 @@ module.exports = {
// console.log(`[check missed messages] in ${channel.id} (${guild.name} / ${channel.name}) because its last message ${channel.last_message_id} is not in the database`) // console.log(`[check missed messages] in ${channel.id} (${guild.name} / ${channel.name}) because its last message ${channel.last_message_id} is not in the database`)
let messages let messages
try { try {
messages = await client.snow.channel.getChannelMessages(channel.id, {limit: 100}) messages = await client.snow.channel.getChannelMessages(channel.id, {limit: 50})
} catch (e) { } catch (e) {
if (e.message === "Missing Access") { if (e.message === `{"message": "Missing Access", "code": 50001}`) { // pathetic error handling from SnowTransfer
console.log(`[check missed messages] no permissions to look back in channel ${channel.name} (${channel.id})`) console.log(`[check missed messages] no permissions to look back in channel ${channel.name} (${channel.id})`)
continue // Sucks. continue // Sucks.
} else { } else {
@ -213,7 +212,7 @@ module.exports = {
const channelID = thread.parent_id || undefined const channelID = thread.parent_id || undefined
const parentRoomID = select("channel_room", "room_id", {channel_id: channelID}).pluck().get() const parentRoomID = select("channel_room", "room_id", {channel_id: channelID}).pluck().get()
if (!parentRoomID) return // Not interested in a thread if we aren't interested in its wider channel (won't autocreate) if (!parentRoomID) return // Not interested in a thread if we aren't interested in its wider channel (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.ensureRoom(thread.id)
await announceThread.announceThread(parentRoomID, threadRoomID, thread) await announceThread.announceThread(parentRoomID, threadRoomID, thread)
}, },
@ -288,6 +287,7 @@ module.exports = {
if (!guildID) return // channel must have been a DM channel or something if (!guildID) return // channel must have been a DM channel or something
const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get() const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get()
if (!roomID) return // channel wasn't being bridged in the first place if (!roomID) return // channel wasn't being bridged in the first place
// @ts-ignore
await createRoom.unbridgeChannel(channel, guildID) await createRoom.unbridgeChannel(channel, guildID)
}, },
@ -297,7 +297,6 @@ module.exports = {
*/ */
async MESSAGE_CREATE(client, message) { async MESSAGE_CREATE(client, message) {
if (message.author.username === "Deleted User") return // Nothing we can do for deleted users. if (message.author.username === "Deleted User") return // Nothing we can do for deleted users.
if (select("opt_out", "user_id", {user_id: message.author.id}).pluck().get()) return // This user opted out.
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.
@ -306,19 +305,23 @@ module.exports = {
if (message.webhook_id) { if (message.webhook_id) {
const row = select("webhook", "webhook_id", {webhook_id: message.webhook_id}).pluck().get() const row = select("webhook", "webhook_id", {webhook_id: message.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) { // The message was sent by the bridge's own webhook on discord. We don't want to reflect this back, so just drop it.
await agiListener.process(message, channel, guild, true)
return
}
} }
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 if (!createRoom.existsOrAutocreatable(channel, guild.id)) return // Check that the sending-to room exists or is autocreatable
const {skip} = await speedbump.maybeDoSpeedbump(true, message) const {affected, row} = await speedbump.maybeDoSpeedbump(message.channel_id, message.id)
if (skip) return if (affected) return
await sendMessage.sendMessage(message, channel, guild) // @ts-ignore
await sendMessage.sendMessage(message, channel, guild, row)
retrigger.finishedBridging(message.id) retrigger.messageFinishedBridging(message.id)
}, },
/** /**
@ -334,27 +337,24 @@ module.exports = {
if (dUtils.isEphemeralMessage(data)) return // Ephemeral messages are for the eyes of the receiver only! if (dUtils.isEphemeralMessage(data)) return // Ephemeral messages are for the eyes of the receiver only!
// Edits need to go through the speedbump as well. If the message is delayed but the edit isn't, we don't have anything to edit from. // Edits need to go through the speedbump as well. If the message is delayed but the edit isn't, we don't have anything to edit from.
const {skip, hasCreate} = await speedbump.maybeDoSpeedbump(false, data) const {affected, row} = await speedbump.maybeDoSpeedbump(data.channel_id, data.id)
if (skip) return if (affected) return
if (!row) {
// Check that the sending-to room exists, and deal with Eventual Consistency(TM)
if (retrigger.eventNotFoundThenRetrigger(data.id, module.exports.MESSAGE_UPDATE, client, data)) return
}
/** @type {DiscordTypes.GatewayMessageCreateDispatchData} */ /** @type {DiscordTypes.GatewayMessageCreateDispatchData} */
// @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)
if (!hasCreate) { // @ts-ignore
// Standard path for most message updates await retrigger.pauseChanges(message.id, editMessage.editMessage(message, guild, row))
// Check that the target message already exists, and deal with Eventual Consistency(TM)
if (!await retrigger.waitForMessage(data.id)) return
await retrigger.pauseChanges(message.id, editMessage.editMessage(message, guild))
}
else {
// Path for edit packets that were speedbumped into the latest copy of a message that needs to be created
// Just pretend to be MESSAGE_CREATE
await sendMessage.sendMessage(message, channel, guild)
}
}, },
/** /**
@ -362,7 +362,6 @@ module.exports = {
* @param {DiscordTypes.GatewayMessageReactionAddDispatchData} data * @param {DiscordTypes.GatewayMessageReactionAddDispatchData} data
*/ */
async MESSAGE_REACTION_ADD(client, data) { async MESSAGE_REACTION_ADD(client, data) {
if (select("opt_out", "user_id", {user_id: data.user_id}).pluck().get()) return // This user opted out.
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.
if (data.emoji.name === "❓" && select("event_message", "message_id", {message_id: data.message_id, source: 0, part: 0}).get()) { // source 0 = matrix if (data.emoji.name === "❓" && select("event_message", "message_id", {message_id: data.message_id, source: 0, part: 0}).get()) { // source 0 = matrix
const guild_id = data.guild_id ?? client.channels.get(data.channel_id)?.["guild_id"] const guild_id = data.guild_id ?? client.channels.get(data.channel_id)?.["guild_id"]
@ -381,16 +380,6 @@ module.exports = {
* @param {DiscordTypes.GatewayMessageReactionRemoveDispatchData | DiscordTypes.GatewayMessageReactionRemoveEmojiDispatchData | DiscordTypes.GatewayMessageReactionRemoveAllDispatchData} data * @param {DiscordTypes.GatewayMessageReactionRemoveDispatchData | DiscordTypes.GatewayMessageReactionRemoveEmojiDispatchData | DiscordTypes.GatewayMessageReactionRemoveAllDispatchData} data
*/ */
async onSomeReactionsRemoved(client, data) { async onSomeReactionsRemoved(client, data) {
// Don't attempt to double-bridge our own m2d deleted reactions back to Matrix
if ("user_id" in data && data.user_id === botID) {
const emojiIdOrName = data.emoji.id || data.emoji.name
const i = redact.m2dDeletedReactions.findIndex(x => data.message_id === x.messageID && emojiIdOrName === x.emojiIdOrName)
if (i !== -1) {
redact.m2dDeletedReactions.splice(i, 1)
return
}
}
await removeReaction.removeSomeReactions(data) await removeReaction.removeSomeReactions(data)
}, },
@ -400,7 +389,7 @@ module.exports = {
*/ */
async MESSAGE_DELETE(client, data) { async MESSAGE_DELETE(client, data) {
speedbump.onMessageDelete(data.id) speedbump.onMessageDelete(data.id)
if (!await retrigger.waitForMessage(data.id)) return if (retrigger.eventNotFoundThenRetrigger(data.id, module.exports.MESSAGE_DELETE, client, data)) return
await deleteMessage.deleteMessage(data) await deleteMessage.deleteMessage(data)
}, },
@ -417,7 +406,6 @@ module.exports = {
* @param {DiscordTypes.GatewayTypingStartDispatchData} data * @param {DiscordTypes.GatewayTypingStartDispatchData} data
*/ */
async TYPING_START(client, data) { async TYPING_START(client, data) {
if (data.user_id === client.user.id) return
const roomID = select("channel_room", "room_id", {channel_id: data.channel_id}).pluck().get() const roomID = select("channel_room", "room_id", {channel_id: data.channel_id}).pluck().get()
if (!roomID) return if (!roomID) return
const mxid = from("sim").join("sim_member", "mxid").where({user_id: data.user_id, room_id: roomID}).pluck("mxid").get() const mxid = from("sim").join("sim_member", "mxid").where({user_id: data.user_id, room_id: roomID}).pluck("mxid").get()
@ -449,12 +437,12 @@ module.exports = {
* @param {DiscordTypes.GatewayMessagePollVoteDispatchData} data * @param {DiscordTypes.GatewayMessagePollVoteDispatchData} data
*/ */
async MESSAGE_POLL_VOTE_ADD(client, data) { async MESSAGE_POLL_VOTE_ADD(client, data) {
if (!await retrigger.waitForMessage(data.message_id)) return if (retrigger.eventNotFoundThenRetrigger(data.message_id, module.exports.MESSAGE_POLL_VOTE_ADD, client, data)) return
await vote.addVote(data) await vote.addVote(data)
}, },
async MESSAGE_POLL_VOTE_REMOVE(client, data) { async MESSAGE_POLL_VOTE_REMOVE(client, data) {
if (!await retrigger.waitForMessage(data.message_id)) return if (retrigger.eventNotFoundThenRetrigger(data.message_id, module.exports.MESSAGE_POLL_VOTE_REMOVE, client, data)) return
await vote.removeVote(data) await vote.removeVote(data)
}, },

View file

@ -0,0 +1,25 @@
BEGIN TRANSACTION;
CREATE TABLE "agi_prior_message" (
"channel_id" TEXT NOT NULL,
"username" TEXT NOT NULL,
"avatar_url" TEXT NOT NULL,
"use_caps" INTEGER NOT NULL,
"use_punct" INTEGER NOT NULL,
"use_apos" INTEGER NOT NULL,
"timestamp" INTEGER NOT NULL,
PRIMARY KEY("channel_id")
) WITHOUT ROWID;
CREATE TABLE "agi_optout" (
"guild_id" TEXT NOT NULL,
PRIMARY KEY("guild_id")
) WITHOUT ROWID;
CREATE TABLE "agi_cooldown" (
"guild_id" TEXT NOT NULL,
"timestamp" INTEGER,
PRIMARY KEY("guild_id")
) WITHOUT ROWID;
COMMIT;

View file

@ -1,42 +0,0 @@
const {discord, db, from, select, sync} = require("../../passthrough")
/** @type {import("../../discord/utils")} */
const dUtils = sync.require("../../discord/utils")
const ones = "₀₁₂₃₄₅₆₇₈₉"
const tens = "0123456789"
/* c8 ignore start */
module.exports = async function(db) {
// added tolerance to https://discordstatus.com/incidents/4hpm4454hxtx
const OUTAGE_START = 1778263200000
const OUTAGE_END = 1778284800000
const startSnowflake = dUtils.timestampToSnowflakeInexact(OUTAGE_START)
const endSnowflake = dUtils.timestampToSnowflakeInexact(OUTAGE_END)
const affectedChannels = from("message_room").join("historical_channel_room", "historical_room_index")
.pluck("reference_channel_id").selectUnsafe("DISTINCT reference_channel_id")
.and("WHERE message_id >= ? AND message_id <= ? AND length(message_id) = ?").all(startSnowflake, endSnowflake, startSnowflake.length)
let affectedWebhooks = select("webhook", ["channel_id", "webhook_id", "webhook_token"], {channel_id: affectedChannels}).all()
affectedWebhooks = affectedWebhooks.filter(w => BigInt(w.webhook_id) < BigInt(endSnowflake)) // if webhook ID is already newly generated then no need to replace
if (affectedWebhooks.length) {
process.stdout.write(` revoking ${affectedWebhooks.length} possibly compromised webhooks... `)
for (let counter = 1; counter <= affectedWebhooks.length; counter++) {
const webhook = affectedWebhooks[counter-1]
await discord.snow.webhook.deleteWebhookToken(webhook.webhook_id, webhook.webhook_token, "Webhook token possibly compromised during 8th May 2026 outage").catch(e => {
if (e.message === "Unknown Webhook") {
// OK
} else {
throw e
}
})
db.prepare("DELETE FROM webhook WHERE channel_id = ?").run(webhook.channel_id)
process.stdout.write(String(counter).at(-1) === "0" ? tens[(counter/10)%10] : ones[counter%10])
}
process.stdout.write("\n")
}
}

View file

@ -1,5 +0,0 @@
BEGIN TRANSACTION;
DELETE FROM emoji WHERE mxc_url NOT IN (SELECT mxc_url FROM file WHERE discord_url LIKE 'https://cdn.discordapp.com/emojis/%.webp%');
COMMIT;

View file

@ -1,19 +0,0 @@
BEGIN TRANSACTION;
CREATE TABLE "channel_speedbump" (
"channel_id" TEXT NOT NULL,
"speedbump_webhook_id" TEXT NOT NULL,
"speedbump_user_id" TEXT NOT NULL,
PRIMARY KEY("channel_id","speedbump_webhook_id"),
FOREIGN KEY("channel_id") REFERENCES "channel_room"("channel_id")
) WITHOUT ROWID;
INSERT INTO channel_speedbump (channel_id, speedbump_webhook_id, speedbump_user_id)
SELECT channel_id, speedbump_webhook_id, speedbump_id FROM channel_room WHERE speedbump_id IS NOT NULL AND speedbump_webhook_id IS NOT NULL;
ALTER TABLE channel_room DROP COLUMN speedbump_id;
ALTER TABLE channel_room DROP COLUMN speedbump_webhook_id;
ALTER TABLE sim_proxy ADD COLUMN proxy_app INTEGER DEFAULT 0;
COMMIT;

View file

@ -1,9 +0,0 @@
BEGIN TRANSACTION;
CREATE TABLE "opt_out" (
"user_id" TEXT NOT NULL,
"opted_out_at" INTEGER NOT NULL,
PRIMARY KEY("user_id")
) WITHOUT ROWID;
COMMIT;

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

@ -1,4 +1,23 @@
export type Models = { export type Models = {
agi_prior_message: {
channel_id: string
username: string
avatar_url: string
use_caps: number
use_punct: number
use_apos: number
timestamp: number
}
agi_optout: {
guild_id: string
}
agi_cooldown: {
guild_id: string
timestamp: number
}
app_user_install: { app_user_install: {
guild_id: string guild_id: string
app_bot_id: string app_bot_id: string
@ -18,17 +37,13 @@ export type Models = {
thread_parent: string | null thread_parent: string | null
custom_avatar: string | null custom_avatar: string | null
last_bridged_pin_timestamp: number | null last_bridged_pin_timestamp: number | null
speedbump_id: string | null
speedbump_webhook_id: string | null
speedbump_checked: number | null speedbump_checked: number | null
guild_id: string | null guild_id: string | null
custom_topic: number custom_topic: number
} }
channel_speedbump: {
channel_id: string
speedbump_webhook_id: string
speedbump_user_id: string
}
direct: { direct: {
mxid: string mxid: string
room_id: string room_id: string
@ -48,7 +63,6 @@ export type Models = {
event_subtype: string | null event_subtype: string | null
part: number part: number
reaction_part: number reaction_part: number
/** 0 = Matrix, 1 = Discord */
source: number source: number
} }
@ -115,11 +129,6 @@ export type Models = {
historical_room_index: number historical_room_index: number
} }
opt_out: {
user_id: string
opted_out_at: number
}
role_default: { role_default: {
guild_id: string guild_id: string
role_id: string role_id: string
@ -147,8 +156,6 @@ export type Models = {
user_id: string user_id: string
proxy_owner_id: string proxy_owner_id: string
displayname: string displayname: string
/** 0 = PluralKit, 1 = /plu/ral */
proxy_app: number
} }
webhook: { webhook: {

View file

@ -104,16 +104,6 @@ class From {
return r return r
} }
pluckUnsafe(col) {
/** @type {Pluck<Table, any>} */
// @ts-ignore
const r = this
r.cols = [col]
r.makeColsSafe = false
r.isPluck = true
return r
}
/** /**
* @param {string} sql * @param {string} sql
*/ */

View file

@ -68,8 +68,3 @@ test("orm: select unsafe works (to select complex column names that can't be typ
.all() .all()
t.equal(results[0].power_level, 150) t.equal(results[0].power_level, 150)
}) })
test("orm: pluck unsafe works (to select complex column names that can't be type verified)", t => {
const result = from("channel_room").where({guild_id: "112760669178241024"}).pluckUnsafe("count(*)").get()
t.equal(result, 7)
})

View file

@ -54,7 +54,6 @@ async function _interact({guild_id, data}, {api}) {
// from Matrix // from Matrix
const event = await api.getEvent(message.room_id, message.event_id) const event = await api.getEvent(message.room_id, message.event_id)
const via = await utils.getViaServersQuery(message.room_id, api) const via = await utils.getViaServersQuery(message.room_id, api)
const channelsInGuild = discord.guildChannelMap.get(guild_id) const channelsInGuild = discord.guildChannelMap.get(guild_id)
assert(channelsInGuild) assert(channelsInGuild)
const inChannels = channelsInGuild const inChannels = channelsInGuild
@ -62,11 +61,6 @@ async function _interact({guild_id, data}, {api}) {
.map(/** @returns {DiscordTypes.APIGuildChannel} */ cid => discord.channels.get(cid)) .map(/** @returns {DiscordTypes.APIGuildChannel} */ cid => discord.channels.get(cid))
.sort((a, b) => webGuild._getPosition(a, discord.channels) - webGuild._getPosition(b, discord.channels)) .sort((a, b) => webGuild._getPosition(a, discord.channels) - webGuild._getPosition(b, discord.channels))
.filter(channel => from("channel_room").join("member_cache", "room_id").select("mxid").where({channel_id: channel.id, mxid: event.sender}).get()) .filter(channel => from("channel_room").join("member_cache", "room_id").select("mxid").where({channel_id: channel.id, mxid: event.sender}).get())
let inChannelsText = inChannels.map(c => `<#${c.id}>`).join(" • ")
if (inChannelsText.length > 1024) {
inChannelsText = `In ${inChannels.length} channels`
}
const matrixMember = select("member_cache", ["displayname", "avatar_url"], {room_id: message.room_id, mxid: event.sender}).get() const matrixMember = select("member_cache", ["displayname", "avatar_url"], {room_id: message.room_id, mxid: event.sender}).get()
let name = matrixMember?.displayname || event.sender let name = matrixMember?.displayname || event.sender
let avatar = utils.getPublicUrlForMxc(matrixMember?.avatar_url) let avatar = utils.getPublicUrlForMxc(matrixMember?.avatar_url)
@ -104,7 +98,7 @@ async function _interact({guild_id, data}, {api}) {
color: 0x0dbd8b, color: 0x0dbd8b,
fields: [{ fields: [{
name: "In Channels", name: "In Channels",
value: inChannelsText value: inChannels.map(c => `<#${c.id}>`).join(" • ")
}, { }, {
name: "\u200b", name: "\u200b",
value: idInfo value: idInfo

View file

@ -55,10 +55,10 @@ async function* _interact({data, message, member, user}, {api}) {
title: "Poll", title: "Poll",
components: [{ components: [{
type: DiscordTypes.ComponentType.TextDisplay, type: DiscordTypes.ComponentType.TextDisplay,
content: `## ${pollRow.question_text}` content: `-# ${pollComponents.getMultiSelectString(pollRow.max_selections, options.length)}`
}, { }, {
type: DiscordTypes.ComponentType.Label, type: DiscordTypes.ComponentType.Label,
label: pollComponents.getMultiSelectString(pollRow.max_selections, options.length), label: pollRow.question_text,
component: { component: {
type: DiscordTypes.ComponentType.RadioGroup, type: DiscordTypes.ComponentType.RadioGroup,
custom_id: "POLL_MODAL_SELECTION", custom_id: "POLL_MODAL_SELECTION",

View file

@ -16,9 +16,9 @@ const utils = sync.require("../../matrix/utils")
* @returns {AsyncGenerator<{[k in keyof InteractionMethods]?: Parameters<InteractionMethods[k]>[2]}>} * @returns {AsyncGenerator<{[k in keyof InteractionMethods]?: Parameters<InteractionMethods[k]>[2]}>}
*/ */
async function* _interact({data}, {api}) { async function* _interact({data}, {api}) {
const events = from("event_message").join("message_room", "message_id").join("historical_channel_room", "historical_room_index") const row = from("event_message").join("message_room", "message_id").join("historical_channel_room", "historical_room_index")
.select("event_id", "room_id").where({message_id: data.target_id}).and("ORDER BY reaction_part").all() .select("event_id", "room_id").where({message_id: data.target_id}).get()
if (!events.length) { if (!row) {
return yield {createInteractionResponse: { return yield {createInteractionResponse: {
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
data: { data: {
@ -35,23 +35,17 @@ async function* _interact({data}, {api}) {
} }
}} }}
const reactions = [] const reactions = await api.getFullRelations(row.room_id, row.event_id, "m.annotation")
for (const event of events) {
const reactionsOnEvent = await api.getFullRelations(event.room_id, event.event_id, "m.annotation")
reactions.push(...reactionsOnEvent)
}
/** @type {Map<string, Set<string>>} reaction key (the emoji) -> displaynames */ /** @type {Map<string, string[]>} */
const inverted = new Map() const inverted = new Map()
for (const reaction of reactions) { for (const reaction of reactions) {
if (utils.eventSenderIsFromDiscord(reaction.sender)) continue if (utils.eventSenderIsFromDiscord(reaction.sender)) continue
const key = reaction.content["m.relates_to"].key const key = reaction.content["m.relates_to"].key
const displayname = select("member_cache", "displayname", {mxid: reaction.sender, room_id: events[0].room_id}).pluck().get() || reaction.sender const displayname = select("member_cache", "displayname", {mxid: reaction.sender, room_id: row.room_id}).pluck().get() || reaction.sender
if (!inverted.has(key)) { if (!inverted.has(key)) inverted.set(key, [])
inverted.set(key, new Set())
}
// @ts-ignore // @ts-ignore
inverted.get(key).add(displayname) inverted.get(key).push(displayname)
} }
if (inverted.size === 0) { if (inverted.size === 0) {
@ -61,7 +55,7 @@ async function* _interact({data}, {api}) {
} }
return yield {editOriginalInteractionResponse: { return yield {editOriginalInteractionResponse: {
content: [...inverted.entries()].sort((a, b) => b[1].size - a[1].size).map(([key, value]) => `${key}${[...value].join(" ⬩ ")}`).join("\n"), content: [...inverted.entries()].map(([key, value]) => `${key}${value.join(" ⬩ ")}`).join("\n"),
}} }}
} }

View file

@ -43,7 +43,7 @@ test("reactions: different response if nobody reacted", async t => {
}) })
test("reactions: shows reactions if there are some, ignoring discord users", async t => { test("reactions: shows reactions if there are some, ignoring discord users", async t => {
let called = 0 let called = 1
const msgs = await fromAsync(_interact({ const msgs = await fromAsync(_interact({
data: { data: {
target_id: "1126786462646550579" target_id: "1126786462646550579"
@ -51,7 +51,6 @@ test("reactions: shows reactions if there are some, ignoring discord users", asy
}, { }, {
api: { api: {
async getFullRelations(roomID, eventID) { async getFullRelations(roomID, eventID) {
called++
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
t.equal(eventID, "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg") t.equal(eventID, "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg")
return [{ return [{
@ -98,57 +97,3 @@ test("reactions: shows reactions if there are some, ignoring discord users", asy
) )
t.equal(called, 1) t.equal(called, 1)
}) })
test("reactions: combines reactions from multiple events", async t => {
let called = 0
const msgs = await fromAsync(_interact({
data: {
target_id: "1141501302736695316"
}
}, {
api: {
async getFullRelations(roomID, eventID) {
called++
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
if (eventID === "$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA") {
return [{
sender: "@cadence:cadence.moe",
content: {
"m.relates_to": {
key: "🐈",
rel_type: "m.annotation"
}
}
}, {
sender: "@rnl:cadence.moe",
content: {
"m.relates_to": {
key: "🐈",
rel_type: "m.annotation"
}
}
}]
} else if (eventID === "$51f4yqHinwnSbPEQ9dCgoyy4qiIJSX0QYYVUnvwyTCI") {
return [{
sender: "@cadence:cadence.moe",
content: {
"m.relates_to": {
key: "🐈‍⬛",
rel_type: "m.annotation"
}
}
}]
} else {
t.fail(`tried to read reactions on wrong event ${eventID}`)
}
}
}
}))
t.equal(msgs.length, 2)
t.equal(
msgs[1].editOriginalInteractionResponse.content,
"🐈 ⮞ cadence [they] ⬩ @rnl:cadence.moe"
+ "\n🐈⬛ ⮞ cadence [they]"
)
t.equal(called, 2)
})

View file

@ -16,7 +16,7 @@ const ping = sync.require("./interactions/ping.js")
// User must have EVERY permission in default_member_permissions to be able to use the command // User must have EVERY permission in default_member_permissions to be able to use the command
function registerInteractions() { function registerInteractions() {
discord.snow.interaction.editApplicationCommands(id, [{ discord.snow.interaction.bulkOverwriteApplicationCommands(id, [{
name: "Matrix info", name: "Matrix info",
contexts: [DiscordTypes.InteractionContextType.Guild], contexts: [DiscordTypes.InteractionContextType.Guild],
type: DiscordTypes.ApplicationCommandType.Message, type: DiscordTypes.ApplicationCommandType.Message,
@ -91,32 +91,40 @@ function registerInteractions() {
async function dispatchInteraction(interaction) { async function dispatchInteraction(interaction) {
const interactionId = interaction.data?.["custom_id"] || interaction.data?.["name"] const interactionId = interaction.data?.["custom_id"] || interaction.data?.["name"]
try { try {
if (interactionId === "Matrix info") { if (interaction.type === DiscordTypes.InteractionType.MessageComponent || interaction.type === DiscordTypes.InteractionType.ModalSubmit) {
await matrixInfo.interact(interaction) // All we get is custom_id, don't know which context the button was clicked in.
} else if (interactionId === "invite") { // So we namespace these ourselves in the custom_id. Currently the only existing namespace is POLL_.
await invite.interact(interaction) if (interaction.data.custom_id.startsWith("POLL_")) {
} else if (interactionId === "invite_channel") { await poll.interact(interaction)
await invite.interactButton(interaction)
} else if (interactionId === "Permissions") {
await permissions.interact(interaction)
} else if (interactionId === "permissions_edit") {
await permissions.interactEdit(interaction)
} else if (interactionId === "Responses") {
/** @type {DiscordTypes.APIMessageApplicationCommandGuildInteraction} */ // @ts-ignore
const messageInteraction = interaction
if (select("poll", "message_id", {message_id: messageInteraction.data.target_id}).get()) {
await pollResponses.interact(messageInteraction)
} else { } else {
await reactions.interact(messageInteraction) throw new Error(`Unknown message component ${interaction.data.custom_id}`)
} }
} else if (interactionId === "ping") {
await ping.interact(interaction)
} else if (interactionId === "privacy") {
await privacy.interact(interaction)
} else if (interactionId.startsWith("POLL_")) {
await poll.interact(interaction)
} else { } else {
throw new Error(`Unknown interaction ${interactionId}`) if (interactionId === "Matrix info") {
await matrixInfo.interact(interaction)
} else if (interactionId === "invite") {
await invite.interact(interaction)
} else if (interactionId === "invite_channel") {
await invite.interactButton(interaction)
} else if (interactionId === "Permissions") {
await permissions.interact(interaction)
} else if (interactionId === "permissions_edit") {
await permissions.interactEdit(interaction)
} else if (interactionId === "Responses") {
/** @type {DiscordTypes.APIMessageApplicationCommandGuildInteraction} */ // @ts-ignore
const messageInteraction = interaction
if (select("poll", "message_id", {message_id: messageInteraction.data.target_id}).get()) {
await pollResponses.interact(messageInteraction)
} else {
await reactions.interact(messageInteraction)
}
} else if (interactionId === "ping") {
await ping.interact(interaction)
} else if (interactionId === "privacy") {
await privacy.interact(interaction)
} else {
throw new Error(`Unknown interaction ${interactionId}`)
}
} }
} catch (e) { } catch (e) {
let stackLines = null let stackLines = null

View file

@ -182,406 +182,6 @@ function filterTo(xs, fn) {
return filtered return filtered
} }
/**
* The parameters correspond to the columns of the channel_room table.
* @param {string} rowChannelID thread ID, OR channel ID if there is no thread
* @param {string | null | undefined} rowThreadParent channel ID if there is a thread
*/
function swapThreadID(rowChannelID, rowThreadParent) {
return {
channelID: rowThreadParent ? rowThreadParent : rowChannelID,
threadID: rowThreadParent ? rowChannelID : undefined
}
}
const supportedPlaintextPreviewExtensions = new Set([
"4d",
"abnf",
"accesslog",
"actionscript",
"ada",
"adoc",
"alan",
"angelscript",
"ansi",
"apache",
"apacheconf",
"applescript",
"arcade",
"arduino",
"arm",
"armasm",
"as",
"asc",
"asciidoc",
"aspectj",
"ass",
"atom",
"autohotkey",
"autoit",
"avrasm",
"awk",
"axapta",
"bash",
"basic",
"bat",
"bbcode",
"bf",
"bind",
"blade",
"bnf",
"brainfuck",
"c",
"c++",
"cal",
"capnp",
"capnproto",
"cc",
"chaos",
"chapel",
"chpl",
"cisco",
"clj",
"clojure",
"cls",
"cmake.in",
"cmake",
"cmd",
"coffee",
"coffeescript",
"console",
"coq",
"cos",
"cpc",
"cpp",
"cr",
"craftcms",
"crm",
"crmsh",
"crystal",
"cs",
"csharp",
"cshtml",
"cson",
"csp",
"css",
"csv",
"cxx",
"cypher",
"d",
"dart",
"delphi",
"dfm",
"diff",
"django",
"dns",
"docker",
"dockerfile",
"dos",
"dpr",
"dsconfig",
"dst",
"dts",
"dust",
"dylan",
"ebnf",
"elixir",
"elm",
"erl",
"erlang",
"ex",
"extempore",
"f90",
"f95",
"fix",
"fortran",
"freepascal",
"fs",
"fsharp",
"gams",
"gauss",
"gawk",
"gcode",
"gdscript",
"gemspec",
"gf",
"gherkin",
"glsl",
"gms",
"gn",
"gni",
"go",
"godot",
"golang",
"golo",
"gololang",
"gradle",
"graph",
"groovy",
"gss",
"gyp",
"h",
"h++",
"haml",
"handlebars",
"haskell",
"haxe",
"hbs",
"hcl",
"hh",
"hpp",
"hs",
"html.handlebars",
"html.hbs",
"html",
"http",
"https",
"hx",
"hxx",
"hy",
"hylang",
"i",
"i7",
"iced",
"iecst",
"inform7",
"ini",
"ino",
"instances",
"iol",
"irb",
"irpf90",
"java",
"javascript",
"jinja",
"jolie",
"js",
"json",
"jsp",
"jsx",
"julia-repl",
"julia",
"k",
"kaos",
"kdb",
"kotlin",
"kt",
"lasso",
"lassoscript",
"lazarus",
"ldif",
"leaf",
"lean",
"less",
"lfm",
"lisp",
"livecodeserver",
"livescript",
"ln",
"lock",
"log",
"lpr",
"ls",
"ls",
"lua",
"mak",
"make",
"makefile",
"markdown",
"mathematica",
"matlab",
"mawk",
"maxima",
"md",
"mel",
"mercury",
"mirc",
"mizar",
"mk",
"mkd",
"mkdown",
"ml",
"ml",
"mm",
"mma",
"mojolicious",
"monkey",
"moon",
"moonscript",
"mrc",
"n1ql",
"nawk",
"nc",
"never",
"nginx",
"nginxconf",
"nim",
"nimrod",
"nix",
"nsis",
"obj-c",
"obj-c++",
"objc",
"objective-c++",
"objectivec",
"ocaml",
"ocl",
"ol",
"openscad",
"osascript",
"oxygene",
"p21",
"parser3",
"pas",
"pascal",
"patch",
"pcmk",
"perl",
"pf.conf",
"pf",
"pgsql",
"php",
"php3",
"php4",
"php5",
"php6",
"php7",
"pl",
"plaintext",
"plist",
"pm",
"podspec",
"pony",
"postgres",
"postgresql",
"powershell",
"pp",
"processing",
"profile",
"prolog",
"properties",
"proto",
"protobuf",
"ps",
"ps1",
"puppet",
"py",
"pycon",
"python-repl",
"python",
"qml",
"r",
"razor-cshtml",
"razor",
"rb",
"re",
"reasonml",
"rebol",
"red-system",
"red",
"redbol",
"rf",
"rib",
"robot",
"rpm-spec",
"rpm-specfile",
"rpm",
"rs",
"rsl",
"rss",
"ruby",
"ruleslanguage",
"rust",
"sas",
"SAS",
"sc",
"scad",
"scala",
"scheme",
"sci",
"scilab",
"scl",
"scss",
"sh",
"shell",
"shexc",
"smali",
"smalltalk",
"sml",
"sol",
"solidity",
"spec",
"specfile",
"sql",
"srt",
"ssa",
"st",
"stan",
"stanfuncs",
"stata",
"step",
"stp",
"structured-text",
"styl",
"stylus",
"subunit",
"supercollider",
"svelte",
"svg",
"swift",
"tao",
"tap",
"tcl",
"terraform",
"tex",
"text",
"tf",
"thor",
"thrift",
"tk",
"toml",
"tp",
"ts",
"tsql",
"tsx",
"ttml",
"twig",
"txt",
"typescript",
"unicorn-rails-log",
"v",
"vala",
"vb",
"vba",
"vbnet",
"vbs",
"vbscript",
"verilog",
"vhdl",
"vim",
"vtt",
"wl",
"x++",
"x86asm",
"xhtml",
"xjb",
"xl",
"xml",
"xpath",
"xq",
"xquery",
"xsd",
"xsl",
"xtlang",
"xtm",
"yaml",
"yml",
"zep",
"zephir",
"zone",
"zsh"
])
module.exports.getPermissions = getPermissions module.exports.getPermissions = getPermissions
module.exports.getDefaultPermissions = getDefaultPermissions module.exports.getDefaultPermissions = getDefaultPermissions
module.exports.hasPermission = hasPermission module.exports.hasPermission = hasPermission
@ -594,5 +194,3 @@ module.exports.timestampToSnowflakeInexact = timestampToSnowflakeInexact
module.exports.getPublicUrlForCdn = getPublicUrlForCdn module.exports.getPublicUrlForCdn = getPublicUrlForCdn
module.exports.howOldUnbridgedMessage = howOldUnbridgedMessage module.exports.howOldUnbridgedMessage = howOldUnbridgedMessage
module.exports.filterTo = filterTo module.exports.filterTo = filterTo
module.exports.swapThreadID = swapThreadID
module.exports.supportedPlaintextPreviewExtensions = supportedPlaintextPreviewExtensions

View file

@ -17,7 +17,7 @@ const retrigger = sync.require("../../d2m/actions/retrigger")
*/ */
async function addReaction(event) { async function addReaction(event) {
// Wait until the corresponding channel and message have already been bridged // Wait until the corresponding channel and message have already been bridged
if (!await retrigger.waitForEvent(event.content["m.relates_to"].event_id)) return if (retrigger.eventNotFoundThenRetrigger(event.content["m.relates_to"].event_id, () => as.emit("type:m.reaction", event))) return
// These will exist because it passed retrigger // These will exist because it passed retrigger
const row = from("event_message").join("message_room", "message_id").join("historical_channel_room", "historical_room_index") const row = from("event_message").join("message_room", "message_id").join("historical_channel_room", "historical_room_index")
@ -50,8 +50,6 @@ async function addReaction(event) {
} }
db.prepare("REPLACE INTO reaction (hashed_event_id, message_id, encoded_emoji, original_encoding) VALUES (?, ?, ?, ?)").run(utils.getEventIDHash(event.event_id), messageID, discordPreferredEncoding, key) db.prepare("REPLACE INTO reaction (hashed_event_id, message_id, encoded_emoji, original_encoding) VALUES (?, ?, ?, ?)").run(utils.getEventIDHash(event.event_id), messageID, discordPreferredEncoding, key)
retrigger.finishedBridging(event.event_id)
} }
module.exports.addReaction = addReaction module.exports.addReaction = addReaction

View file

@ -45,7 +45,7 @@ async function ensureWebhook(channelID, forceCreate = false) {
async function withWebhook(channelID, callback) { async function withWebhook(channelID, callback) {
const webhook = await ensureWebhook(channelID, false) const webhook = await ensureWebhook(channelID, false)
return callback(webhook).catch(async e => { return callback(webhook).catch(async e => {
if (e.message === "Unknown Webhook") { if (e.message === `{"message": "Unknown Webhook", "code": 10015}`) { // pathetic error handling from SnowTransfer
// Our webhook is gone. Maybe somebody deleted it, or removed and re-added OOYE from the guild. // Our webhook is gone. Maybe somebody deleted it, or removed and re-added OOYE from the guild.
const newWebhook = await ensureWebhook(channelID, true) const newWebhook = await ensureWebhook(channelID, true)
return callback(newWebhook) // not caught; if the error happens again just throw it instead of looping return callback(newWebhook) // not caught; if the error happens again just throw it instead of looping

View file

@ -23,7 +23,7 @@ async function getAndConvertEmoji(mxc) {
const res = await api.getMedia(mxc, {signal: abortController.signal}) const res = await api.getMedia(mxc, {signal: abortController.signal})
if (res.status !== 200) { if (res.status !== 200) {
const root = await res.json() const root = await res.json()
throw new mreq.MatrixServerError(root, res.status, {mxc}) throw new mreq.MatrixServerError(root, {mxc})
} }
const readable = stream.Readable.fromWeb(res.body) const readable = stream.Readable.fromWeb(res.body)
return emojiSheetConverter.convertImageStream(readable, () => { return emojiSheetConverter.convertImageStream(readable, () => {

View file

@ -10,9 +10,6 @@ const utils = sync.require("../../matrix/utils")
/** @type {import("../../d2m/actions/retrigger")} */ /** @type {import("../../d2m/actions/retrigger")} */
const retrigger = sync.require("../../d2m/actions/retrigger") const retrigger = sync.require("../../d2m/actions/retrigger")
/** @type {{messageID: string, emojiIdOrName: string}[]} */
const m2dDeletedReactions = []
/** /**
* @param {Ty.Event.Outer_M_Room_Redaction} event * @param {Ty.Event.Outer_M_Room_Redaction} event
*/ */
@ -27,21 +24,6 @@ async function deleteMessage(event) {
db.prepare("DELETE FROM message_room WHERE message_id = ?").run(rows[0].message_id) db.prepare("DELETE FROM message_room WHERE message_id = ?").run(rows[0].message_id)
} }
/**
* @param {Ty.Event.Outer_M_Room_Redaction} event
*/
async function removeMessageEvent(event) {
// Could be for removing a message or suppressing embeds. For more information, the message needs to be bridged first.
if (!await retrigger.waitForEvent(event.redacts)) return
const row = select("event_message", ["event_type", "event_subtype", "part"], {event_id: event.redacts}).get()
if (row && row.event_type === "m.room.message" && row.event_subtype === "m.notice" && row.part === 1) {
await suppressEmbeds(event)
} else {
await deleteMessage(event)
}
}
/** /**
* @param {Ty.Event.Outer_M_Room_Redaction} event * @param {Ty.Event.Outer_M_Room_Redaction} event
*/ */
@ -59,20 +41,11 @@ async function suppressEmbeds(event) {
* @param {Ty.Event.Outer_M_Room_Redaction} event * @param {Ty.Event.Outer_M_Room_Redaction} event
*/ */
async function removeReaction(event) { async function removeReaction(event) {
if (!await retrigger.waitForReactionEvent(event.redacts)) return
const hash = utils.getEventIDHash(event.redacts) const hash = utils.getEventIDHash(event.redacts)
const row = from("reaction").join("message_room", "message_id").join("historical_channel_room", "historical_room_index") const row = from("reaction").join("message_room", "message_id").join("historical_channel_room", "historical_room_index")
.select("reference_channel_id", "message_id", "encoded_emoji").where({hashed_event_id: hash}).get() .select("reference_channel_id", "message_id", "encoded_emoji").where({hashed_event_id: hash}).get()
if (!row) return if (!row) return
// See how many Matrix-side reactions there are, and delete if it's the last one await discord.snow.channel.deleteReactionSelf(row.reference_channel_id, row.message_id, row.encoded_emoji)
const numberOfReactions = from("reaction").where({message_id: row.message_id, encoded_emoji: row.encoded_emoji}).pluckUnsafe("count(*)").get()
if (numberOfReactions === 1) {
// If a unicode emoji, the name is already the Discord preferred version because that's what was added and stored to encoded_emoji
const emojiIdOrName = decodeURIComponent(row.encoded_emoji).split(":").slice(-1)[0]
m2dDeletedReactions.push({messageID: row.message_id, emojiIdOrName})
await discord.snow.channel.deleteReactionSelf(row.reference_channel_id, row.message_id, row.encoded_emoji)
}
db.prepare("DELETE FROM reaction WHERE hashed_event_id = ?").run(hash) db.prepare("DELETE FROM reaction WHERE hashed_event_id = ?").run(hash)
} }
@ -81,12 +54,18 @@ async function removeReaction(event) {
* @param {Ty.Event.Outer_M_Room_Redaction} event * @param {Ty.Event.Outer_M_Room_Redaction} event
*/ */
async function handle(event) { async function handle(event) {
// Don't know if it's a redaction for a reaction or an event, try both at the same time (otherwise waitFor will block) // If this is for removing a reaction, try it
await Promise.all([ await removeReaction(event)
removeMessageEvent(event),
removeReaction(event) // Or, it might be for removing a message or suppressing embeds. But to do that, the message needs to be bridged first.
]) if (retrigger.eventNotFoundThenRetrigger(event.redacts, () => as.emit("type:m.room.redaction", event))) return
const row = select("event_message", ["event_type", "event_subtype", "part"], {event_id: event.redacts}).get()
if (row && row.event_type === "m.room.message" && row.event_subtype === "m.notice" && row.part === 1) {
await suppressEmbeds(event)
} else {
await deleteMessage(event)
}
} }
module.exports.handle = handle module.exports.handle = handle
module.exports.m2dDeletedReactions = m2dDeletedReactions

View file

@ -39,14 +39,20 @@ async function resolvePendingFiles(message) {
if ("key" in p) { if ("key" in p) {
// Encrypted file // Encrypted file
const d = crypto.createDecipheriv("aes-256-ctr", Buffer.from(p.key, "base64url"), Buffer.from(p.iv, "base64url")) const d = crypto.createDecipheriv("aes-256-ctr", Buffer.from(p.key, "base64url"), Buffer.from(p.iv, "base64url"))
await api.getMedia(p.mxc).then(res => stream.Readable.fromWeb(res.body).pipe(d)) await api.getMedia(p.mxc).then(res => stream.Readable.fromWeb(
// @ts-ignore
res.body
).pipe(d))
return { return {
name: p.name, name: p.name,
file: d file: d
} }
} else { } else {
// Unencrypted file // Unencrypted file
const body = await api.getMedia(p.mxc).then(res => stream.Readable.fromWeb(res.body)) const body = await api.getMedia(p.mxc).then(res => stream.Readable.fromWeb(
// @ts-ignore
res.body
))
return { return {
name: p.name, name: p.name,
file: body file: body
@ -153,7 +159,7 @@ async function sendEvent(event) {
channel_id: messageResponse.channel_id, channel_id: messageResponse.channel_id,
guild_id: guild.id, guild_id: guild.id,
embeds: messageResponse.embeds embeds: messageResponse.embeds
}, guild) }, guild, null)
) )
} }
} }

View file

@ -22,7 +22,7 @@ async function getAndResizeSticker(mxc) {
const res = await api.getMedia(mxc) const res = await api.getMedia(mxc)
if (res.status !== 200) { if (res.status !== 200) {
const root = await res.json() const root = await res.json()
throw new mreq.MatrixServerError(root, res.status, {mxc}) throw new mreq.MatrixServerError(root, {mxc})
} }
const streamIn = Readable.fromWeb(res.body) const streamIn = Readable.fromWeb(res.body)

View file

@ -1,39 +0,0 @@
// @ts-check
const {discord} = require("../../passthrough")
/** @type {Set<string>} channel ids that are currently typing */
const isTyping = new Set()
/** @type {Map<string, NodeJS.Timeout>} channel id -> interval */
const intervals = new Map()
/** @param {string} channelID */
function startTyping(channelID) {
isTyping.add(channelID)
// Start a new typing session if needed
if (!intervals.has(channelID)) {
intervals.set(channelID, setInterval(refreshTyping, 8e3, channelID))
discord.snow.channel.startChannelTyping(channelID).catch(() => {})
}
}
/** @param {string} channelID */
function refreshTyping(channelID) {
if (isTyping.has(channelID)) {
// Continue typing session
discord.snow.channel.startChannelTyping(channelID).catch(() => {})
} else {
// End typing session
clearInterval(intervals.get(channelID))
intervals.delete(channelID)
}
}
/** @param {string} channelID */
function stopTyping(channelID) {
// Schedule typing session to end
isTyping.delete(channelID)
}
module.exports.startTyping = startTyping
module.exports.stopTyping = stopTyping

View file

@ -16,9 +16,9 @@ async function updatePins(pins, prev) {
.select("reference_channel_id", "message_id").where({event_id}).and("ORDER BY part ASC").get() .select("reference_channel_id", "message_id").where({event_id}).and("ORDER BY part ASC").get()
if (!row) continue if (!row) continue
if (added) { if (added) {
discord.snow.channel.createChannelPinnedMessage(row.reference_channel_id, row.message_id, "Message pinned on Matrix") discord.snow.channel.addChannelPinnedMessage(row.reference_channel_id, row.message_id, "Message pinned on Matrix")
} else { } else {
discord.snow.channel.deleteChannelPinnedMessage(row.reference_channel_id, row.message_id, "Message unpinned on Matrix") discord.snow.channel.removeChannelPinnedMessage(row.reference_channel_id, row.message_id, "Message unpinned on Matrix")
} }
} }
} }

View file

@ -1,12 +1,18 @@
// @ts-check // @ts-check
const Ty = require("../../types") const Ty = require("../../types")
const DiscordTypes = require("discord-api-types/v10")
const {Readable} = require("stream")
const assert = require("assert").strict const assert = require("assert").strict
const crypto = require("crypto")
const passthrough = require("../../passthrough") const passthrough = require("../../passthrough")
const {sync, db, select} = passthrough const {sync, discord, db, select} = passthrough
/** @type {import("../../discord/utils")} */ const {reg} = require("../../matrix/read-registration")
const dUtils = sync.require("../../discord/utils") /** @type {import("../../matrix/api")} */
const api = sync.require("../../matrix/api")
/** @type {import("../../matrix/utils")} */
const utils = sync.require("../../matrix/utils")
/** @type {import("../converters/poll-components")} */ /** @type {import("../converters/poll-components")} */
const pollComponents = sync.require("../converters/poll-components") const pollComponents = sync.require("../converters/poll-components")
/** @type {import("./channel-webhook")} */ /** @type {import("./channel-webhook")} */
@ -27,10 +33,9 @@ async function updateVote(event) {
// If poll was started on Matrix, the Discord version is using components, so we can update that to the current status // If poll was started on Matrix, the Discord version is using components, so we can update that to the current status
if (messageRow.source === 0) { if (messageRow.source === 0) {
const row = select("channel_room", ["channel_id", "thread_parent"], {room_id: event.room_id}).get() const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get()
assert(row) assert(channelID)
const {channelID, threadID} = dUtils.swapThreadID(row.channel_id, row.thread_parent) await webhook.editMessageWithWebhook(channelID, messageID, pollComponents.getPollComponentsFromDatabase(messageID))
await webhook.editMessageWithWebhook(channelID, messageID, pollComponents.getPollComponentsFromDatabase(messageID), threadID)
} }
} }

View file

@ -10,7 +10,6 @@ const domino = require("domino")
const assert = require("assert").strict const assert = require("assert").strict
const entities = require("entities") const entities = require("entities")
const pb = require("prettier-bytes") const pb = require("prettier-bytes")
const mimeTypes = require("mime-types")
const {tag} = require("@cloudrac3r/html-template-tag") const {tag} = require("@cloudrac3r/html-template-tag")
const passthrough = require("../../passthrough") const passthrough = require("../../passthrough")
@ -30,10 +29,6 @@ const pollComponents = sync.require("./poll-components")
const setupEmojis = sync.require("../actions/setup-emojis") const setupEmojis = sync.require("../actions/setup-emojis")
/** @type {import("../../d2m/converters/user-to-mxid")} */ /** @type {import("../../d2m/converters/user-to-mxid")} */
const userToMxid = sync.require("../../d2m/converters/user-to-mxid") const userToMxid = sync.require("../../d2m/converters/user-to-mxid")
/** @type {import("../../web/routes/letter-avatar")} */
const letterAvatar = sync.require("../../web/routes/letter-avatar")
/** @type {import("../../d2m/actions/retrigger")} */
const retrigger = sync.require("../../d2m/actions/retrigger")
/** @type {[RegExp, string][]} */ /** @type {[RegExp, string][]} */
const markdownEscapes = [ const markdownEscapes = [
@ -100,6 +95,15 @@ turndownService.addRule("underline", {
} }
}) })
turndownService.addRule("blockquote", {
filter: "blockquote",
replacement: function (content) {
content = content.replace(/^\n+|\n+$/g, "")
content = content.replace(/^/gm, "> ")
return content
}
})
turndownService.addRule("spoiler", { turndownService.addRule("spoiler", {
filter: function (node, options) { filter: function (node, options) {
return node.tagName === "SPAN" && node.hasAttribute("data-mx-spoiler") return node.tagName === "SPAN" && node.hasAttribute("data-mx-spoiler")
@ -386,7 +390,7 @@ function linkEndOfMessageSpriteSheet(content) {
*/ */
async function handleRoomOrMessageLinks(input, di) { async function handleRoomOrMessageLinks(input, di) {
let offset = 0 let offset = 0
for (const match of [...input.matchAll(/("?https:\/\/matrix.to\/#\/((?:#|%23|!|%21)[^"/, ?)]+)(?:\/((?:\$|%24)[^"/ ?)]+))?(?:\?[^",:!? )]*?)?)(">|[,<\n )]|$)/g)]) { for (const match of [...input.matchAll(/("?https:\/\/matrix.to\/#\/((?:#|%23|!)[^"/, ?)]+)(?:\/(\$[^"/ ?)]+))?(?:\?[^",:!? )]*?)?)(">|[,<\n )]|$)/g)]) {
assert(typeof match.index === "number") assert(typeof match.index === "number")
let [_, attributeValue, roomID, eventID, endMarker] = match let [_, attributeValue, roomID, eventID, endMarker] = match
let result let result
@ -467,6 +471,7 @@ async function checkWrittenMentions(content, senderMxid, roomID, guild, di) {
// @ts-ignore - typescript doesn't know about indices yet // @ts-ignore - typescript doesn't know about indices yet
content: content.slice(0, writtenMentionMatch.indices[1][0]-1) + `@everyone` + content.slice(writtenMentionMatch.indices[1][1]), content: content.slice(0, writtenMentionMatch.indices[1][0]-1) + `@everyone` + content.slice(writtenMentionMatch.indices[1][1]),
ensureJoined: [], ensureJoined: [],
/**@type {DiscordTypes.AllowedMentionsTypes[]}*/ // @ts-ignore - TypeScript is for whatever reason conviced that "everyone" cannot be assigned to AllowedMentionsTypes, but if you „Go to Definition”, you'll see that "everyone" is a valid enum value.
allowedMentionsParse: ["everyone"], allowedMentionsParse: ["everyone"],
allowedMentionsUsers: [] allowedMentionsUsers: []
} }
@ -511,7 +516,7 @@ function getFileSizeForGuild(guild) {
if (fileSizeFeature) { if (fileSizeFeature) {
return fileSizeFeature * 1024 * 1024 // discord uses big megabytes return fileSizeFeature * 1024 * 1024 // discord uses big megabytes
} else { } else {
return 20 * 1024 * 1024 // default file size is 20 MB return 10 * 1024 * 1024 // default file size is 10 MB
} }
} }
@ -541,6 +546,7 @@ async function getL1L2ReplyLine(called = false) {
async function eventToMessage(event, guild, channel, di) { async function eventToMessage(event, guild, channel, di) {
let displayName = event.sender let displayName = event.sender
let avatarURL = undefined let avatarURL = undefined
/**@type {DiscordTypes.AllowedMentionsTypes[]}*/ // @ts-ignore - TypeScript is for whatever reason conviced that neither "users" no "roles" cannot be assigned to AllowedMentionsTypes, but if you „Go to Definition”, you'll see that both are valid enum values.
const allowedMentionsParse = ["users", "roles"] const allowedMentionsParse = ["users", "roles"]
const allowedMentionsUsers = [] const allowedMentionsUsers = []
/** @type {string[]} */ /** @type {string[]} */
@ -578,13 +584,6 @@ async function eventToMessage(event, guild, channel, di) {
displayNameRunoff = "" displayNameRunoff = ""
} }
// Avatar post-processing. Use a thumbnail for media, or generate letter avatar if none present.
if (avatarURL) {
avatarURL = avatarURL + "?preset=avatar"
} else {
avatarURL = letterAvatar.getLetterAvatarURL(event.sender, displayNameShortened)
}
let content = event.content["body"] || "" // ultimate fallback let content = event.content["body"] || "" // ultimate fallback
/** @type {{id: string, filename: string}[]} */ /** @type {{id: string, filename: string}[]} */
const attachments = [] const attachments = []
@ -616,20 +615,13 @@ async function eventToMessage(event, guild, channel, di) {
captionContent.addLine(`(Spoiler: ${fileSpoilerReason})`) captionContent.addLine(`(Spoiler: ${fileSpoilerReason})`)
} }
// If filename is not specified, create one from mimetype
let filename = event.content.filename || event.content.body
if (!filename) {
const extension = mimeTypes.extension(event.content.info?.mimetype)
if (!extension) throw new Error("Filename and mimetype missing. Please report this bug to your client.")
filename = `file.${extension}`
}
// File link as alternative to uploading // File link as alternative to uploading
if (!("file" in event.content) && event.content.info?.size > getFileSizeForGuild(guild)) { if (!("file" in event.content) && event.content.info?.size > getFileSizeForGuild(guild)) {
// Upload (unencrypted) file as link, because it's too large for Discord // Upload (unencrypted) file as link, because it's too large for Discord
// Do this by constructing a sample Matrix message with the link and then use the text processor to convert that + the original caption. // Do this by constructing a sample Matrix message with the link and then use the text processor to convert that + the original caption.
const url = mxUtils.getPublicUrlForMxc(event.content.url) const url = mxUtils.getPublicUrlForMxc(event.content.url)
assert(url) assert(url)
const filename = event.content.filename || event.content.body
const emoji = attachmentEmojis.has(event.content.msgtype) ? attachmentEmojis.get(event.content.msgtype) + " " : "" const emoji = attachmentEmojis.has(event.content.msgtype) ? attachmentEmojis.get(event.content.msgtype) + " " : ""
if (fileIsSpoiler) { if (fileIsSpoiler) {
captionContent.addLine(`${emoji}Uploaded SPOILER file: <${url}> (${pb(event.content.info.size)})`, tag`${emoji}<em>Uploaded <strong>SPOILER</strong> file: <span data-mx-spoiler><a href="${url} ">${filename}</a></span> (${pb(event.content.info.size)})</em>`) // the space is necessary to work around a bug in Discord's URL previewer. the preview still gets blurred in the client. captionContent.addLine(`${emoji}Uploaded SPOILER file: <${url}> (${pb(event.content.info.size)})`, tag`${emoji}<em>Uploaded <strong>SPOILER</strong> file: <span data-mx-spoiler><a href="${url} ">${filename}</a></span> (${pb(event.content.info.size)})</em>`) // the space is necessary to work around a bug in Discord's URL previewer. the preview still gets blurred in the client.
@ -638,6 +630,7 @@ async function eventToMessage(event, guild, channel, di) {
} }
} else { } else {
// Upload file as file // Upload file as file
let filename = event.content.filename || event.content.body
if (fileIsSpoiler) filename = "SPOILER_" + filename if (fileIsSpoiler) filename = "SPOILER_" + filename
if ("file" in event.content) { if ("file" in event.content) {
// Encrypted // Encrypted
@ -698,7 +691,6 @@ async function eventToMessage(event, guild, channel, di) {
// Check if we have a pointer to what was edited // Check if we have a pointer to what was edited
const originalEventId = relatesTo.event_id const originalEventId = relatesTo.event_id
if (!originalEventId) return if (!originalEventId) return
if (!await retrigger.waitForEvent(originalEventId)) return
messageIDsToEdit = select("event_message", "message_id", {event_id: originalEventId}, "ORDER BY part").pluck().all() messageIDsToEdit = select("event_message", "message_id", {event_id: originalEventId}, "ORDER BY part").pluck().all()
if (!messageIDsToEdit.length) return if (!messageIDsToEdit.length) return
@ -776,14 +768,14 @@ async function eventToMessage(event, guild, channel, di) {
repliedToEvent.content = repliedToEvent.content["m.new_content"] repliedToEvent.content = repliedToEvent.content["m.new_content"]
} }
/** @type {string} */ /** @type {string} */
let originalRepliedToContent = repliedToEvent.content.formatted_body || repliedToEvent.content.body let repliedToContent = repliedToEvent.content.formatted_body || repliedToEvent.content.body
const fileReplyContentAlternative = attachmentEmojis.get(repliedToEvent.content.msgtype) const fileReplyContentAlternative = attachmentEmojis.get(repliedToEvent.content.msgtype)
let contentPreview let contentPreview
if (fileReplyContentAlternative) { if (fileReplyContentAlternative) {
contentPreview = " " + fileReplyContentAlternative contentPreview = " " + fileReplyContentAlternative
} else if (repliedToEvent.unsigned?.redacted_because) { } else if (repliedToEvent.unsigned?.redacted_because) {
contentPreview = " (in reply to a deleted message)" contentPreview = " (in reply to a deleted message)"
} else if (typeof originalRepliedToContent !== "string") { } else if (typeof repliedToContent !== "string") {
// in reply to a weird metadata event like m.room.name, m.room.member... // in reply to a weird metadata event like m.room.name, m.room.member...
// I'm not implementing text fallbacks for arbitrary room events. this should cover most cases // I'm not implementing text fallbacks for arbitrary room events. this should cover most cases
// this has never ever happened in the wild anyway // this has never ever happened in the wild anyway
@ -791,7 +783,6 @@ async function eventToMessage(event, guild, channel, di) {
contentPreview = " (channel details edited)" contentPreview = " (channel details edited)"
} else { } else {
// Generate a reply preview for a standard message // Generate a reply preview for a standard message
let repliedToContent = originalRepliedToContent
repliedToContent = repliedToContent.replace(/.*<\/mx-reply>/s, "") // Remove everything before replies, so just use the actual message body repliedToContent = repliedToContent.replace(/.*<\/mx-reply>/s, "") // Remove everything before replies, so just use the actual message body
repliedToContent = repliedToContent.replace(/^\s*<blockquote>.*?<\/blockquote>(.....)/s, "$1") // If the message starts with a blockquote, don't count it and use the message body afterwards repliedToContent = repliedToContent.replace(/^\s*<blockquote>.*?<\/blockquote>(.....)/s, "$1") // If the message starts with a blockquote, don't count it and use the message body afterwards
repliedToContent = repliedToContent.replace(/(?:\n|<br ?\/?>)+/g, " ") // Should all be on one line repliedToContent = repliedToContent.replace(/(?:\n|<br ?\/?>)+/g, " ") // Should all be on one line
@ -806,18 +797,7 @@ async function eventToMessage(event, guild, channel, di) {
const contentPreviewChunks = chunk(repliedToContent, 50) const contentPreviewChunks = chunk(repliedToContent, 50)
if (contentPreviewChunks.length) { if (contentPreviewChunks.length) {
contentPreview = ": " + contentPreviewChunks[0] contentPreview = ": " + contentPreviewChunks[0]
contentPreview = contentPreview.replace(/\bhttps?:\/\/[^ )]*/g, url => { contentPreview = contentPreview.replace(/\bhttps?:\/\/[^ )]*/g, "<$&>")
const originalUrlIndex = originalRepliedToContent.indexOf(url)
if (originalUrlIndex !== -1 && originalRepliedToContent[originalUrlIndex + url.length]?.match(/[^ )>"'`]/)) { // URL was truncated by chunking, replace it
try {
const u = new URL(url)
return `<via ${u.hostname}>`
} catch (e) {
return `<url>`
}
}
return `<${url}>` // Full URL is present, escape it
})
if (contentPreviewChunks.length > 1) contentPreview = contentPreview.replace(/[,.']$/, "") + "..." if (contentPreviewChunks.length > 1) contentPreview = contentPreview.replace(/[,.']$/, "") + "..."
} else { } else {
contentPreview = "" contentPreview = ""
@ -843,7 +823,7 @@ async function eventToMessage(event, guild, channel, di) {
} }
// Handling mentions of Discord users // Handling mentions of Discord users
input = input.replace(/("https:\/\/matrix.to\/#\/((?:@|%40)[^"]+)")/g, (whole, attributeValue, mxid) => { input = input.replace(/("https:\/\/matrix.to\/#\/((?:@|%40)[^"]+)")>/g, (whole, attributeValue, mxid) => {
mxid = decodeURIComponent(mxid) mxid = decodeURIComponent(mxid)
if (mxUtils.eventSenderIsFromDiscord(mxid)) { if (mxUtils.eventSenderIsFromDiscord(mxid)) {
// Handle mention of an OOYE sim user by their mxid // Handle mention of an OOYE sim user by their mxid
@ -868,7 +848,7 @@ async function eventToMessage(event, guild, channel, di) {
input = input.replace(/("https:\/\/matrix.to.*?<\/a>):?/g, "$1") input = input.replace(/("https:\/\/matrix.to.*?<\/a>):?/g, "$1")
// Element adds a bunch of <br> before </blockquote> but doesn't render them. I can't figure out how this even works in the browser, so let's just delete those. // Element adds a bunch of <br> before </blockquote> but doesn't render them. I can't figure out how this even works in the browser, so let's just delete those.
input = input.replace(/(?:\n|<br ?\/?>\s*)*<\/blockquote>(?:\n|<br ?\/?>\s*)*/g, "</blockquote>") input = input.replace(/(?:\n|<br ?\/?>\s*)*<\/blockquote>/g, "</blockquote>")
// The matrix spec hasn't decided whether \n counts as a newline or not, but I'm going to count it, because if it's in the data it's there for a reason. // The matrix spec hasn't decided whether \n counts as a newline or not, but I'm going to count it, because if it's in the data it's there for a reason.
// But I should not count it if it's between block elements. // But I should not count it if it's between block elements.
@ -903,12 +883,6 @@ async function eventToMessage(event, guild, channel, di) {
assert(root) assert(root)
async function forEachNode(event, node) { async function forEachNode(event, node) {
for (; node; node = node.nextSibling) { for (; node; node = node.nextSibling) {
// Don't process mx-reply further
if (node.nodeType === 1 && node.tagName === "MX-REPLY") {
node.remove()
continue
}
// Check written mentions // Check written mentions
if (node.nodeType === 3 && node.nodeValue.includes("@") && !nodeIsChildOf(node, ["A", "CODE", "PRE"])) { if (node.nodeType === 3 && node.nodeValue.includes("@") && !nodeIsChildOf(node, ["A", "CODE", "PRE"])) {
const result = await checkWrittenMentions(node.nodeValue, event.sender, event.room_id, guild, di) const result = await checkWrittenMentions(node.nodeValue, event.sender, event.room_id, guild, di)
@ -920,12 +894,9 @@ async function eventToMessage(event, guild, channel, di) {
} }
// Check for incompatible backticks in code blocks // Check for incompatible backticks in code blocks
let preNode let preNode
let isBackticksTextInPre = node.nodeType === 3 && node.nodeValue.includes("```") && (preNode = nodeIsChildOf(node, ["PRE"])) if (node.nodeType === 3 && node.nodeValue.includes("```") && (preNode = nodeIsChildOf(node, ["PRE"]))) {
let isLongPre = node.tagName === "PRE" && node.textContent.length > 1800 && (preNode = node)
if (isBackticksTextInPre || isLongPre) {
if (preNode.firstChild?.nodeName === "CODE") { if (preNode.firstChild?.nodeName === "CODE") {
let ext = preNode.firstChild.className.match(/language-(\S+)/)?.[1] const ext = preNode.firstChild.className.match(/language-(\S+)/)?.[1] || "txt"
if (!dUtils.supportedPlaintextPreviewExtensions.has(ext)) ext = "txt"
const filename = `inline_code.${ext}` const filename = `inline_code.${ext}`
// Build the replacement <code> node // Build the replacement <code> node
const replacementCode = doc.createElement("code") const replacementCode = doc.createElement("code")
@ -1054,11 +1025,11 @@ async function eventToMessage(event, guild, channel, di) {
// Combine requested mentions with detected written mentions to get the full list // Combine requested mentions with detected written mentions to get the full list
if (Array.isArray(event.content["m.mentions"].user_ids)) { if (Array.isArray(event.content["m.mentions"].user_ids)) {
for (const mxid of event.content["m.mentions"].user_ids) { for (const mxid of event.content["m.mentions"].user_ids) {
let user_id = select("sim", "user_id", {mxid}).pluck().get() const user_id = select("sim", "user_id", {mxid}).pluck().get()
if (!user_id) continue // only for discord users if (!user_id) continue
user_id = select("sim_proxy", "proxy_owner_id", {user_id}).pluck().get() || user_id // convert to sim proxy owner's ID allowedMentionsUsers.push(
if (!user_id.match(/^[0-9]+$/)) continue // mentioned sim does not have a real discord user ID (i.e. it's a webhook) select("sim_proxy", "proxy_owner_id", {user_id}).pluck().get() || user_id
allowedMentionsUsers.push(user_id) )
} }
} }
// Specific mentions were requested, so do not parse users // Specific mentions were requested, so do not parse users

File diff suppressed because it is too large Load diff

View file

@ -34,7 +34,6 @@ function optionsToComponents(isClosed, pollOptions) {
/** @type {DiscordTypes.APIMessageTopLevelComponent[]} */ /** @type {DiscordTypes.APIMessageTopLevelComponent[]} */
return pollOptions.map(option => { return pollOptions.map(option => {
const medal = getMedal(topAnswers, option.count) const medal = getMedal(topAnswers, option.count)
const label = option.count === 1 ? `${option.count} vote\u2002\u200b` : `${option.count} votes`
return { return {
type: DiscordTypes.ComponentType.Container, type: DiscordTypes.ComponentType.Container,
components: [{ components: [{
@ -46,7 +45,7 @@ function optionsToComponents(isClosed, pollOptions) {
accessory: { accessory: {
type: DiscordTypes.ComponentType.Button, type: DiscordTypes.ComponentType.Button,
style: medal === "🥇" && isClosed ? DiscordTypes.ButtonStyle.Success : DiscordTypes.ButtonStyle.Secondary, style: medal === "🥇" && isClosed ? DiscordTypes.ButtonStyle.Success : DiscordTypes.ButtonStyle.Secondary,
label: label, label: option.count.toString(),
custom_id: `POLL_OPTION#${option.matrix_option}`, custom_id: `POLL_OPTION#${option.matrix_option}`,
disabled: isClosed disabled: isClosed
} }

View file

@ -24,8 +24,6 @@ const vote = sync.require("./actions/vote")
const matrixCommandHandler = sync.require("../matrix/matrix-command-handler") const matrixCommandHandler = sync.require("../matrix/matrix-command-handler")
/** @type {import("../matrix/utils")} */ /** @type {import("../matrix/utils")} */
const utils = sync.require("../matrix/utils") const utils = sync.require("../matrix/utils")
/** @type {import("../matrix/mreq")}) */
const mreq = sync.require("../matrix/mreq")
/** @type {import("../matrix/api")}) */ /** @type {import("../matrix/api")}) */
const api = sync.require("../matrix/api") const api = sync.require("../matrix/api")
/** @type {import("../d2m/actions/create-room")} */ /** @type {import("../d2m/actions/create-room")} */
@ -34,10 +32,6 @@ const createRoom = sync.require("../d2m/actions/create-room")
const roomUpgrade = require("../matrix/room-upgrade") const roomUpgrade = require("../matrix/room-upgrade")
/** @type {import("../d2m/actions/retrigger")} */ /** @type {import("../d2m/actions/retrigger")} */
const retrigger = sync.require("../d2m/actions/retrigger") const retrigger = sync.require("../d2m/actions/retrigger")
/** @type {import("../matrix/homeserver-status")} */
const homeserverStatus = sync.require("../matrix/homeserver-status")
/** @type {import("./actions/typing")} */
const typing = sync.require("./actions/typing")
const {reg} = require("../matrix/read-registration") const {reg} = require("../matrix/read-registration")
let lastReportedEvent = 0 let lastReportedEvent = 0
@ -100,11 +94,6 @@ function printError(type, source, e, payload) {
console.dir(payload, {depth: null}) console.dir(payload, {depth: null})
} }
/** @param {string} stack */
function cleanErrorStack(stack) {
return stack.replace(/(\/webhooks\/[0-9]+\/)[a-zA-Z0-9_-]+/g, "$1(redacted)")
}
/** /**
* @param {string} roomID * @param {string} roomID
* @param {"Discord" | "Matrix"} source * @param {"Discord" | "Matrix"} source
@ -145,7 +134,7 @@ async function sendError(roomID, source, type, e, payload) {
builder.addLine(errorIntroLine) builder.addLine(errorIntroLine)
// Where // Where
const stack = cleanErrorStack(stringifyErrorStack(e)) const stack = stringifyErrorStack(e)
builder.addLine(`Error trace:\n${stack}`, tag`<details><summary>Error trace</summary><pre>${stack}</pre></details>`) builder.addLine(`Error trace:\n${stack}`, tag`<details><summary>Error trace</summary><pre>${stack}</pre></details>`)
// How // How
@ -154,7 +143,7 @@ async function sendError(roomID, source, type, e, payload) {
// Send // Send
try { try {
const errorEventID = await 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: source.toLowerCase(), source: source.toLowerCase(),
@ -164,28 +153,17 @@ async function sendError(roomID, source, type, e, payload) {
user_ids: ["@cadence:cadence.moe"] user_ids: ["@cadence:cadence.moe"]
} }
}) })
// Add reaction indicating that errors may be retried } catch (e) {}
await api.sendEvent(roomID, "m.reaction", {
"m.relates_to": {
rel_type: "m.annotation",
event_id: errorEventID,
key: "🔁"
}
})
} catch (e) {
if (
e.toString().includes("fetch failed")
|| (e instanceof mreq.MatrixServerError && [502, 503].includes(e.httpStatus) && e.errcode === "CX_SERVER_ERROR")
) {
// Matrix homeserver is down (reverse proxy indicated failure)
homeserverStatus.homeserverStatus.setErrorWithPacket(payload)
}
}
} }
/**
* @param {string} type
* @param {(event: Ty.Event.Outer<any> & {type: any, redacts:any, state_key:any}, ...args: any)=>any} fn
*/
function guard(type, fn) { function guard(type, fn) {
return async function(event, ...args) { return async function(/** @type {Ty.Event.Outer<any>} */ event, /** @type {any} */ ...args) {
try { try {
// @ts-ignore
return await fn(event, ...args) return await fn(event, ...args)
} catch (e) { } catch (e) {
await sendError(event.room_id, "Matrix", type, e, event) await sendError(event.room_id, "Matrix", type, e, event)
@ -199,7 +177,6 @@ const errorRetrySema = new Semaphore()
* @param {Ty.Event.Outer<Ty.Event.M_Reaction>} reactionEvent * @param {Ty.Event.Outer<Ty.Event.M_Reaction>} reactionEvent
*/ */
async function onRetryReactionAdd(reactionEvent) { async function onRetryReactionAdd(reactionEvent) {
if (reactionEvent.sender === `@${reg.sender_localpart}:${reg.ooye.server_name}`) return // Don't respond to the bot's own indicative reaction
const roomID = reactionEvent.room_id const roomID = reactionEvent.room_id
await errorRetrySema.request(async () => { await errorRetrySema.request(async () => {
const event = await api.getEvent(roomID, reactionEvent.content["m.relates_to"]?.event_id) const event = await api.getEvent(roomID, reactionEvent.content["m.relates_to"]?.event_id)
@ -233,16 +210,66 @@ sync.addTemporaryListener(as, "type:m.room.message", guard("m.room.message",
*/ */
async event => { async event => {
if (utils.eventSenderIsFromDiscord(event.sender)) return if (utils.eventSenderIsFromDiscord(event.sender)) return
let processCommands = true
if (event.content["m.relates_to"]?.rel_type === "m.thread") {
const toRedact = event.room_id
const bridgedTo = utils.getThreadRoomFromThreadEvent(event.content["m.relates_to"].event_id)
processCommands = false
if (bridgedTo) event.room_id = bridgedTo;
else if (await bridgeThread(event)) {
api.redactEvent(toRedact, event.event_id)
event.content["m.relates_to"] = undefined
api.sendEvent(event.room_id, event.type, {...event.content, body: event.content.body+"\n ~ "+event.sender, formatted_body: event.content.formatted_body ? event.content.formatted_body+"<br> ~ "+event.sender :undefined })
}
}
const messageResponses = await sendEvent.sendEvent(event) const messageResponses = await sendEvent.sendEvent(event)
if (!messageResponses.length) return if (!messageResponses.length) return
if (event.type === "m.room.message" && event.content.msgtype === "m.text") {
// @ts-ignore if (event.type === "m.room.message" && event.content.msgtype === "m.text" && processCommands) {
await matrixCommandHandler.execute(event) await matrixCommandHandler.parseAndExecute(
// @ts-ignore - TypeScript doesn't know that the event.content.msgtype === "m.text" check ensures that event isn't of type Ty.Event.Outer_M_Room_Message_File (which, indeed, wouldn't fit here)
event
)
} }
retrigger.finishedBridging(event.event_id)
retrigger.messageFinishedBridging(event.event_id)
await api.ackEvent(event) await api.ackEvent(event)
})) }))
/**
* @param {Ty.Event.Outer_M_Room_Message | Ty.Event.Outer_M_Room_Message_File} event Used for determining the branching-point and the title; any relation data will be stripped and its room_id will mutate to the target thread-room, if one gets created.
* @returns {Promise<boolean>} whether a thread-room was created
*/
async function bridgeThread(event) {
/** @type {string} */ // @ts-ignore
const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get()
const channel = discord.channels.get(channelID)
const guildID = channel?.["guild_id"]
if (!guildID) return false; //Room not bridged? We don't care. It's a Matrix-native room, let Matrix users have standard Matrix-native threads there.
const eventID = event.content["m.relates_to"]?.event_id
if (!eventID) throw new Error("There was an event sent inside SOME Matrix thread, but it lacked any information as to what thread it actually was!"); //An „ugly error” is justified because if something like this DOES happen, then that means that it should be reported to us, as there is some broken client out there that we should account for.
const messageID = select("event_message", "message_id", {event_id: eventID}).pluck().get()
if (!messageID) return false; //Message not bridged? Too bad! Discord users will just see normal replies, and Matrix uses won't get a thread-room. We COULD technically create a "headless" thread on Discord side and bridge it to a new thread-room, but that comes with a whole host of complications on its own (notably: what do we do if the message gets bridged later (by reaction emoji), and then hypothetically gets its own thread; and: getThreadRoomFromThreadEvent will have to be much more complex than a simple DB call (probably a whole new DB table would have to be created, just to hold these Matrix-branched-but-headless-on-Discord threads) because the simple „MX event --(db)--> Discord Message --(Discord spec)--> Discord thread” relation would no longer hold true), which may not be worth it, as an unbridged message in a bridged channel is already an edge-case (so it seems pointless to introduce a whole bunch of edgier-cases that handling this edge-case "properly" would bring).
let name = event.content.body
if (name.startsWith("/thread ")) name = name.substring(8);
else name = (await api.getEvent(event.room_id, eventID)).content.body;
name = name.length < 100 ? name.replaceAll("\n", " ") : name.slice(0, 96).replaceAll("\n", " ") + "..."
try {
event.room_id = await createRoom.ensureRoom((await discord.snow.channel.createThreadWithMessage(channelID, messageID, {name})).id)
return true;
}
catch (e){
if (e.message?.includes("50024")) return false; //Tried to created a thread in a thread (see: https://docs.discord.com/developers/topics/opcodes-and-status-codes)? Too bad! Discord users will just see normal replies, and Matrix uses won't get a thread-room. Same case as for message-not-bridged, except there at least exists a HYPOTHETICAL solution (just one so unwieldly that it's nonsensical to dedicate resources to), wheres here I don't know what could possibly be done at all.
else throw e; //In here (unlike in matrix-command-handler.js), there are much fewer things that could "intentionally" go wrong (both thread double-creation and too-long names shouldn't be possible due to earlier checks). As such, if anything breaks, it should be reported to OOYE for further investigation, which the user should do when encountering an "ugly error" (if they follow the „every error should be reported” directive), so this is re-thrown as-is (no stacktrace-breaking exception wrapping) to be turned into such an "ugly error" upstream.
}
}
sync.addTemporaryListener(as, "type:m.sticker", guard("m.sticker", 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 * @param {Ty.Event.Outer_M_Sticker} event it is a m.sticker because that's what this listener is filtering for
@ -250,7 +277,7 @@ sync.addTemporaryListener(as, "type:m.sticker", guard("m.sticker",
async event => { async event => {
if (utils.eventSenderIsFromDiscord(event.sender)) return if (utils.eventSenderIsFromDiscord(event.sender)) return
const messageResponses = await sendEvent.sendEvent(event) const messageResponses = await sendEvent.sendEvent(event)
retrigger.finishedBridging(event.event_id) retrigger.messageFinishedBridging(event.event_id)
await api.ackEvent(event) await api.ackEvent(event)
})) }))
@ -529,21 +556,6 @@ async event => {
await createRoom.unbridgeChannel(channel, channel["guild_id"], "Encrypted rooms are not supported. This room was removed from the bridge.") await createRoom.unbridgeChannel(channel, channel["guild_id"], "Encrypted rooms are not supported. This room was removed from the bridge.")
})) }))
sync.addTemporaryListener(as, "ephemeral_type:m.typing", guard("m.typing",
/**
* @param {Ty.Event.Outer_M_Typing} event
*/
async event => {
const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get()
if (!channelID) return
if (event.content.user_ids.some(mxid => !utils.eventSenderIsFromDiscord(mxid))) {
typing.startTyping(channelID)
} else {
typing.stopTyping(channelID)
}
}))
module.exports.stringifyErrorStack = stringifyErrorStack module.exports.stringifyErrorStack = stringifyErrorStack
module.exports.cleanErrorStack = cleanErrorStack
module.exports.sendError = sendError module.exports.sendError = sendError
module.exports.printError = printError module.exports.printError = printError

View file

@ -1,7 +1,7 @@
// @ts-check // @ts-check
const {test} = require("supertape") const {test} = require("supertape")
const {stringifyErrorStack, cleanErrorStack} = require("./event-dispatcher") const {stringifyErrorStack} = require("./event-dispatcher")
test("stringify error stack: works", t => { test("stringify error stack: works", t => {
function a() { function a() {
@ -21,30 +21,3 @@ test("stringify error stack: works", t => {
t.match(str, /^ \[prop\]: 2.1$/m) t.match(str, /^ \[prop\]: 2.1$/m)
} }
}) })
test("clean error stack: removes webhook token", t => {
t.notMatch(
cleanErrorStack(`
DiscordAPIError: Service resource is being rate limited.
at fn (/var/home/cadence/out-of-your-element/node_modules/snowtransfer/src/RequestHandler.ts:591:13)
at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
at exports.RequestHandler.request (/var/home/cadence/out-of-your-element/node_modules/snowtransfer/src/RequestHandler.ts:546:17)
at WebhookMethods.executeWebhook (/var/home/cadence/out-of-your-element/node_modules/snowtransfer/src/methods/Webhook.ts:249:35)
at /var/home/cadence/out-of-your-element/src/m2d/actions/channel-webhook.js:65:31
at withWebhook (/var/home/cadence/out-of-your-element/src/m2d/actions/channel-webhook.js:47:9)
at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
at async Object.sendMessageWithWebhook (/var/home/cadence/out-of-your-element/src/m2d/actions/channel-webhook.js:64:17)
at async Object.sendEvent (/var/home/cadence/out-of-your-element/src/m2d/actions/send-event.js:132:27)
at async /var/home/cadence/out-of-your-element/src/m2d/event-dispatcher.js:208:27
at async AppService.<anonymous> (/var/home/cadence/out-of-your-element/src/m2d/event-dispatcher.js:162:11) {
[method]: "POST"
[path]: "/webhooks/1160903754728611841/pfRqHl9vVZImdqwWWSZxxH8T-JJMnauxroMnHsvC6ARA-3B9_STH_bnHB9pd7QQaUVCG"
[code]: 40062
[httpStatus]: 429
[request]: {"endpoint":"/webhooks/1160903754728611841/pfRqHl9vVZImdqwWWSZxxH8T-JJMnauxroMnHsvC6ARA-3B9_STH_bnHB9pd7QQaUVCG","method":"POST","dataType":"json","data":{"content":"https://discordstatus.com/#day\nOnly what discord tell us right now","allowed_mentions":{"parse":["roles"],"users":[]},"username":"lewri","avatar_url":"https://bridge.cadence.moe/download/matrix/matrix.org/URWwrtSUONGOYhfMsdUzcrir"}}
[response]: {}
[name]: "DiscordAPIError"`
),
/pfRqHl9v/
)
})

View file

@ -452,8 +452,7 @@ async function ping() {
headers: { headers: {
Authorization: `Bearer ${reg.as_token}` Authorization: `Bearer ${reg.as_token}`
}, },
body: "{}", body: "{}"
signal: AbortSignal.timeout(15e3)
}) })
const root = await res.json() const root = await res.json()
return { return {
@ -464,29 +463,17 @@ async function ping() {
} }
/** /**
* Given an mxc:// URL, and optional parameters for thumbnailing, get the file from the content repository. Returns res. * Given an mxc:// URL, and an optional height for thumbnailing, get the file from the content repository. Returns res.
*
* Note that Synapse currently doesn't support animated thumbnails: https://github.com/element-hq/synapse/pull/18831
* @see https://spec.matrix.org/v1.18/client-server-api/#get_matrixclientv1mediathumbnailservernamemediaid
* @param {string} mxc * @param {string} mxc
* @param {RequestInit & {thumbnail?: {height?: number | string, width?: number | string, animated?: boolean, method?: "crop" | "scale"}}} [init] * @param {RequestInit & {height?: number | string}} [init]
* @return {Promise<Response & {body: streamWeb.ReadableStream<Uint8Array>}>} * @return {Promise<Response & {body: streamWeb.ReadableStream<Uint8Array>}>}
*/ */
async function getMedia(mxc, init = {}) { async function getMedia(mxc, init = {}) {
init = {...init} const mediaParts = mxc?.match(/^mxc:\/\/([^/]+)\/(\w+)$/)
const mediaParts = mxc?.match(/^mxc:\/\/([^/]+)\/([a-zA-Z0-9_-]+)$/)
assert(mediaParts) assert(mediaParts)
const downloadOrThumbnail = init.height ? "thumbnail" : "download"
let route = "download" let url = `${mreq.baseUrl}/client/v1/media/${downloadOrThumbnail}/${mediaParts[1]}/${mediaParts[2]}`
let query = "" if (init.height) url += "?" + new URLSearchParams({height: String(init.height), width: String(init.height)})
if (init.thumbnail) {
route = "thumbnail"
query = "?" + new URLSearchParams(Object.keys(init.thumbnail).map(k => [k, String(init.thumbnail?.[k])]))
}
let url = `${mreq.baseUrl}/client/v1/media/${route}/${mediaParts[1]}/${mediaParts[2]}${query}`
const res = await fetch(url, { const res = await fetch(url, {
headers: { headers: {
Authorization: `Bearer ${reg.as_token}` Authorization: `Bearer ${reg.as_token}`

View file

@ -15,15 +15,10 @@ const IMAGE_SIZE = 1024
const inflight = new Map() const inflight = new Map()
/** /**
* @param {string} urlString * @param {string} url
*/ */
function _removeExpiryParams(urlString) { function _removeExpiryParams(url) {
const url = new URL(urlString) return url.replace(/\?(?:(?:ex|is|sg|hm)=[a-f0-9]+&?)*$/, "")
url.searchParams.delete("ex")
url.searchParams.delete("is")
url.searchParams.delete("sg")
url.searchParams.delete("hm")
return url.toString()
} }
/** /**

View file

@ -20,9 +20,3 @@ test("removeExpiryParams: rearranged params are removed", t => {
const result = file._removeExpiryParams(url) const result = file._removeExpiryParams(url)
t.equal(result, "https://cdn.discordapp.com/attachments/112760669178241024/1157363960518029322/image.png") t.equal(result, "https://cdn.discordapp.com/attachments/112760669178241024/1157363960518029322/image.png")
}) })
test("removeExpiryParams: works on media proxy and keeps quality", t => {
const url = "https://media.discordapp.net/attachments/1160894080998461480/1548665030898094240/855064e9-bdae-487a-a497-c662bd510487.png?ex=6aa7e234&is=6aa690b4&hm=7c153efcdd1feb1d36d0bb99955a2bfe12f26abea377fed02980521766f51620&format=jpeg"
const result = file._removeExpiryParams(url)
t.equal(result, "https://media.discordapp.net/attachments/1160894080998461480/1548665030898094240/855064e9-bdae-487a-a497-c662bd510487.png?format=jpeg")
})

View file

@ -1,134 +0,0 @@
// @ts-check
const assert = require("assert")
const Denque = require("denque")
const StateMachine = require("snowtransfer").StateMachine
const passthrough = require("../passthrough")
const {sync} = passthrough
/** @type {import("../d2m/discord-packets")} */
const discordPackets = sync.require("../d2m/discord-packets")
/** @type {import("../matrix/api")} */
const api = sync.require("../matrix/api")
const DEBUG_HOMESERVER_STATUS = false
function debugHomeserverStatus(message) {
if (DEBUG_HOMESERVER_STATUS) {
console.log(message)
}
}
const homeserverStatus = new class HomeserverStatus {
constructor() {
/** @private */
this.queue = new Denque()
/** @private */
this.pingInterval = undefined
/** @private */
this.sm = new StateMachine("online")
.defineState("online")
.defineState("checking", {
onEnter: [async () => {
const pingResult = await api.ping().catch(e => ({ok: false}))
if (pingResult.ok) {
this.sm.doTransition("check ok")
} else {
this.sm.doTransition("check fail")
}
}],
onLeave: [],
transitions: new Map()
})
.defineState("offline", {
onEnter: [() => {
this.pingInterval = setInterval(async () => {
const pingResult = await api.ping().catch(e => ({ok: false, status: "net", root: e.message}))
if (pingResult.ok) {
this.sm.doTransition("ping ok")
}
}, 15e3)
}],
onLeave: [() => {
clearInterval(this.pingInterval)
}],
transitions: new Map()
})
.defineState("recovering", {
onEnter: [async () => { // Drain queue.
while (!this.queue.isEmpty()) {
const packet = this.queue.peekFront() // same position as .shift()
debugHomeserverStatus(`homeserver status: ${new Date().toISOString()} dq packet ${packet.t} ${packet.d?.content}`)
await discordPackets.dispatchPacketToBridge(passthrough.discord, packet)
if (this.sm.currentStateName !== "recovering") return // got kicked out due to another error
this.queue.shift()
}
this.sm.doTransition("recovered")
}],
onLeave: [],
transitions: new Map()
})
.defineUniversalTransition("error", "offline")
.defineTransition("offline", "ping ok", "recovering")
.defineTransition("recovering", "recovered", "online")
.defineTransition("online", "check", "checking")
.defineTransition("checking", "check ok", "recovering")
.defineTransition("checking", "check fail", "offline")
this.sm.on("enter", st => debugHomeserverStatus(`homeserver status: ${st}`))
this.sm.setMaxListeners(101)
this.sm.freeze()
}
isRealTime() {
return this.sm.currentStateName === "online"
}
/** @param {boolean} forceCheck */
waitForOnline(forceCheck) {
const onlinePromise = new Promise(resolve => {
// Already online? Start check or just done
if (this.sm.currentStateName === "online") {
if (forceCheck) {
this.sm.doTransition("check")
} else {
return resolve(null)
}
}
// Checking or not online. Wait for online.
const onlineListener = stateName => {
if (stateName === "online") {
this.sm.removeListener("enter", onlineListener)
resolve(null)
}
}
this.sm.on("enter", onlineListener)
})
return onlinePromise
}
/**
* When offline or recovering, call this for incoming packets to queue them to be sent in order later.
*/
queuePacket(packet) {
assert(["offline", "recovering"].includes(this.sm.currentStateName))
this.queue.push(packet)
}
setErrorWithPacket(packet) {
const wasRecovering = this.sm.currentStateName === "recovering"
this.sm.doTransition("error")
if (!wasRecovering) { // if was recovering then packet is already in the right place in queue
this.queuePacket(packet)
}
}
}
module.exports.homeserverStatus = homeserverStatus

View file

@ -262,7 +262,64 @@ const commands = [{
}) })
} }
await discord.snow.channel.createThreadWithoutMessage(channelID, {type: 11, name: words.slice(1).join(" ")}) const relation = event.content["m.relates_to"]
let isFallingBack = false;
let branchedFromMxEvent = relation?.["m.in_reply_to"]?.event_id // By default, attempt to branch the thread from the message to which /thread was replying.
if (relation?.rel_type === "m.thread") branchedFromMxEvent = relation?.event_id // If /thread was sent inside a Matrix thread, attempt to branch the Discord thread from the message, which that Matrix thread already is branching from.
if (!branchedFromMxEvent){
branchedFromMxEvent = event.event_id // If /thread wasn't replying to anything (ie. branchedFromMxEvent was undefined at initial assignment), or if the event was somehow malformed (in such a way that that - one way or another - branchedFromMxEvent ended up being undefined, even if according to the spec it shouldn't), branch the thread from the /thread command-message that created it.
isFallingBack = true;
}
const branchedFromDiscordMessage = select("event_message", "message_id", {event_id: branchedFromMxEvent}).pluck().get()
if (words.length < 2){
if (isFallingBack) return api.sendEvent(event.room_id, "m.room.message", {
...ctx,
msgtype: "m.text",
body: "**`/thread` usage:**\nRun this command as `/thread [Thread Name]` to create a thread on Discord (and optionally Matrix, if one doesn't exist already). The message from which said thread will branch, is chosen based on the following rules:\n* If ran stand-alone (not as a reply, nor in a Matrix thread), the created thread will branch from the command-message itself. The `Thread Name` argument must be provided in this case, otherwise you get this help message.\n* If sent as a reply (outside a Matrix thread), the thread will branch from the message to which you replied.\n* If ran inside an existing Matrix thread (regardless of whether it's a reply or not), the created Discord thread will be branching from the same message as the Matrix thread already is.",
format: "org.matrix.custom.html",
formatted_body: "<strong><code>/thread</code> usage:</strong><br>Run this command as <code>/thread [Thread Name]</code> to create a thread on Discord (and optionally Matrix, if one doesn't exist already). The message from which said thread will branch, is chosen based on the following rules:<br><ul><li>If ran stand-alone (not as a reply, nor in a Matrix thread), the created thread will branch from the command-message itself. The <code>Thread Name</code> argument must be provided in this case, otherwise you get this help message.</li><li>If sent as a reply (outside a Matrix thread), the thread will branch from the message to which you replied.</li><li>If ran inside an existing Matrix thread (regardless of whether it's a reply or not), the created Discord thread will be branching from the same message as the Matrix thread already is.</li></ul>"
})
words[1] = (await api.getEvent(event.room_id, branchedFromMxEvent)).content.body.replaceAll("\n", " ")
words[1] = words[1].length < 100 ? words[1] : words[1].slice(0, 96) + "..."
}
try {
if (branchedFromDiscordMessage) return await discord.snow.channel.createThreadWithMessage(channelID, branchedFromDiscordMessage, {name: words.slice(1).join(" ")}) //can't just return the promise directly like in 99% of other cases here in commands, otherwise the error-handling below will not work
else {return api.sendEvent(event.room_id, "m.room.message", {
...ctx,
msgtype: "m.text",
body: "⚠️ Couldn't find a Discord representation of the message from which you're trying to branch this thread (event ID `"+branchedFromMxEvent+"` on Matrix), so it wasn't created. Either you ran this command on an unbridged message (one sent by this bot or one that failed to bridge due to a previous error), or this is an error on our side and should be reported.",
format: "org.matrix.custom.html",
formatted_body: "⚠️ Couldn't find a Discord representation of the message from which you're trying to branch this thread (event ID <code>"+branchedFromMxEvent+"</code> on Matrix), so it wasn't created. Either you ran this command on an unbridged message (one sent by this bot or one that failed to bridge due to a previous error), or this is an error on our side and should be reported."
})};
}
catch (e){
/**@type {string|undefined} */
let err = e.message // see: https://docs.discord.com/developers/topics/opcodes-and-status-codes
if (err?.includes("160004")) {
if (isFallingBack) throw e; //Discord claims that there already exists a thread for the message ran this command was ran on, but that doesn't make logical sense, as it doesn't seem like it was ran on any message. Either the Matrix client did something funny with reply/thread tags, or this is a logic error on our side. At any rate, this should be reported to OOYE for further investigation, which the user should do when encountering an „ugly error” (if they follow the „every error should be reported” directive), so this is re-thrown as-is (no stacktrace-breaking exception wrapping) to be turned into such an „ugly error” upstream.
const thread = mxUtils.getThreadRoomFromThreadEvent(branchedFromMxEvent)
return api.sendEvent(event.room_id, "m.room.message", {
...ctx,
msgtype: "m.text",
body: "There already exists a Discord thread for the message you ran this command on" + (thread ? " - you may join its bridged room here: https://matrix.to/#/"+thread+"?"+(await mxUtils.getViaServersQuery(thread, api)).toString() : ", so a new one cannot be crated. However, it seems like that thread isn't bridged to any Matrix rooms. Please ask the space/server admins to rectify this issue by creating the bridge. (If you're said admin and you can see that said bridge already exists, but this error message is still showing up, please report that as a bug.)")
})
}
if (err?.includes("50024")) return api.sendEvent(event.room_id, "m.room.message", {
...ctx,
msgtype: "m.text",
body: "You cannot create threads in a Discord channel of the type, to which this Matrix room is bridged to. Did you try to create a thread inside a thread?"
})
if (err?.includes("50035")) return api.sendEvent(event.room_id, "m.room.message", {
...ctx,
msgtype: "m.text",
body: "Specified thread name is too long - thread creation failed. Please yap a bit less in the title, the thread body is for that. ;)"
})
throw e //Some other error happened, one that OOYE didn't anticipate the possibility of? It should be reported to us for further investigation, which the user should do when encountering an „ugly error” (if they follow the „every error should be reported” directive), so this is re-thrown as-is (no stacktrace-breaking exception wrapping) to be turned into such an „ugly error” upstream.
}
} }
) )
}, { }, {
@ -296,7 +353,7 @@ const commands = [{
try { try {
var invite = await discord.snow.channel.createChannelInvite(channelID) var invite = await discord.snow.channel.createChannelInvite(channelID)
} catch (e) { } catch (e) {
if (e.message === "Missing Permissions") { if (e.message === `{"message": "Missing Permissions", "code": 50013}`) {
return api.sendEvent(event.room_id, "m.room.message", { return api.sendEvent(event.room_id, "m.room.message", {
...ctx, ...ctx,
msgtype: "m.text", msgtype: "m.text",
@ -321,8 +378,11 @@ const commands = [{
}] }]
/** @type {CommandExecute} */ /**
async function execute(event) { * @param {Ty.Event.Outer_M_Room_Message} event
* @returns {Promise<any>|undefined} the executed command's in-process promise or undefined if no command execution was performed
*/
function parseAndExecute(event) {
let realBody = event.content.body let realBody = event.content.body
while (realBody.startsWith("> ")) { while (realBody.startsWith("> ")) {
const i = realBody.indexOf("\n") const i = realBody.indexOf("\n")
@ -342,8 +402,8 @@ async function execute(event) {
const command = commands.find(c => c.aliases.includes(commandName)) const command = commands.find(c => c.aliases.includes(commandName))
if (!command) return if (!command) return
await command.execute(event, realBody, words) return command.execute(event, realBody, words)
} }
module.exports.execute = execute module.exports.parseAndExecute = parseAndExecute
module.exports.onReactionAdd = onReactionAdd module.exports.onReactionAdd = onReactionAdd

View file

@ -9,14 +9,9 @@ const {reg} = require("./read-registration.js")
const baseUrl = `${reg.ooye.server_origin}/_matrix` const baseUrl = `${reg.ooye.server_origin}/_matrix`
class MatrixServerError extends Error { class MatrixServerError extends Error {
/** constructor(data, opts) {
* @param {number} httpStatus}
*/
constructor(data, httpStatus, opts = {}) {
super(data.error || data.errcode) super(data.error || data.errcode)
this.data = data this.data = data
/** @type {number} */
this.httpStatus = httpStatus
/** @type {string} */ /** @type {string} */
this.errcode = data.errcode this.errcode = data.errcode
this.opts = opts this.opts = opts
@ -49,11 +44,11 @@ async function _convertBody(body) {
async function makeMatrixServerError(res, opts = {}) { async function makeMatrixServerError(res, opts = {}) {
delete opts.headers?.["Authorization"] delete opts.headers?.["Authorization"]
if (res.headers.get("content-type") === "application/json") { if (res.headers.get("content-type") === "application/json") {
return new MatrixServerError(await res.json(), res.status, opts) return new MatrixServerError(await res.json(), opts)
} else if (res.headers.get("content-type")?.startsWith("text/")) { } else if (res.headers.get("content-type")?.startsWith("text/")) {
return new MatrixServerError({errcode: "CX_SERVER_ERROR", error: `Server returned HTTP status ${res.status}`, message: await res.text()}, res.status, opts) return new MatrixServerError({errcode: "CX_SERVER_ERROR", error: `Server returned HTTP status ${res.status}`, message: await res.text()}, opts)
} else { } else {
return new MatrixServerError({errcode: "CX_SERVER_ERROR", error: `Server returned HTTP status ${res.status}`, content_type: res.headers.get("content-type")}, res.status, opts) return new MatrixServerError({errcode: "CX_SERVER_ERROR", error: `Server returned HTTP status ${res.status}`, content_type: res.headers.get("content-type")}, opts)
} }
} }
@ -83,12 +78,12 @@ async function mreq(method, url, bodyIn, extra = {}) {
var root = JSON.parse(text) var root = JSON.parse(text)
} catch (e) { } catch (e) {
delete opts.headers?.["Authorization"] delete opts.headers?.["Authorization"]
throw new MatrixServerError(text, res.status, {baseUrl, url, ...opts}) throw new MatrixServerError(text, {baseUrl, url, ...opts})
} }
if (!res.ok || root.errcode) { if (!res.ok || root.errcode) {
delete opts.headers?.["Authorization"] delete opts.headers?.["Authorization"]
throw new MatrixServerError(root, res.status, {baseUrl, url, ...opts}) throw new MatrixServerError(root, {baseUrl, url, ...opts})
} }
return root return root
} }

View file

@ -17,10 +17,6 @@ function checkRegistration(reg) {
assert(reg.ooye?.server_origin.match(/^https?:\/\//), "server origin must start with http or https") 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") assert.notEqual(reg.ooye?.server_origin.slice(-1), "/", "server origin must not end in slash")
assert.match(reg.url, /^https?:/, "url must start with http:// or https://") assert.match(reg.url, /^https?:/, "url must start with http:// or https://")
if (!reg.receive_ephemeral) {
reg.receive_ephemeral = true
writeRegistration(reg)
}
} }
/* c8 ignore next 4 */ /* c8 ignore next 4 */
@ -54,7 +50,6 @@ function getTemplateRegistration(serverName) {
], ],
sender_localpart: `${namespace_prefix}bot`, sender_localpart: `${namespace_prefix}bot`,
rate_limited: false, rate_limited: false,
receive_ephemeral: true,
socket: 6693, socket: 6693,
ooye: { ooye: {
namespace_prefix, namespace_prefix,
@ -83,14 +78,10 @@ function readRegistration() {
/** @type {import("../types").AppServiceRegistrationConfig} */ // @ts-ignore /** @type {import("../types").AppServiceRegistrationConfig} */ // @ts-ignore
let reg = readRegistration() let reg = readRegistration()
if (reg) { fs.watch(registrationFilePath, {persistent: false}, () => {
fs.watch(registrationFilePath, {persistent: false}, () => { let newReg = readRegistration()
let newReg = readRegistration() Object.assign(reg, newReg)
if (newReg) { })
Object.assign(reg, newReg)
}
})
}
module.exports.registrationFilePath = registrationFilePath module.exports.registrationFilePath = registrationFilePath
module.exports.readRegistration = readRegistration module.exports.readRegistration = readRegistration

View file

@ -4,7 +4,7 @@ const assert = require("assert").strict
const Ty = require("../types") const Ty = require("../types")
const {tag} = require("@cloudrac3r/html-template-tag") const {tag} = require("@cloudrac3r/html-template-tag")
const passthrough = require("../passthrough") const passthrough = require("../passthrough")
const {db} = passthrough const {db, select} = passthrough
const {reg} = require("./read-registration") const {reg} = require("./read-registration")
const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex)) const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex))
@ -250,7 +250,7 @@ function getPublicUrlForMxc(mxc) {
*/ */
function makeMxcPublic(mxc) { function makeMxcPublic(mxc) {
assert(hasher, "xxhash is not ready yet") assert(hasher, "xxhash is not ready yet")
const mediaParts = mxc?.match(/^mxc:\/\/([^/]+)\/([a-zA-Z0-9_-]+)$/) const mediaParts = mxc?.match(/^mxc:\/\/([^/]+)\/(\w+)$/)
if (!mediaParts) return undefined if (!mediaParts) return undefined
const serverAndMediaID = `${mediaParts[1]}/${mediaParts[2]}` const serverAndMediaID = `${mediaParts[1]}/${mediaParts[2]}`
@ -385,6 +385,16 @@ async function setUserPowerCascade(spaceID, mxid, power, api) {
} }
} }
/**
* @param {undefined|string?} eventID
*/ //^For some reason, ? doesn't include Undefined and it needs to be explicitly specified
function getThreadRoomFromThreadEvent(eventID){
if (!eventID) return eventID;
const threadID = select("event_message", "message_id", {event_id: eventID}).pluck().get() //Discord thread ID === its message ID
if (!threadID) return threadID;
return select("channel_room", "room_id", {channel_id: threadID}).pluck().get()
}
module.exports.bot = bot module.exports.bot = bot
module.exports.BLOCK_ELEMENTS = BLOCK_ELEMENTS module.exports.BLOCK_ELEMENTS = BLOCK_ELEMENTS
module.exports.eventSenderIsFromDiscord = eventSenderIsFromDiscord module.exports.eventSenderIsFromDiscord = eventSenderIsFromDiscord
@ -400,3 +410,4 @@ module.exports.removeCreatorsFromPowerLevels = removeCreatorsFromPowerLevels
module.exports.getEffectivePower = getEffectivePower module.exports.getEffectivePower = getEffectivePower
module.exports.setUserPower = setUserPower module.exports.setUserPower = setUserPower
module.exports.setUserPowerCascade = setUserPowerCascade module.exports.setUserPowerCascade = setUserPowerCascade
module.exports.getThreadRoomFromThreadEvent = getThreadRoomFromThreadEvent

View file

@ -2,7 +2,7 @@
const {select} = require("../passthrough") const {select} = require("../passthrough")
const {test} = require("supertape") const {test} = require("supertape")
const {eventSenderIsFromDiscord, getEventIDHash, MatrixStringBuilder, getViaServers, roomHasAtLeastVersion, removeCreatorsFromPowerLevels, setUserPower} = require("./utils") const {eventSenderIsFromDiscord, getEventIDHash, MatrixStringBuilder, getViaServers, roomHasAtLeastVersion, removeCreatorsFromPowerLevels, setUserPower, getThreadRoomFromThreadEvent} = require("./utils")
const util = require("util") const util = require("util")
/** @param {string[]} mxids */ /** @param {string[]} mxids */
@ -417,4 +417,38 @@ test("set user power: privileged users must demote themselves", async t => {
t.equal(called, 3) t.equal(called, 3)
}) })
test("getThreadRoomFromThreadEvent: real message, but without a thread", t => {
const room = getThreadRoomFromThreadEvent("$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4")
const msg = "Expected null/undefined, got: "+room
if(room) t.fail(msg);
else t.pass(msg)
})
test("getThreadRoomFromThreadEvent: real message with a thread", t => {
const room = getThreadRoomFromThreadEvent("$fdD9o7NxMA4VPexlAiIx2CB9JbsiGhJeyJgnZG7U5xg")
t.equal(room, "!FuDZhlOAtqswlyxzeR:cadence.moe")
})
test("getThreadRoomFromThreadEvent: fake message", t => {
const room = getThreadRoomFromThreadEvent("$ThisEvent-IdDoesNotExistInTheDatabase4Sure")
const msg = "Expected null/undefined, got: "+room
if(room) t.fail(msg);
else t.pass(msg)
})
test("getThreadRoomFromThreadEvent: null", t => {
const room = getThreadRoomFromThreadEvent(null)
t.equal(room, null)
})
test("getThreadRoomFromThreadEvent: undefined", t => {
const room = getThreadRoomFromThreadEvent(undefined)
t.equal(room, undefined)
})
test("getThreadRoomFromThreadEvent: no value at all", t => {
const room = getThreadRoomFromThreadEvent() //This line should be giving a type-error, so it's not @ts-ignored on purpose. This is to test the desired behavior of that function, ie. „it CAN TAKE an undefined VALUE (as tested above), but you can just LEAVE the value completely undefined” (well, you can leave it like that from JS syntax perspective (which is why this test passes), but it makes no sense from usage standpoint, as it just gives back undefined). So this isn't a logic test (that's handled above), as much as it is a TypeScript test.
t.equal(room, undefined)
})
module.exports.mockGetEffectivePower = mockGetEffectivePower module.exports.mockGetEffectivePower = mockGetEffectivePower

View file

@ -15,15 +15,12 @@ const mreq = sync.require("./matrix/mreq")
const api = sync.require("./matrix/api") const api = sync.require("./matrix/api")
const file = sync.require("./matrix/file") const file = sync.require("./matrix/file")
const sendEvent = sync.require("./m2d/actions/send-event") const sendEvent = sync.require("./m2d/actions/send-event")
const redact = sync.require("./m2d/actions/redact")
const eventDispatcher = sync.require("./d2m/event-dispatcher") const eventDispatcher = sync.require("./d2m/event-dispatcher")
const updatePins = sync.require("./d2m/actions/update-pins") const updatePins = sync.require("./d2m/actions/update-pins")
const speedbump = sync.require("./d2m/actions/speedbump") const speedbump = sync.require("./d2m/actions/speedbump")
const ks = sync.require("./matrix/kstate") const ks = sync.require("./matrix/kstate")
const setPresence = sync.require("./d2m/actions/set-presence") const setPresence = sync.require("./d2m/actions/set-presence")
const channelWebhook = sync.require("./m2d/actions/channel-webhook") const channelWebhook = sync.require("./m2d/actions/channel-webhook")
const dUtils = sync.require("./discord/utils")
const mxUtils = sync.require("./matrix/utils")
const guildID = "112760669178241024" const guildID = "112760669178241024"
async function ping() { async function ping() {

55
src/types.d.ts vendored
View file

@ -18,7 +18,6 @@ export type AppServiceRegistrationConfig = {
} }
protocols: [string] protocols: [string]
rate_limited: boolean rate_limited: boolean
receive_ephemeral: boolean
socket?: string | number, socket?: string | number,
ooye: { ooye: {
namespace_prefix: string namespace_prefix: string
@ -36,8 +35,6 @@ export type AppServiceRegistrationConfig = {
web_password: string web_password: string
time_zone?: string time_zone?: string
receive_presences: boolean receive_presences: boolean
plu_ral_api_key?: string
bridge_admin_contact?: string
} }
old_bridge?: { old_bridge?: {
as_token: string as_token: string
@ -62,7 +59,6 @@ export type InitialAppServiceRegistrationConfig = {
} }
protocols: [string] protocols: [string]
rate_limited: boolean rate_limited: boolean
receive_ephemeral: boolean
socket?: string | number, socket?: string | number,
ooye: { ooye: {
namespace_prefix: string namespace_prefix: string
@ -126,28 +122,6 @@ export type PkMessage = {
sender: string sender: string
} }
export type PluRalWebhookMessage = {
original_id: string | null
proxy_id: string
author_id: string
channel_id: string
member_id: string
reason: string
webhook_id: string
member: PluRalMember
}
export type PluRalMember = {
id: string
name: string
pronouns: string
bio: string
birthday: string
color: number | null
avatar_url: string
private: boolean
}
export namespace Event { export namespace Event {
export type Outer<T> = { export type Outer<T> = {
type: string type: string
@ -216,11 +190,12 @@ export namespace Event {
format?: "org.matrix.custom.html" format?: "org.matrix.custom.html"
formatted_body?: string, formatted_body?: string,
"m.relates_to"?: { "m.relates_to"?: {
"m.in_reply_to": { event_id?: string
is_falling_back?: boolean
"m.in_reply_to"?: {
event_id: string event_id: string
} }
rel_type?: "m.replace" rel_type?: "m.replace"|"m.thread"
event_id?: string
} }
} }
@ -236,11 +211,12 @@ export namespace Event {
info?: any info?: any
"page.codeberg.everypizza.msc4193.spoiler"?: boolean "page.codeberg.everypizza.msc4193.spoiler"?: boolean
"m.relates_to"?: { "m.relates_to"?: {
"m.in_reply_to": { event_id?: string
is_falling_back?: boolean
"m.in_reply_to"?: {
event_id: string event_id: string
} }
rel_type?: "m.replace" rel_type?: "m.replace"|"m.thread"
event_id?: string
} }
} }
@ -272,11 +248,12 @@ export namespace Event {
}, },
info?: any info?: any
"m.relates_to"?: { "m.relates_to"?: {
"m.in_reply_to": { event_id?: string
is_falling_back?: boolean
"m.in_reply_to"?: {
event_id: string event_id: string
} }
rel_type?: "m.replace" rel_type?: "m.replace"|"m.thread"
event_id?: string
} }
} }
@ -422,14 +399,6 @@ export namespace Event {
rotation_period_ms?: number rotation_period_ms?: number
rotation_period_msgs?: number rotation_period_msgs?: number
} }
export type Outer_M_Typing = {
content: {
user_ids: string[]
}
room_id: string
type: "m.typing"
}
} }
export namespace R { export namespace R {

View file

@ -23,7 +23,7 @@ async function getManagedGuilds(event) {
/** /**
* @param {h3.H3Event} event * @param {h3.H3Event} event
* @returns {ReturnType<typeof h3.useSession<{userID?: string, mxid?: string, managedGuilds?: string[], state?: string, selfService?: boolean, next?: string, password?: string}>>} * @returns {ReturnType<typeof h3.useSession<{userID?: string, mxid?: string, managedGuilds?: string[], state?: string, selfService?: boolean, password?: string}>>}
*/ */
function useSession(event) { function useSession(event) {
return h3.useSession(event, {password: reg.as_token, maxAge: 365 * 24 * 60 * 60}) return h3.useSession(event, {password: reg.as_token, maxAge: 365 * 24 * 60 * 60})

View file

@ -0,0 +1,24 @@
extends includes/template.pug
block body
h1.ta-center.fs-display2.fc-green-400 April Fools!
.ws7.m-auto
.s-prose.fs-body2
p Sheesh, wouldn't that be horrible?
if guild_id
p Fake AI messages have now been #[strong.fc-green-600 deactivated for everyone in your server.]
p Hope the prank entertained you. #[a(href="https://cadence.moe/contact") Send love or hate mail here.]
h2 What actually happened?
ul
li A secret event was added for the duration of 1st April 2026 (UTC).
li If a message matches a preset pattern, a preset response is posted to chat by an AI-ified profile of the previous author.
li It only happens at most once per hour in each server.
li I tried to design it to not interrupt any serious/sensitive topics. I am deeply sorry if that didn't work out.
li No AI generated materials have ever been used in Out Of Your Element: no code, no prose, no images, no jokes.
li It'll always deactivate itself on 2nd April, no matter what, and I'll remove the relevant code shortly after.
if guild_id
.s-prose.fl-grow1.mt16
p If you thought it was funny, feel free to opt back in. This affects the entire server, so please be courteous.
form(method="post" action=rel(`/agi/optin?guild_id=${guild_id}`))
button(type="submit").s-btn.s-btn__muted Opt back in

41
src/web/pug/agi.pug Normal file
View file

@ -0,0 +1,41 @@
extends includes/template.pug
block title
title AGI in Discord
block body
style.
.ai-gradient {
background: linear-gradient(100deg, #fb72f2, #072ea4);
color: transparent;
background-clip: text;
}
h1.ta-center.fs-display2.ai-gradient AGI in Discord:#[br]Revolutionizing the Future of Communications
.ws7.m-auto
.s-prose.fs-body2
p In the ever-changing and turbulent world of AI, it's crucial to always be one step ahead.
p That's why Out Of Your Element has partnered with #[strong Grimace AI] to provide you tomorrow's technology, today.
ul
li #[strong Always online:] Miss your friends when they log off? Now you can talk to facsimiles of them etched into an unfeeling machine, always and forever!
li #[strong Smarter than ever:] Pissed off when somebody says something #[em wrong] on the internet? Frustrated with having to stay up all night correcting them? With Grimace Truth (available in Pro+ Ultra plan), all information is certified true to reality, so you'll never have to worry about those frantic Google searches at 3 AM.
li #[strong Knows you better than yourself:] We aren't just training on your data; we're copying minds into our personality matrix — including yours. Do you find yourself enjoying the sound of your own voice more than anything else? Our unique simulation of You is here to help.
h1.mt64.mb32 Frequently Asked Questions
.s-link-preview
.s-link-preview--header.fd-column
.s-link-preview--title.fs-title.pl4 How to opt out?
.s-link-preview--details.fc-red-500
!= icons.Icons.IconFire
= ` 20,000% higher search volume for this question in the last hour`
.s-link-preview--body
.s-prose
h2.fs-body3 Is this really goodbye? 😢😢😢😢😢
p I can't convince you to stay?
p As not just a customer service representative but someone with a shared vision, I simply want you to know that everyone at Grimace AI will miss all the time that we've shared together with you.
form(method="post" action=(guild_id ? rel(`/agi/optout?guild_id=${guild_id}`) : rel("/agi/optout"))).d-flex.g4.mt16
button(type="button").s-btn.s-btn__filled Nevermind, I'll stay :)
button(type="submit").s-btn.s-btn__danger.s-btn__outlined Opt out for 3 days
div(style="height: 200px")

View file

@ -122,7 +122,7 @@ block body
#role-add.s-popover(popover style="display: revert").ws2.px0.py4.bs-lg.overflow-visible #role-add.s-popover(popover style="display: revert").ws2.px0.py4.bs-lg.overflow-visible
.s-popover--arrow.s-popover--arrow__tc .s-popover--arrow.s-popover--arrow__tc
+add-roles-menu(guild, guild_id) +add-roles-menu(guild, guild_id)
p.fc-light.mb0.mt8 Matrix users will start with these roles. If your main channels are gated by a role, use this to let Matrix users skip the gate. p.fc-medium.mb0.mt8 Matrix users will start with these roles. If your main channels are gated by a role, use this to let Matrix users skip the gate.
h3.mt32.fs-category Features h3.mt32.fs-category Features
.s-card.d-grid.px0.g16 .s-card.d-grid.px0.g16
@ -191,14 +191,14 @@ block body
label.s-btn.s-btn__muted.ta-left.truncate(for=channel.id) label.s-btn.s-btn__muted.ta-left.truncate(for=channel.id)
+discord(channel, true, "Announcement") +discord(channel, true, "Announcement")
else else
.s-empty-state.p8 No Discord channels available. .s-empty-state.p8 All Discord channels are linked.
.fl-grow1.s-btn-group.fd-column.w30 .fl-grow1.s-btn-group.fd-column.w30
each room in unlinkedRooms each room in unlinkedRooms
input.s-btn--radio(type="radio" name="matrix" required id=room.room_id value=room.room_id) input.s-btn--radio(type="radio" name="matrix" required id=room.room_id value=room.room_id)
label.s-btn.s-btn__muted.ta-left.truncate(for=room.room_id) label.s-btn.s-btn__muted.ta-left.truncate(for=room.room_id)
+matrix(room, true) +matrix(room, true)
else else
.s-empty-state.p8 No Matrix rooms available. .s-empty-state.p8 All Matrix rooms are linked.
input(type="hidden" name="guild_id" value=guild_id) input(type="hidden" name="guild_id" value=guild_id)
div div
button.s-btn.s-btn__icon.s-btn__filled#link-button button.s-btn.s-btn__icon.s-btn__filled#link-button

View file

@ -65,7 +65,8 @@ mixin define-themed-button(name, theme)
doctype html doctype html
html(lang="en") html(lang="en")
head head
title Out Of Your Element block title
title Out Of Your Element
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
link(rel="stylesheet" type="text/css" href=rel("/static/stacks.min.css")) link(rel="stylesheet" type="text/css" href=rel("/static/stacks.min.css"))
//- Please use responsibly!!!!! //- Please use responsibly!!!!!
@ -157,10 +158,6 @@ html(lang="en")
//- Body //- Body
.mx-auto.w100.wmx9.py24.px8.fs-body1#content .mx-auto.w100.wmx9.py24.px8.fs-body1#content
block body block body
if reg.ooye.bridge_admin_contact
aside.fs-body0.mt48.pt8.bt.bc-black-350.d-inline-block
a(href=rel("/privacy-policy")) Privacy policy
//- Guild list popover //- Guild list popover
script. script.
document.querySelectorAll("[popovertarget]").forEach(e => { document.querySelectorAll("[popovertarget]").forEach(e => {

View file

@ -1,34 +0,0 @@
extends includes/template.pug
block body
if !session.data.userID
.s-empty-state.wmx4.p48
!= icons.Spots.SpotKeyXL
p Please log in to access the opt-out page.
.d-flex.jc-center.g8
a.s-btn.s-btn__icon.s-btn__featured.s-btn__filled(href=rel("/oauth?next=/opt-out"))
!= icons.Icons.IconDiscord
= ` Log in with Discord`
else
.s-page-title.mb24
h1.s-page-title--header Opt Out
- let value = !select("opt_out", "user_id", {user_id: session.data.userID}).pluck().get()
noscript
.s-notice.s-notice__danger.mb16 You must enable JavaScript to use this form. Please do that now!
#opt-in-msg(hx-swap-oob="true")
if msg
.s-notice.s-notice__info.mb16= msg
else if !value
.s-notice.s-notice__warning.mb16 You are currently opted out.
.s-card.d-grid.px0.g16
form.d-flex.ai-center.g16
#opt-in-loading.p8
input.s-toggle-switch#opt-in(name="opt_in" type="checkbox" hx-post=rel("/api/opt-out") hx-indicator="#opt-in-loading" hx-disabled-elt="this" checked=value autocomplete="off" hx-swap="none")
label.s-label.fl-grow1(for="opt-in")
| Bridge my messages to Matrix
p.s-description Turn it off to opt out. This applies to all servers.

View file

@ -1,65 +0,0 @@
extends includes/template.pug
block body
.s-page-title.mb24
h1.s-page-title--header Privacy Policy
p.s-page-title--description Last updated 4 August 2026
.s-prose
h2 Summary
p Out Of Your Element bridges messages between Matrix and Discord. It does this by forwarding all the messages from one platform to the other. Your messages aren't used for any other purpose.
p Matrix and Discord will store these messages until you delete them, but Out Of Your Element doesn't keep its own copy.
h2 Important information about the Matrix network
p The public Matrix Network is a decentralised and openly federated communication network. This means that user messages are replicated on each participant's homeserver, and messages posted to a room are visible to all participants, including new joiners. Anybody can run their own homeserver, so your messages may be widely replicated onto many servers. Out Of Your Element does not control these servers.
p Out Of Your Element does endeavour to remove the corresponding data from all of these servers when you delete a particular message, opt out, or request erasure. Due to the nature of decentralised systems, deletion might not be immediate or thorough. For example, some servers could be offline and catch up later, or they may have backups of old data.
p Please keep this in mind when using chatrooms with Out Of Your Element, or any other bridge software.
h2 What data is collected
p Data provided by you in the chatroom: Your messages, edits, uploaded files, username, profile picture, emojis, reactions, commands used, online indicator, typing indicator, and generally any other information written in chat will be copied to the other platform. This happens in all chatrooms where you see the Out Of Your Element bot.
p There is also a non-identifiable #[a(href=rel("/api/stats")) counter of how many people and chatrooms are active].
h2 What data isn't collected
p No tracking here. Out Of Your Element #[strong does not] automatically access or share your IP address, device fingerprint, location, read receipts, or your real-world identity.
h2 How long data is stored for
p Out Of Your Element doesn't store your message content directly. It only stores non-identifiable metadata, such as message IDs and room IDs.
p However, the messages are stored forever by Discord and Matrix, or until you delete them. Your messages will remain in the chat even if you leave the chat later.
p Your online indicator and typing indicator are only stored briefly to show your real-time status. They can't be viewed as history.
h2 Who can access this data
ul
li Anybody participating in the chatroom (on either platform) can see your messages attributed to your username
li Discord, Inc., and their hosting providers, can access that too
li People running a Matrix homeserver in the room, and their hosting providers, can access that too (see #[a(href="https://matrix.org/legal/privacy-notice/") Matrix.org privacy policy])
li Your data isn't shared with any other third parties (no analytics or monitoring)
h2 How to control access to your data
p To remove data in a specific message, you can delete that message. Deletions are bridged across all platforms. Discord and Matrix will discard the message.
p However, we can't stop people in the chatroom from independently saving or sharing a screenshot of your messages.
h3 Broad opt-out for Discord account holders
p If you don't want any future messages to be bridged across platforms, #[a(href=rel("/opt-out")) opt out (of your element) here]. Keep in mind this could make conversations confusing because people will see different things.
p Your old messages will stick around, but if you want those deleted as well, please contact the operator !{reg.ooye.bridge_admin_contact} to request erasure.
h2 Questions
p Out Of Your Element is open source, freely available software that anyone can run. The person running this version you're using is probably a different person from the software author.
p For questions about #[em your] data and how #[em this] version handles it, please contact the operator !{reg.ooye.bridge_admin_contact} to discuss.
p For general questions and clarifications about how the software handles data more broadly, please contact #[a(href="https://cadence.moe/contact") the author of the Out Of Your Element software.]

36
src/web/routes/agi.js Normal file
View file

@ -0,0 +1,36 @@
// @ts-check
const {z} = require("zod")
const {defineEventHandler, getValidatedQuery, sendRedirect} = require("h3")
const {as, from, sync, db} = require("../../passthrough")
/** @type {import("../pug-sync")} */
const pugSync = sync.require("../pug-sync")
const schema = {
opt: z.object({
guild_id: z.string().regex(/^[0-9]+$/)
})
}
as.router.get("/agi", defineEventHandler(async event => {
return pugSync.render(event, "agi.pug", {})
}))
as.router.get("/agi/optout", defineEventHandler(async event => {
return pugSync.render(event, "agi-optout.pug", {})
}))
as.router.post("/agi/optout", defineEventHandler(async event => {
const parseResult = await getValidatedQuery(event, schema.opt.safeParse)
if (parseResult.success) {
db.prepare("INSERT OR IGNORE INTO agi_optout (guild_id) VALUES (?)").run(parseResult.data.guild_id)
}
return sendRedirect(event, "", 302)
}))
as.router.post("/agi/optin", defineEventHandler(async event => {
const {guild_id} = await getValidatedQuery(event, schema.opt.parse)
db.prepare("DELETE FROM agi_optout WHERE guild_id = ?").run(guild_id)
return sendRedirect(event, `../agi?guild_id=${guild_id}`, 302)
}))

View file

@ -3,9 +3,6 @@
const assert = require("assert/strict") const assert = require("assert/strict")
const {defineEventHandler, getValidatedRouterParams, setResponseStatus, setResponseHeader, createError, H3Event, getValidatedQuery} = require("h3") const {defineEventHandler, getValidatedRouterParams, setResponseStatus, setResponseHeader, createError, H3Event, getValidatedQuery} = require("h3")
const {z} = require("zod") const {z} = require("zod")
const {ReadableStream} = require("stream/web")
const {Readable} = require("stream")
const sharp = require("sharp")
/** @type {import("xxhash-wasm").XXHashAPI} */ // @ts-ignore /** @type {import("xxhash-wasm").XXHashAPI} */ // @ts-ignore
let hasher = null let hasher = null
@ -22,27 +19,11 @@ const emojiSheetConverter = sync.require("../../m2d/converters/emoji-sheet")
/** @type {import("../../m2d/actions/sticker")} */ /** @type {import("../../m2d/actions/sticker")} */
const sticker = sync.require("../../m2d/actions/sticker") const sticker = sync.require("../../m2d/actions/sticker")
// Resizing client-side because server-side is too slow, at least with Synapse. Really need it to be fast because webhook avatars show a placeholder in the interim.
/** @type {{[presetKey: string]: (body: ReadableStream) => ReadableStream}} */
const MEDIA_THUMBNAIL_PRESETS = {
avatar: body =>
Readable.toWeb(
Readable.fromWeb(body).pipe(
sharp()
.resize({height: 210, width: 210, fit: "cover"}) // the largest display of the webhook pfp on Discord Android in screen pixels
.jpeg({force: false, quality: 90}) // File size works out to up to ~110k for a PNG, less for a JPEG
)
)
}
const schema = { const schema = {
media: z.object({ params: z.object({
server_name: z.string(), server_name: z.string(),
media_id: z.string() media_id: z.string()
}), }),
mediaQuery: z.object({
preset: z.enum(Object.keys(MEDIA_THUMBNAIL_PRESETS)) // list of possible thumbnail presets
}),
sheet: z.object({ sheet: z.object({
e: z.array(z.string()).or(z.string()) e: z.array(z.string()).or(z.string())
}), }),
@ -84,8 +65,7 @@ function verifyMediaHash(serverAndMediaID) {
} }
as.router.get(`/download/matrix/:server_name/:media_id`, defineEventHandler(async event => { as.router.get(`/download/matrix/:server_name/:media_id`, defineEventHandler(async event => {
const params = await getValidatedRouterParams(event, schema.media.parse) const params = await getValidatedRouterParams(event, schema.params.parse)
const query = await getValidatedQuery(event, schema.mediaQuery.safeParse)
verifyMediaHash(`${params.server_name}/${params.media_id}`) verifyMediaHash(`${params.server_name}/${params.media_id}`)
const api = getAPI(event) const api = getAPI(event)
@ -97,17 +77,7 @@ as.router.get(`/download/matrix/:server_name/:media_id`, defineEventHandler(asyn
setResponseStatus(event, res.status) setResponseStatus(event, res.status)
setResponseHeader(event, "Content-Type", contentType) setResponseHeader(event, "Content-Type", contentType)
setResponseHeader(event, "Transfer-Encoding", "chunked") setResponseHeader(event, "Transfer-Encoding", "chunked")
return res.body
const contentDisposition = res.headers.get("content-disposition")
if (contentDisposition) {
setResponseHeader(event, "Content-Disposition", contentDisposition)
}
if (res.ok && query.success) {
return MEDIA_THUMBNAIL_PRESETS[query.data.preset](res.body)
} else {
return res.body
}
})) }))
as.router.get(`/download/sheet`, defineEventHandler(async event => { as.router.get(`/download/sheet`, defineEventHandler(async event => {

View file

@ -30,13 +30,12 @@ test("web download matrix: works if a known attachment", async t => {
api: { api: {
// @ts-ignore // @ts-ignore
async getMedia(mxc, init) { async getMedia(mxc, init) {
return new Response("", {status: 200, headers: {"content-type": "image/png", "content-disposition": `attachment; filename="wa.png"`}}) return new Response("", {status: 200, headers: {"content-type": "image/png"}})
} }
} }
}) })
t.equal(event.node.res.statusCode, 200) t.equal(event.node.res.statusCode, 200)
t.equal(event.node.res.getHeader("content-type"), "image/png") t.equal(event.node.res.getHeader("content-type"), "image/png")
t.equal(event.node.res.getHeader("content-disposition"), `attachment; filename="wa.png"`)
}) })
/** /**
@ -65,7 +64,7 @@ test("web sheet: single emoji", async t => {
mxcDownloader: mockGetAndConvertEmoji mxcDownloader: mockGetAndConvertEmoji
}) })
t.equal(event.node.res.statusCode, 200) t.equal(event.node.res.statusCode, 200)
t.equal(sheet.subarray(0, 90).toString("base64"), "iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAACXBIWXMAAAPoAAAD6AG1e1JrAAALoklEQVRogc1ZaVBU2RU+LZSIGnAvFUtcRkSk6abpbkDH") t.equal(sheet.subarray(0, 90).toString("base64"), "iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAACXBIWXMAAAPoAAAD6AG1e1JrAAALoklEQVR4nM1ZaVBU2RU+LZSIGnAvFUtcRkSk6abpbkDH")
}) })
test("web sheet: multiple sources", async t => { test("web sheet: multiple sources", async t => {

View file

@ -158,13 +158,13 @@ as.router.get("/guild", defineEventHandler(async event => {
// Permission problems // Permission problems
if (!guild_id || !guild || !managed.has(guild_id) || !row) { if (!guild_id || !guild || !managed.has(guild_id) || !row) {
return pugSync.render(event, "guild-access-denied.pug", {guild_id, row}) return pugSync.render(event, "guild_access_denied.pug", {guild_id, row})
} }
// Self-service guild that hasn't been linked yet - needs a special page encouraging the link flow // Self-service guild that hasn't been linked yet - needs a special page encouraging the link flow
if (!row.space_id && row.autocreate === 0) { if (!row.space_id && row.autocreate === 0) {
const spaces = session.data.mxid ? getInviteTargetSpaces(session.data.mxid) : [] const spaces = session.data.mxid ? getInviteTargetSpaces(session.data.mxid) : []
return pugSync.render(event, "guild-not-linked.pug", {guild, guild_id, spaces}) return pugSync.render(event, "guild_not_linked.pug", {guild, guild_id, spaces})
} }
const roles = guild.members?.find(m => m.user.id === botID)?.roles || [] const roles = guild.members?.find(m => m.user.id === botID)?.roles || []
@ -191,7 +191,7 @@ as.router.get("/qr", defineEventHandler(async event => {
// Permission problems // Permission problems
if (!guild_id || !guild || !managed.has(guild_id) || !row) { if (!guild_id || !guild || !managed.has(guild_id) || !row) {
return pugSync.render(event, "guild-access-denied.pug", {guild_id, row}) return pugSync.render(event, "guild_access_denied.pug", {guild_id, row})
} }
const nonce = randomUUID() const nonce = randomUUID()

View file

@ -1,117 +0,0 @@
// @ts-check
const h3 = require("h3")
const {defineEventHandler, getValidatedQuery, setResponseHeader} = h3
const sharp = require("sharp")
const {z} = require("zod")
const {as} = require("../../passthrough")
const {reg} = require("../../matrix/read-registration")
/*
Create a 300x300 avatar image consisting of a dark coloured background, and a single character in a lighter colour centered in the middle.
Note: Where dimensions are changed, font size must also be changed too to produce an identical image as before.
Simply put, 100px = 60pt for font.
*/
const SIZE = 300
const POSSIBLE_HUES = 12
/** Helper function: To get accurate complimenting colours we need to work in HSL, then convert back to RGB at the end */
function hslToRgb(h, s, l) {
s /= 100;
l /= 100;
const a = s * Math.min(l, 1 - l);
const f = n => {
const k = (n + h / 30) % 12;
return l - a * Math.max(-1, Math.min(Math.min(k - 3, 9 - k), 1));
};
return {
r: Math.round(255 * f(0)),
g: Math.round(255 * f(8)),
b: Math.round(255 * f(4))
};
}
/**
* Use the MXID to generate deterministic avatar colours for each user.
* Here, we use the string hash code as a hue value, with a 360 wrap modulo.
* @param {string} mxid
*/
function mxidToHue(mxid) {
// Element Classic string hasher
let hash = 0;
let i;
let chr;
if (mxid.length === 0) {
return hash;
}
for (i = 0; i < mxid.length; i++) {
chr = mxid.charCodeAt(i);
hash = ((hash << 5) - hash) + chr;
hash |= 0;
}
hash = Math.abs(hash)
return (hash % POSSIBLE_HUES) * (360 / POSSIBLE_HUES)
}
/**
* Get first useful character in username to put in the avatar.
* @param {string} username
*/
function usernameToLetter(username) {
return (username.match(/[a-z0-9]/i)?.[0] || "#").toUpperCase()
}
/**
* @param {string} mxid
* @param {string} username
*/
function getLetterAvatarURL(mxid, username) {
const p = new URLSearchParams({letter: usernameToLetter(username), hue: String(mxidToHue(mxid))})
return `${reg.ooye.bridge_origin}/download/letter-avatar?${p}`
}
const schema = {
letterAvatar: z.object({
hue: z.coerce.number().min(0).max(360),
letter: z.string().regex(/^[A-Z0-9#]$/)
})
}
/**
* Produce a PNG letter-avatar from given parameters.
* @param {string} letter
* @param {number} hue
*/
as.router.get("/download/letter-avatar", defineEventHandler(async event => {
const {letter, hue} = await getValidatedQuery(event, schema.letterAvatar.parse)
const bg_rgb = hslToRgb(hue, 65, 18);
const text_rgb = hslToRgb(hue, 70, 65);
const text_rgbahex = `#${text_rgb.r.toString(16).padStart(2, "0")}${text_rgb.g.toString(16).padStart(2, "0")}${text_rgb.b.toString(16).padStart(2, "0")}ff`
const streamOut = sharp({
create: {
width: SIZE, height: SIZE, channels: 4,
background: {
r: bg_rgb.r, g: bg_rgb.g, b: bg_rgb.b, alpha: 1
}
}
}).composite([{
input: {
text: {
text: `<span foreground="${text_rgbahex}">${letter}</span>`,
font: "Noto Sans Bold 180", align: "center", rgba: true
}
}
}]).png()
setResponseHeader(event, "content-type", "image/png")
return streamOut
}))
module.exports.getLetterAvatarURL = getLetterAvatarURL

View file

@ -73,7 +73,7 @@ test("web link space: check that OOYE is joined", async t => {
api: { api: {
async joinRoom(roomID) { async joinRoom(roomID) {
called++ called++
throw new MatrixServerError({errcode: "M_FORBIDDEN", error: "not allowed to join I guess"}, 400) throw new MatrixServerError({errcode: "M_FORBIDDEN", error: "not allowed to join I guess"})
} }
} }
})) }))
@ -368,7 +368,7 @@ test("web link room: check that bridge can join room (notices lack of via and as
api: { api: {
async joinRoom(roomID) { async joinRoom(roomID) {
called++ called++
throw new MatrixServerError({errcode: "M_FORBIDDEN", error: "not allowed to join I guess"}, 400) throw new MatrixServerError({errcode: "M_FORBIDDEN", error: "not allowed to join I guess"})
}, },
async *generateFullHierarchy(spaceID) { async *generateFullHierarchy(spaceID) {
called++ called++
@ -402,7 +402,7 @@ test("web link room: check that bridge can join room (uses via for join attempt)
async joinRoom(roomID, _, via) { async joinRoom(roomID, _, via) {
called++ called++
t.deepEqual(via, ["cadence.moe", "hashi.re"]) t.deepEqual(via, ["cadence.moe", "hashi.re"])
throw new MatrixServerError({errcode: "M_FORBIDDEN", error: "not allowed to join I guess"}, 400) throw new MatrixServerError({errcode: "M_FORBIDDEN", error: "not allowed to join I guess"})
}, },
async *generateFullHierarchy(spaceID) { async *generateFullHierarchy(spaceID) {
called++ called++
@ -710,7 +710,7 @@ test("web unlink room: checks that the channel is bridged", async t => {
})) }))
t.equal(error.data, "Channel ID 665310973967597573 is not currently bridged") t.equal(error.data, "Channel ID 665310973967597573 is not currently bridged")
db.prepare("INSERT INTO channel_room (channel_id, room_id, name, nick, thread_parent, custom_avatar, last_bridged_pin_timestamp, speedbump_checked, guild_id, custom_topic) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)").run(row.channel_id, row.room_id, row.name, row.nick, row.thread_parent, row.custom_avatar, row.last_bridged_pin_timestamp, row.speedbump_checked, row.guild_id, row.custom_topic) db.prepare("INSERT INTO channel_room (channel_id, room_id, name, nick, thread_parent, custom_avatar, last_bridged_pin_timestamp, speedbump_id, speedbump_checked, speedbump_webhook_id, guild_id, custom_topic) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)").run(row.channel_id, row.room_id, row.name, row.nick, row.thread_parent, row.custom_avatar, row.last_bridged_pin_timestamp, row.speedbump_id, row.speedbump_checked, row.speedbump_webhook_id, row.guild_id, row.custom_topic)
const new_row = db.prepare("SELECT * FROM channel_room WHERE channel_id = '665310973967597573'").get() const new_row = db.prepare("SELECT * FROM channel_room WHERE channel_id = '665310973967597573'").get()
t.deepEqual(row, new_row) t.deepEqual(row, new_row)
}) })

View file

@ -17,8 +17,7 @@ const redirect_uri = `${reg.ooye.bridge_origin}/oauth`
const schema = { const schema = {
first: z.object({ first: z.object({
action: z.string().optional(), action: z.string().optional()
next: z.string().optional()
}), }),
code: z.object({ code: z.object({
state: z.string(), state: z.string(),
@ -54,10 +53,10 @@ function getOauth2Token(event) {
as.router.get("/oauth", defineEventHandler(async event => { as.router.get("/oauth", defineEventHandler(async event => {
const session = await auth.useSession(event) const session = await auth.useSession(event)
const parsedFirstQuery = await getValidatedQuery(event, schema.first.safeParse)
let scope = "guilds" let scope = "guilds"
if (!reg.ooye.web_password || reg.ooye.web_password === session.data.password) { if (!reg.ooye.web_password || reg.ooye.web_password === session.data.password) {
const parsedFirstQuery = await getValidatedQuery(event, schema.first.safeParse)
if (parsedFirstQuery.data?.action === "add") { if (parsedFirstQuery.data?.action === "add") {
scope = "bot+guilds" scope = "bot+guilds"
await session.update({selfService: false}) await session.update({selfService: false})
@ -70,9 +69,6 @@ as.router.get("/oauth", defineEventHandler(async event => {
async function tryAgain() { async function tryAgain() {
const newState = randomUUID() const newState = randomUUID()
await session.update({state: newState}) await session.update({state: newState})
if (parsedFirstQuery.data?.next) {
await session.update({next: parsedFirstQuery.data?.next})
}
return sendRedirect(event, `https://discord.com/oauth2/authorize?client_id=${id}&scope=${scope}&permissions=${permissions}&response_type=code&redirect_uri=${redirect_uri}&state=${newState}`) return sendRedirect(event, `https://discord.com/oauth2/authorize?client_id=${id}&scope=${scope}&permissions=${permissions}&response_type=code&redirect_uri=${redirect_uri}&state=${newState}`)
} }
@ -90,10 +86,8 @@ as.router.get("/oauth", defineEventHandler(async event => {
const client = getClient(event)(parsedToken.access_token) const client = getClient(event)(parsedToken.access_token)
const guilds = await client.user.getGuilds() const guilds = await client.user.getGuilds()
const managedGuilds = guilds.filter(g => BigInt(g.permissions) & DiscordTypes.PermissionFlagsBits.ManageGuild).map(g => g.id) var managedGuilds = guilds.filter(g => BigInt(g.permissions) & DiscordTypes.PermissionFlagsBits.ManageGuild).map(g => g.id)
await session.update({managedGuilds, userID, state: undefined})
const savedNext = session.data.next
await session.update({managedGuilds, userID, state: undefined, next: undefined})
// Set auto-create for the guild // Set auto-create for the guild
// @ts-ignore // @ts-ignore
@ -106,5 +100,5 @@ as.router.get("/oauth", defineEventHandler(async event => {
return sendRedirect(event, getRelativePath(event.path, `/guild?guild_id=${parsedQuery.data.guild_id}`), 302) return sendRedirect(event, getRelativePath(event.path, `/guild?guild_id=${parsedQuery.data.guild_id}`), 302)
} }
return sendRedirect(event, getRelativePath(event.path, savedNext || "/"), 302) return sendRedirect(event, getRelativePath(event.path, "/"), 302)
})) }))

View file

@ -1,43 +0,0 @@
// @ts-check
const assert = require("assert/strict")
const {z} = require("zod")
const {defineEventHandler, createError, readValidatedBody, getRequestHeader, setResponseHeader, sendRedirect, H3Event} = require("h3")
const {as, db, sync, select, discord} = require("../../passthrough")
/** @type {import("../auth")} */
const auth = sync.require("../auth")
/** @type {import("../pug-sync")} */
const pugSync = sync.require("../pug-sync")
const schema = {
optOut: z.object({
opt_in: z.string().optional() // switch is reversed, you turn it on to opt in
})
}
as.router.post("/api/opt-out", defineEventHandler(async event => {
// CSRF prevention for a more important endpoint
if (!getRequestHeader(event, "HX-Request")) {
throw createError({status: 403, message: "Forbidden", data: "JavaScript is required for the opt-out form."})
}
const session = await auth.useSession(event)
if (!session.data.userID) {
throw createError({status: 401, message: "Unauthorised", data: "Log in first."})
}
const parsedBody = await readValidatedBody(event, schema.optOut.parse)
const isOptIn = !!parsedBody.opt_in
if (isOptIn) {
db.prepare("DELETE FROM opt_out WHERE user_id = ?").run(session.data.userID)
var msg = "You have opted in."
} else {
db.prepare("INSERT OR IGNORE INTO opt_out (user_id, opted_out_at) VALUES (?, ?)").run(session.data.userID, Date.now())
var msg = "You have opted out."
}
return sendRedirect(event, `../opt-out?${new URLSearchParams({msg})}`, 302)
}))

View file

@ -1,85 +0,0 @@
// @ts-check
const {defineEventHandler, getValidatedQuery, H3Event, setResponseHeader} = require("h3")
const {as, db, sync} = require("../../passthrough")
const {reg} = require("../../matrix/read-registration")
/** @type {import("../../discord/utils")} */
const dUtils = sync.require("../../discord/utils")
// Calculation takes time and is single-threaded. I could add database indexes, but this is simpler and doesn't need storage.
const STATS_CACHE_TIME = 10 * 60 * 1000 // 10 minutes
function getMessageCountLastDuration(duration) {
const snowflake = dUtils.timestampToSnowflakeInexact(Date.now() - duration)
return db.prepare("select count(*) from message_room where message_id >= ? and length(message_id) = ?").pluck().get(snowflake, snowflake.length)
}
function getStats() {
const durations = [
["week", 7 * 24 * 60 * 60 * 1000],
["day", 1 * 24 * 60 * 60 * 1000],
["hour", 1 * 60 * 60 * 1000]
]
// console.time("get stats")
let temp = {
guilds: db.prepare("select count(*) from guild_space").pluck().get(),
channels: db.prepare("select count(*) from channel_room").pluck().get(),
messages: db.prepare("select count(*) from message_room").pluck().get(),
...durations.reduce((a, c) => (a[`messages_last_${c[0]}`] = getMessageCountLastDuration(c[1]), a), {}),
message_sources: db.prepare("select count(*) from event_message where part = 0 group by source order by source").pluck().all(),
oldest_message: new Date(dUtils.snowflakeToTimestampExact(db.prepare("select min(message_id) from event_message where source = 0").pluck().get())), // good until 2090
discord_users: db.prepare("select count(*) from sim").pluck().get(),
matrix_users: db.prepare("select count(distinct mxid) from member_cache where mxid not like ?").pluck().get(reg.namespaces.users[0].regex.replace(/\.\*.*/, "%")),
}
// console.timeEnd("get stats")
return temp
}
/** @type {ReturnType<typeof getStats>} */
let stats
let statsUpdatedAt = 0
function updateStatsIfOld() {
if (statsUpdatedAt < Date.now() - STATS_CACHE_TIME) {
stats = getStats()
statsUpdatedAt = Date.now()
}
}
as.router.get("/api/stats", defineEventHandler(async event => {
updateStatsIfOld()
return {
...stats,
oldest_message: stats.oldest_message.toISOString(),
}
}))
as.router.get("/metrics", defineEventHandler(async event => {
updateStatsIfOld()
setResponseHeader(event, "content-type", "text/plain")
return `
# HELP guilds Total number of guilds
# TYPE guilds gauge
ooye_guilds_total ${stats.guilds}
# HELP channels Total number of channels
# TYPE channels gauge
ooye_channels_total ${stats.channels}
# HELP messages_total Total number of messages sent from each side
# TYPE messages_total gauge
ooye_messages_total{type="matrix"} ${stats.message_sources[0]}
ooye_messages_total{type="discord"} ${stats.message_sources[1]}
# HELP oldest_message_timestamp Unix timestamp of the oldest message
# TYPE oldest_message_timestamp gauge
ooye_oldest_message_timestamp_seconds ${stats.oldest_message.getTime() / 1000}
# HELP ooye_users_total Total number of users on each side
# TYPE ooye_users_total gauge
ooye_users_total{type="matrix"} ${stats.matrix_users}
ooye_users_total{type="discord"} ${stats.discord_users}
`.trimStart()
}))

View file

@ -123,21 +123,15 @@ as.router.get("/icon.png", defineEventHandler(async event => {
// Routes // Routes
if (reg.reg.ooye.bridge_admin_contact) { pugSync.createRoute(as.router, "/ok", "ok.pug")
pugSync.createRoute(as.router, "/ok", "ok.pug")
}
pugSync.createRoute(as.router, "/opt-out", "opt-out.pug")
pugSync.createRoute(as.router, "/privacy-policy", "privacy-policy.pug")
sync.require("./routes/agi")
sync.require("./routes/download-matrix") sync.require("./routes/download-matrix")
sync.require("./routes/download-discord") sync.require("./routes/download-discord")
sync.require("./routes/guild-settings") sync.require("./routes/guild-settings")
sync.require("./routes/guild") sync.require("./routes/guild")
sync.require("./routes/info") sync.require("./routes/info")
sync.require("./routes/letter-avatar")
sync.require("./routes/link") sync.require("./routes/link")
sync.require("./routes/log-in-with-matrix") sync.require("./routes/log-in-with-matrix")
sync.require("./routes/oauth") sync.require("./routes/oauth")
sync.require("./routes/opt-out")
sync.require("./routes/password") sync.require("./routes/password")
sync.require("./routes/stats")

View file

@ -14,10 +14,6 @@ const sync = new HeatSync({watchFunction: fs.watchFile})
Object.assign(passthrough, {sync, db}) Object.assign(passthrough, {sync, db})
const orm = sync.require("./src/db/orm")
passthrough.from = orm.from
passthrough.select = orm.select
const DiscordClient = require("./src/d2m/discord-client") const DiscordClient = require("./src/d2m/discord-client")
const discord = new DiscordClient(reg.ooye.discord_token) const discord = new DiscordClient(reg.ooye.discord_token)
@ -26,14 +22,17 @@ passthrough.discord = discord
const {as} = require("./src/matrix/appservice") const {as} = require("./src/matrix/appservice")
passthrough.as = as passthrough.as = as
const orm = sync.require("./src/db/orm")
passthrough.from = orm.from
passthrough.select = orm.select
const power = require("./src/matrix/power.js") const power = require("./src/matrix/power.js")
sync.require("./src/m2d/event-dispatcher") sync.require("./src/m2d/event-dispatcher")
;(async () => { ;(async () => {
await migrate.migrate(db) await migrate.migrate(db)
process.stdout.write("Connecting to Discord... ")
await discord.cloud.connect() await discord.cloud.connect()
console.log("ok.") console.log("Discord gateway started")
sync.require("./src/web/server") sync.require("./src/web/server")
await power.applyPower() await power.applyPower()

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