Compare commits

..

128 commits

Author SHA1 Message Date
f9fd9bd513 final text changes, READY TO MERGE!!! 2026-04-26 15:14:05 +00:00
ea94dfe6b9 Fighting the demons of my past
TECHNICALLY, I was asked to remove it. But now that I know that @template is a thing that exists in JSDoc (which was the one missing ingredient the last time), I think I can get this to be good enough that removal won't even be necessary, and now it just looks like regular, sane documentation (not the any-schizopost like last time).
2026-04-26 14:12:13 +00:00
32ba4d8385 Finishing touches..... 2026-04-26 13:31:08 +00:00
c8bf730ec4 Filter out the root space 2026-04-26 13:07:39 +00:00
41c2131061 Merge branch 'main' into mergable-fr-fr 2026-04-26 12:54:34 +00:00
c3f2fbbeb1 Settled on a reasonable compromise for this 2026-04-26 12:22:45 +00:00
f53190f186 OH, I'm SUCH an IDIOT!
This code runs in a loop. And it always starts with the root space. So it always finds a space. And it then never clears it out.

Also, it was after the break;, so EVEN IF it somehow reached our room as false, and then was gonna update it to true because it was a space - it now would not have done that because the loop will had been ended.

Fixed by moving the code to where it made any sense.
2026-04-26 10:56:50 +00:00
b0d4b4c39d forgor to print the most important thing 2026-04-26 10:36:18 +00:00
75fec8819d Somehow, everything is a space... 2026-04-26 10:32:51 +00:00
511138e31f Link Rules system DONE! 2026-04-26 09:57:26 +00:00
9cbeef9efb fell asleep 2026-04-26 09:42:11 +00:00
82c6f0ab19 guild.pug works! 2026-04-25 23:32:09 +00:00
c7378d47ce fuck you vsc 2026-04-25 23:29:21 +00:00
ed651b41cf I can feel it, it's getting somewhere! 2026-04-25 23:27:13 +00:00
69f6379270 WHO TF LOOKED AT COMMENTS AND WAS LIKE „Oh, yea! We should make them syntax-significant!” That's like... The very fucking opposite of what comments are. 2026-04-25 23:09:43 +00:00
00bca38bbe pug 2026-04-25 23:06:44 +00:00
ae5ea2e936 that died quickly... 2026-04-25 23:03:47 +00:00
dc03dbd5f4 New system of linking 2026-04-25 23:01:02 +00:00
e301a1f3b0 oh lol
Wrong IF xD

This is getting nuked soon, but I still find if funny that I got it wrong.
2026-04-25 13:12:58 +00:00
1b9f99c4fd fix regression in Ellie Mode; add the announcement to it
Apparently, the context["m.relates_to"] = {"m.in_reply_to": {event_id: event.event_id}} has always been there... The more you know!

I forgot I didn't, apparently, add it myself.

Also, while fixing the regression, I may as well introduce the note to EM, too.
2026-04-22 14:11:06 +00:00
b007822174 Okey, let's be real: Those tests were an embarrassment. 2026-04-22 13:54:49 +00:00
7943f33dbb What were those imports for, anyway? 2026-04-22 13:50:27 +00:00
cd2b5ebb13 Yep! It does exactly what I thought it would. 2026-04-22 12:41:19 +00:00
a54809155f wait, I wanna test 1 more thing on mobile... 2026-04-22 12:31:02 +00:00
a7aad4281d Well, it DOES WORK, alright.....!
thanks, I hate it
2026-04-22 12:27:46 +00:00
cc906d5fb7 Does EM even work? 2026-04-22 12:18:38 +00:00
e92bda4a2a Credit where credit is due(TRUE)² 2026-04-17 22:39:32 +00:00
189ea7e769 text² 2026-04-17 22:30:14 +00:00
b6a68936ec text. 2026-04-17 22:10:50 +00:00
88b25e0482 imports. 2026-04-17 22:07:41 +00:00
5aa13a2a92 Somehow, it almost did!
2 caveats remain (and neither has anything to do with not passing ...channel):
* ugly-error with permissions (fixed)
* no auto-reset (maybe fixed??? - it's either because I DID pass channel (ironic) or because of no await, testing option 1 now)

Also, improved comment consistency
2026-04-17 21:27:14 +00:00
804a6ecb74 This isn't gonna work, is it?
I'm 99% sure I need to do {...channel, flags:(channel.flags^DiscordTypes.ChannelFlags.RequireTag)} but TypeScript won't let me
2026-04-17 20:04:10 +00:00
ab482a82fe actually, GIMME DA OBJECT! 2026-04-17 19:43:31 +00:00
81758529ba Debugging tags-breaking 2026-04-17 18:46:57 +00:00
e95df09c5d heyyyyy~~~~~~~~ 2026-04-17 17:59:02 +00:00
2d82734a06 just appreciated how useful these links are after getting an error and having to go all the way to the Command Handler for links 2026-04-17 17:48:42 +00:00
d52794e22c ACTUALLY handle forums
turns out my handling of it from yesterday was still broken
2026-04-17 17:00:16 +00:00
86c58f169e stupid emigrants...
The code is always greener in the other file, or something
2026-04-17 14:21:33 +00:00
10b6cf5bdb Undone some of the „quality improvements” from yesterday because I noticed they'd break auto-removing for already existing threads. 2026-04-17 13:27:46 +00:00
b1513a6fd1 idea acquired 2026-04-17 11:30:54 +00:00
3a74dfb78f Make sure it's actually possible to create a /thread in a Forum channel without guard()'s interference.
Also, while at it, let the users know that /thread usage is a possibility in Forums.
2026-04-16 21:27:01 +00:00
f17c070175 Account for the hypothetical „/thread ” command 2026-04-16 21:10:54 +00:00
81bf0b935f Extra changes for compat with previous commit 2026-04-16 20:26:20 +00:00
369370d0ad 🎶 the unenlightened masses, they cannot make a judgement call🎵
🎵Give up free will forever - their voices won't be heard at all!🎶
🎶Display obedience...🎵

Where was I, again? Ah, right. I'm supposed to make the judgment call (I am the unenlightened masses) ((Someone tries to link their, fkin, smart-bidet smart-home-controller-room to a Stage or something and imma be cooked))
2026-04-16 00:01:43 +00:00
f6b9614277 Only 2 things are eternal: Doom, and temporary solutions 2026-04-15 23:42:51 +00:00
0c6a5008e3 MOAR channels!!!1!1!!!11!!!!!
also, switched to working on this branch for now; I think that's the easiest option for the time being
2026-04-15 22:52:11 +00:00
f62468511b unfuck my mess
Why did I make it this way???

Guzio/out-of-your-element#13
2026-04-15 20:05:00 +00:00
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
39 changed files with 1668 additions and 771 deletions

5
.gitignore vendored
View file

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

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.

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "out-of-your-element",
"version": "3.5.1",
"version": "3.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "out-of-your-element",
"version": "3.5.1",
"version": "3.5.0",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@chriscdn/promise-semaphore": "^3.0.1",

View file

@ -1,6 +1,6 @@
{
"name": "out-of-your-element",
"version": "3.5.1",
"version": "3.5.0",
"description": "A bridge between Matrix and Discord",
"main": "index.js",
"repository": {

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

@ -35,6 +35,7 @@ const PRIVACY_ENUMS = {
ROOM_HISTORY_VISIBILITY: ["shared", "shared", "world_readable"], // any events sent after <value> are visible, but for world_readable anybody can read without even joining
GUEST_ACCESS: ["can_join", "forbidden", "forbidden"], // whether guests can join space if other conditions are met
SPACE_JOIN_RULES: ["invite", "public", "public"],
/** @type {import("../../types").JoinRule[]} */
ROOM_JOIN_RULES: ["restricted", "public", "public"]
}
@ -63,12 +64,13 @@ function convertNameAndTopic(channel, guild, customName) {
const chosenName = customName || (channelPrefix + channel.name);
const maybeTopicWithPipe = channel.topic ? ` | ${channel.topic}` : '';
const maybeTopicWithNewlines = channel.topic ? `${channel.topic}\n\n` : '';
const maybeWithin = parentChannel ? `Within: ${parentChannel.name} (ID: ${parentChannel.id})\n` : '';
const channelIDPart = `Channel ID: ${channel.id}`;
const guildIDPart = `Guild ID: ${guild.id}`;
const convertedTopic = customName
? `#${channel.name}${maybeTopicWithPipe}\n\n${channelIDPart}\n${guildIDPart}`
: `${maybeTopicWithNewlines}${channelIDPart}\n${guildIDPart}`;
? `#${channel.name}${maybeTopicWithPipe}\n\n${maybeWithin}${channelIDPart}\n${guildIDPart}`
: `${maybeTopicWithNewlines}${maybeWithin}${channelIDPart}\n${guildIDPart}`;
return [chosenName, convertedTopic];
}
@ -87,7 +89,7 @@ async function channelToKState(channel, guild, di) {
const guildSpaceID = await createSpace.ensureSpace(guild)
/** Used as the literal parent on Matrix, for categorisation. Will be the same as `guildSpaceID` unless it's a forum channel's thread, in which case a different space is used to group those threads. */
let parentSpaceID = guildSpaceID
if (parentChannel?.type === DiscordTypes.ChannelType.GuildForum) {
if (parentChannel?.type === DiscordTypes.ChannelType.GuildForum || parentChannel?.type === DiscordTypes.ChannelType.GuildMedia) { //TODO: Once Ellie's and Guzio's MSC for room-in-room embedding starts being implemented, make this check for whether THIS channel (not its parent) is a thread of ANY type (not just threads in forum/media channels) - thus making it so that threads always appear embedded under their parent.
parentSpaceID = await ensureRoom(channel.parent_id)
assert(typeof parentSpaceID === "string")
}
@ -110,7 +112,7 @@ async function channelToKState(channel, guild, di) {
let history_visibility = PRIVACY_ENUMS.ROOM_HISTORY_VISIBILITY[privacyLevel]
if (channel["thread_metadata"]) history_visibility = "world_readable"
/** @type {{join_rule: string, allow?: any}} */
/** @type {{join_rule: import("../../types").JoinRule, allow?: {type: "m.room_membership", room_id: string}[]}} */
let join_rules = {
join_rule: "restricted",
allow: [{
@ -118,6 +120,13 @@ async function channelToKState(channel, guild, di) {
room_id: guildSpaceID
}]
}
if (guildSpaceID !== parentSpaceID) {
//@ts-ignore - join_rules.allow most certainly IS defined because we literally define it ~5 lines earlier
join_rules.allow[1] = {
type: "m.room_membership",
room_id: parentSpaceID
}
}
if (PRIVACY_ENUMS.ROOM_JOIN_RULES[privacyLevel] !== "restricted") {
join_rules = {join_rule: PRIVACY_ENUMS.ROOM_JOIN_RULES[privacyLevel]}
}
@ -194,7 +203,7 @@ async function channelToKState(channel, guild, di) {
if (hasCustomTopic) delete channelKState["m.room.topic/"]
// Make voice channels be a Matrix voice room (MSC3417)
if (channel.type === DiscordTypes.ChannelType.GuildVoice) {
if (channel.type === DiscordTypes.ChannelType.GuildVoice || channel.type === DiscordTypes.ChannelType.GuildStageVoice) {
creationContent.type = "org.matrix.msc3417.call"
channelKState["org.matrix.msc3401.call/"] = {
"m.intent": "m.room",
@ -439,12 +448,12 @@ async function _syncRoom(channelID, shouldActuallySync) {
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) {
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) {
return _syncRoom(channelID, true)
}

View file

@ -23,6 +23,8 @@ const pollEnd = sync.require("../actions/poll-end")
const dUtils = sync.require("../../discord/utils")
/** @type {import("../../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
@ -137,6 +139,8 @@ async function sendMessage(message, channel, guild, row) {
}
}
await agiListener.process(message, channel, guild, false)
return eventIDs
}

View file

@ -1,9 +1,7 @@
// @ts-check
const assert = require("assert").strict
const passthrough = require("../../passthrough")
const {discord, sync, db, select} = passthrough
const {sync, select} = passthrough
/** @type {import("../../matrix/utils")} */
const mxUtils = sync.require("../../matrix/utils")
const {reg} = require("../../matrix/read-registration.js")
@ -19,24 +17,32 @@ const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex))
*/
async function threadToAnnouncement(parentRoomID, threadRoomID, creatorMxid, thread, di) {
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 = {}
let suffix = ""
if (branchedFromEventID) {
// Need to figure out who sent that event...
const event = await di.api.getEvent(parentRoomID, branchedFromEventID)
suffix = "\n[Note: You really should move the conversation to that room, rather than continuing to reply via a Matrix thread. Any messages sent in threads will be DELETED and instead moved to that room 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}}
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 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)
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 {
msgtype,
body,
"m.mentions": {},
...context
}
}

View file

@ -49,8 +49,7 @@ test("thread2announcement: no known creator, no branched from event", async t =>
}, {api: viaApi})
t.deepEqual(content, {
msgtype: "m.text",
body: "Thread started: test thread https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org",
"m.mentions": {}
body: "New thread started: \"test thread\" in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org",
})
})
@ -61,8 +60,7 @@ test("thread2announcement: known creator, no branched from event", async t => {
}, {api: viaApi})
t.deepEqual(content, {
msgtype: "m.emote",
body: "started a thread: test thread https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org",
"m.mentions": {}
body: "started a thread called \"test thread\" in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org",
})
})
@ -85,12 +83,14 @@ test("thread2announcement: no known creator, branched from discord event", async
})
t.deepEqual(content, {
msgtype: "m.text",
body: "Thread started: test thread https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org",
"m.mentions": {},
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.relates_to": {
"event_id": "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg",
"is_falling_back": false,
"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, {
msgtype: "m.emote",
body: "started a thread: test thread https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org",
"m.mentions": {},
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.relates_to": {
"event_id": "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg",
"is_falling_back": false,
"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, {
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": {
user_ids: ["@cadence:cadence.moe"]
},
"m.relates_to": {
"event_id": "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4",
"is_falling_back": false,
"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

@ -40,6 +40,8 @@ const vote = sync.require("./actions/poll-vote")
const matrixEventDispatcher = sync.require("../m2d/event-dispatcher")
/** @type {import("../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 checkMissedPinsSema = new Semaphore()
@ -210,7 +212,7 @@ module.exports = {
const channelID = thread.parent_id || undefined
const parentRoomID = select("channel_room", "room_id", {channel_id: channelID}).pluck().get()
if (!parentRoomID) return // Not interested in a thread if we aren't interested in its wider channel (won't autocreate)
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)
},
@ -303,7 +305,10 @@ module.exports = {
if (message.webhook_id) {
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!

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;

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

@ -1,4 +1,23 @@
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: {
guild_id: string
app_bot_id: string

View file

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

View file

@ -182,394 +182,6 @@ function filterTo(xs, fn) {
return filtered
}
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.getDefaultPermissions = getDefaultPermissions
module.exports.hasPermission = hasPermission
@ -582,4 +194,3 @@ module.exports.timestampToSnowflakeInexact = timestampToSnowflakeInexact
module.exports.getPublicUrlForCdn = getPublicUrlForCdn
module.exports.howOldUnbridgedMessage = howOldUnbridgedMessage
module.exports.filterTo = filterTo
module.exports.supportedPlaintextPreviewExtensions = supportedPlaintextPreviewExtensions

View file

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

View file

@ -471,6 +471,7 @@ async function checkWrittenMentions(content, senderMxid, roomID, guild, di) {
// @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]),
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"],
allowedMentionsUsers: []
}
@ -545,6 +546,7 @@ async function getL1L2ReplyLine(called = false) {
async function eventToMessage(event, guild, channel, di) {
let displayName = event.sender
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 allowedMentionsUsers = []
/** @type {string[]} */
@ -894,8 +896,7 @@ async function eventToMessage(event, guild, channel, di) {
let preNode
if (node.nodeType === 3 && node.nodeValue.includes("```") && (preNode = nodeIsChildOf(node, ["PRE"]))) {
if (preNode.firstChild?.nodeName === "CODE") {
let ext = preNode.firstChild.className.match(/language-(\S+)/)?.[1]
if (!dUtils.supportedPlaintextPreviewExtensions.has(ext)) ext = "txt"
const ext = preNode.firstChild.className.match(/language-(\S+)/)?.[1] || "txt"
const filename = `inline_code.${ext}`
// Build the replacement <code> node
const replacementCode = doc.createElement("code")

View file

@ -1155,38 +1155,6 @@ test("event2message: code blocks are uploaded as attachments instead if they con
)
})
test("event2message: code blocks are uploaded as attachments instead if they contain incompatible backticks (use txt extension if discord does not recognise the language)", async t => {
t.deepEqual(
await eventToMessage({
type: "m.room.message",
sender: "@cadence:cadence.moe",
content: {
msgtype: "m.text",
body: "wrong body",
format: "org.matrix.custom.html",
formatted_body: 'So if you run code like this<pre><code class="language-if">System.out.println("```");</code></pre>it should print a markdown formatted code block'
},
event_id: "$pGkWQuGVmrPNByrFELxhzI6MCBgJecr5I2J3z88Gc2s",
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe"
}),
{
ensureJoined: [],
messagesToDelete: [],
messagesToEdit: [],
messagesToSend: [{
username: "cadence [they]",
content: "So if you run code like this `[inline_code.txt]` it should print a markdown formatted code block",
attachments: [{id: "0", filename: "inline_code.txt"}],
pendingFiles: [{name: "inline_code.txt", buffer: Buffer.from('System.out.println("```");')}],
avatar_url: undefined,
allowed_mentions: {
parse: ["users", "roles"]
}
}]
}
)
})
test("event2message: code blocks are uploaded as attachments instead if they contain incompatible backticks (default to txt file extension)", async t => {
t.deepEqual(
await eventToMessage({

View file

@ -0,0 +1,106 @@
//@ts-check
/*
* Misc. utils for transforming various Matrix events (eg. those sent in Forum-bridged channels; those sent) so that they're usable as threads, and for creating said threads.
*/
const Ty = require("../../types")
const {discord, sync, select, from} = require("../../passthrough")
const DiscordTypes = require("discord-api-types/v10")
/** @type {import("../../matrix/api")}) */
const api = sync.require("../../matrix/api")
/** @type {import("../../d2m/actions/create-room")} */
const createRoom = sync.require("../../d2m/actions/create-room")
/**
* @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 threadEventID = event.content["m.relates_to"]?.event_id
if (!threadEventID) 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: threadEventID}).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).
try {
event.room_id = await createRoom.ensureRoom((await discord.snow.channel.createThreadWithMessage(channelID, messageID, {name: computeName(event, await api.getEvent(event.room_id, threadEventID)).name})).id)
return true;
}
catch (e){
if (e.message?.includes("50024")){ //see: https://docs.discord.com/developers/topics/opcodes-and-status-codes
api.sendEvent(event.room_id, "m.room.message", {
body: "Hey, please don't do that! This room is already a thread on Discord (or it could also be a voice-chat or something adjacent) - trying to embed threads inside it, like you just did, will not work. DC users will just see a regular reply, which is distracting and also probably not what you want.",
"m.mentions": { "user_ids": [event.sender]},
"m.relates_to": {
event_id: threadEventID,
is_falling_back: false,
"m.in_reply_to": { event_id: event.event_id },
rel_type: "m.thread"
},
msgtype: "m.text"
})
return false;
}
else throw e
}
}
/**
* @param {Ty.Event.Outer_M_Room_Message | Ty.Event.Outer_M_Room_Message_File} event
* @returns {Promise<boolean>} whether a forum-thread-room was created
*/
async function handleForums(event) {
if (event.content.body === "/thread") return false; //Let the help be shown normally
const row = from("channel_room").where({room_id: event.room_id}).select("channel_id").get()
/** @type {string}*/ //@ts-ignore the possibility that it's undefined - get() will return back an undefined if it's fed one (so that's undefined-safe), and createThreadWithoutMessage() won't be reached because "undefined" is neither DiscordTypes.ChannelType.GuildMedia nor DiscordTypes.ChannelType.GuildForum, so the guard clause kicks in.
let channelID = row?.channel_id
const channel = discord.channels.get(channelID)
if (channel?.type != DiscordTypes.ChannelType.GuildForum && channel?.type != DiscordTypes.ChannelType.GuildMedia) return false
const name = computeName(event)
let resetNeeded = false
try {
if(channel.flags && channel.flags & DiscordTypes.ChannelFlags.RequireTag){
await discord.snow.channel.updateChannel(channelID, {flags:(channel.flags^DiscordTypes.ChannelFlags.RequireTag)}, "Temporary override of tagging requirements because Matrix threads that can't be tagged yet.")
resetNeeded = true
}
//@ts-ignore the presence of message:{} and the absence of type:11 - that's intended for threads in Forum channels (*and no, createThreadWithMessage wouldn't work here, either - that one takes an ALREADY EXISTING message ID, whereas this needs a new message)
await discord.snow.channel.createThreadWithoutMessage(channelID, {name: name.name, message:{content:"**Created by: `"+ event.sender +"`**"}})
if (!name.truncated) api.redactEvent(event.room_id, event.event_id) //Don't destroy people's texts - only remove if no truncation is guaranteed.
if (resetNeeded) discord.snow.channel.updateChannel(channelID, {flags:channel.flags}, "Restoring flags to their original state.")
}
catch (e){
if (e.message?.includes("50013")){
api.sendEvent(event.room_id, "m.room.message", {
body: "You can't create threads in this forum right now! This forum is configured to require tags on post (Matrix users can't yet use tags yet), and OOYE doesn't have the permission to edit this channel on Discord (needed to bypass the requirement of tags). Unless this is intentional (see room description - the admins may have left a note), please ask someone on the Discord side to either grant OOYE the necessary permissions, or to remove tagging requirements.",
msgtype: "m.text"
})
}
else throw e
}
return true
}
/**
* @param {Ty.Event.Outer_M_Room_Message | Ty.Event.Outer_M_Room_Message_File} from event
* @param {Ty.Event.Outer<any> | null | false | undefined} fallback Reuses the "from" param value if empty.
* @returns {{name: string, truncated: boolean}}
*/
function computeName(from, fallback=null){
let name = from.content.body
if (name.startsWith("/thread ") && name.length > 8) name = name.substring(8);
else name = (fallback ? fallback : from).content.body;
return name.length < 100 ? {name: name.replaceAll("\n", " "), truncated: false} : {name: name.slice(0, 96).replaceAll("\n", " ") + "...", truncated: true}
}
module.exports.handleForums = handleForums
module.exports.bridgeThread = bridgeThread

View file

@ -9,6 +9,7 @@ const Ty = require("../types")
const {discord, db, sync, as, select} = require("../passthrough")
const {tag} = require("@cloudrac3r/html-template-tag")
const {Semaphore} = require("@chriscdn/promise-semaphore")
const { bridgeThread, handleForums } = require("./converters/threads-and-forums")
/** @type {import("./actions/send-event")} */
const sendEvent = sync.require("./actions/send-event")
@ -156,6 +157,15 @@ async function sendError(roomID, source, type, e, payload) {
} catch (e) {}
}
/**
* Wraps the function with an automated error catching and reporting mechanism
* @template {Ty.Event.Outer<any>} EVENT The event that the wrapped function processes, its first argument.
* @template {[]} ARGS Other arguments of the wrapped function
* @template RETURNS The output of the wrapped function
* @param {string} type Type of the event, during the processing of which the error may occur.
* @param {(event: EVENT, ...args: ARGS)=>RETURNS|Promise<RETURNS>} fn Function to wrap
* @returns {(event: EVENT, ...args: ARGS)=>Promise<RETURNS|undefined>} Wrapped function
*/
function guard(type, fn) {
return async function(event, ...args) {
try {
@ -205,12 +215,35 @@ sync.addTemporaryListener(as, "type:m.room.message", guard("m.room.message",
*/
async event => {
if (utils.eventSenderIsFromDiscord(event.sender)) return
if (await handleForums(event)) return
let processCommands = true
if (event.content["m.relates_to"]?.rel_type === "m.thread") {
/**@type {string|null} */
let 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)) toRedact = null; //Don't remove anything, if there is nowhere to relocate it to.
if (toRedact) {
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)
if (!messageResponses.length) return
if (event.type === "m.room.message" && event.content.msgtype === "m.text") {
// @ts-ignore
await matrixCommandHandler.execute(event)
if (event.type === "m.room.message" && event.content.msgtype === "m.text" && processCommands) {
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.messageFinishedBridging(event.event_id)
await api.ackEvent(event)
}))

View file

@ -261,8 +261,65 @@ const commands = [{
body: "This command creates a thread on Discord. But you aren't allowed to do this, because if you were a Discord user, you wouldn't have the Create Public Threads permission."
})
}
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()
await discord.snow.channel.createThreadWithoutMessage(channelID, {type: 11, name: words.slice(1).join(" ")})
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. 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. It could be something like a VC, or perhaps... Did you try to embed a thread inside a thread, silly?"
})
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.
}
}
)
}, {
@ -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
while (realBody.startsWith("> ")) {
const i = realBody.indexOf("\n")
@ -342,8 +402,8 @@ async function execute(event) {
const command = commands.find(c => c.aliases.includes(commandName))
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

View file

@ -78,14 +78,10 @@ function readRegistration() {
/** @type {import("../types").AppServiceRegistrationConfig} */ // @ts-ignore
let reg = readRegistration()
if (reg) {
fs.watch(registrationFilePath, {persistent: false}, () => {
let newReg = readRegistration()
if (newReg) {
Object.assign(reg, newReg)
}
})
}
fs.watch(registrationFilePath, {persistent: false}, () => {
let newReg = readRegistration()
Object.assign(reg, newReg)
})
module.exports.registrationFilePath = registrationFilePath
module.exports.readRegistration = readRegistration

View file

@ -4,7 +4,7 @@ const assert = require("assert").strict
const Ty = require("../types")
const {tag} = require("@cloudrac3r/html-template-tag")
const passthrough = require("../passthrough")
const {db} = passthrough
const {db, select} = passthrough
const {reg} = require("./read-registration")
const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex))
@ -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.BLOCK_ELEMENTS = BLOCK_ELEMENTS
module.exports.eventSenderIsFromDiscord = eventSenderIsFromDiscord
@ -400,3 +410,4 @@ module.exports.removeCreatorsFromPowerLevels = removeCreatorsFromPowerLevels
module.exports.getEffectivePower = getEffectivePower
module.exports.setUserPower = setUserPower
module.exports.setUserPowerCascade = setUserPowerCascade
module.exports.getThreadRoomFromThreadEvent = getThreadRoomFromThreadEvent

View file

@ -2,7 +2,7 @@
const {select} = require("../passthrough")
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")
/** @param {string[]} mxids */
@ -417,4 +417,23 @@ test("set user power: privileged users must demote themselves", async t => {
t.equal(called, 3)
})
test("getThreadRoomFromThreadEvent: real message with a thread", t => {
const room = getThreadRoomFromThreadEvent("$fdD9o7NxMA4VPexlAiIx2CB9JbsiGhJeyJgnZG7U5xg")
t.equal(room, "!FuDZhlOAtqswlyxzeR:cadence.moe")
})
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: fake message", t => {
const room = getThreadRoomFromThreadEvent("$ThisEvent-IdDoesNotExistInTheDatabase4Sure")
const msg = "Expected null/undefined, got: "+room
if(room) t.fail(msg);
else t.pass(msg)
})
module.exports.mockGetEffectivePower = mockGetEffectivePower

23
src/types.d.ts vendored
View file

@ -190,11 +190,12 @@ export namespace Event {
format?: "org.matrix.custom.html"
formatted_body?: string,
"m.relates_to"?: {
"m.in_reply_to": {
event_id?: string
is_falling_back?: boolean
"m.in_reply_to"?: {
event_id: string
}
rel_type?: "m.replace"
event_id?: string
rel_type?: "m.replace"|"m.thread"
}
}
@ -210,11 +211,12 @@ export namespace Event {
info?: any
"page.codeberg.everypizza.msc4193.spoiler"?: boolean
"m.relates_to"?: {
"m.in_reply_to": {
event_id?: string
is_falling_back?: boolean
"m.in_reply_to"?: {
event_id: string
}
rel_type?: "m.replace"
event_id?: string
rel_type?: "m.replace"|"m.thread"
}
}
@ -246,11 +248,12 @@ export namespace Event {
},
info?: any
"m.relates_to"?: {
"m.in_reply_to": {
event_id?: string
is_falling_back?: boolean
"m.in_reply_to"?: {
event_id: string
}
rel_type?: "m.replace"
event_id?: string
rel_type?: "m.replace"|"m.thread"
}
}
@ -502,6 +505,8 @@ export namespace R {
export type Membership = "invite" | "knock" | "join" | "leave" | "ban"
export type JoinRule = "public" | "knock" | "invite" | "private" | "restricted" | "knock_restricted"
export type Pagination<T> = {
chunk: T[]
next_batch?: string

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

5
src/web/pug/explain.pug Normal file
View file

@ -0,0 +1,5 @@
extends includes/template.pug
block body
.ta-center.wmx5.p48.mx-auto#ok
p.mt24.fs-body2= msg

View file

@ -1,266 +1,270 @@
extends includes/template.pug
include includes/default-roles-list.pug
mixin badge-readonly
.s-badge.s-badge__xs.s-badge__icon.s-badge__muted
!= icons.Icons.IconEyeSm
| Read-only
mixin badge-private
.s-badge.s-badge__xs.s-badge__icon.s-badge__warning
!= icons.Icons.IconLockSm
| Private
mixin discord(channel, radio=false)
//- Previously, we passed guild.roles as the second parameter, but this doesn't quite match Discord's behaviour. See issue #42 for why this was changed.
//- Basically we just want to assign badges based on the channel overwrites, without considering the guild's base permissions. /shrug
- let permissions = dUtils.getPermissions(guild_id, [], [{id: guild_id, name: "@everyone", permissions: 1<<10 | 1<<11}], null, channel.permission_overwrites)
.s-user-card.s-user-card__small
if !dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.ViewChannel)
!= icons.Icons.IconLock
else if channel.type === 5
!= icons.Icons.IconBullhorn
else if channel.type === 2
!= icons.Icons.IconPhone
else if channel.type === 11 || channel.type === 12
!= icons.Icons.IconCollection
else
include includes/hash.svg
.s-user-card--info.ws-nowrap
if radio
= channel.name
else
.s-user-card--link.fs-body1
a(href=`https://discord.com/channels/${channel.guild_id}/${channel.id}`)= channel.name
if channel.parent_id
.s-user-card--location= discord.channels.get(channel.parent_id).name
if !dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.ViewChannel)
+badge-private
else if !dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.SendMessages)
+badge-readonly
mixin matrix(row, radio=false, badge="")
.s-user-card.s-user-card__small
!= icons.Icons.IconMessage
.s-user-card--info.ws-nowrap
if radio
= row.nick || row.name
else
.s-user-card--link.fs-body1
a(href=`https://matrix.to/#/${row.room_id}`)= row.nick || row.name
if row.join_rule === "invite"
+badge-private
block body
.s-page-title.mb24
h1.s-page-title--header= guild.name
.d-flex.g16(class="sm:fw-wrap")
.fl-grow1
h2.fs-headline1 Invite a Matrix user
form.d-grid.g-af-column.gy4.gx8.jc-start(method="post" action=rel("/api/invite") hx-post=rel("/api/invite") hx-trigger="submit" hx-swap="none" hx-on::after-request="if (event.detail.successful) this.reset()" hx-disabled-elt="input, button" hx-indicator="#invite-button")
label.s-label(for="mxid") Matrix ID
input.fl-grow1.s-input.wmx3#mxid(name="mxid" required placeholder="@user:example.org" pattern="@([^:]+):([a-z0-9:\\-]+\\.[a-z0-9.:\\-]+)")
label.s-label(for="permissions") Permissions
.s-select
select#permissions(name="permissions")
option(value="default") Default
option(value="moderator") Moderator
option(value="admin") Admin
input(type="hidden" name="guild_id" value=guild_id)
.grid--row-start2
button.s-btn.s-btn__filled#invite-button Invite
div
.s-card.d-flex.ai-center.jc-center(style="min-width: 132px; min-height: 132px;")
button.s-btn(class=space_id ? "s-btn__muted" : "s-btn__filled" hx-get=rel(`/qr?guild_id=${guild_id}`) hx-indicator="closest button" hx-swap="outerHTML" hx-disabled-elt="this") Show QR
if space_id
h2.mt48.fs-headline1 Server settings
h3.mt32.fs-category How Matrix users join
span#privacy-level-loading
.s-card
form(hx-post=rel("/api/privacy-level") hx-trigger="change" hx-indicator="#privacy-level-loading" hx-disabled-elt="input")
input(type="hidden" name="guild_id" value=guild_id)
.s-toggle-switch.s-toggle-switch__multiple.s-toggle-switch__incremental.d-grid.gx16.ai-center(style="grid-template-columns: auto 1fr")
input(type="radio" name="privacy_level" value="directory" id="privacy-level-directory" checked=(privacy_level === 2))
label.d-flex.gx8.jc-center.grid--row-start3(for="privacy-level-directory")
!= icons.Icons.IconPlusSm
!= icons.Icons.IconInternationalSm
.fl-grow1 Directory
input(type="radio" name="privacy_level" value="link" id="privacy-level-link" checked=(privacy_level === 1))
label.d-flex.gx8.jc-center.grid--row-start2(for="privacy-level-link")
!= icons.Icons.IconPlusSm
!= icons.Icons.IconLinkSm
.fl-grow1 Link
input(type="radio" name="privacy_level" value="invite" id="privacy-level-invite" checked=(privacy_level === 0))
label.d-flex.gx8.jc-center.grid--row-start1(for="privacy-level-invite")
svg.svg-icon(width="14" height="14" viewBox="0 0 14 14")
!= icons.Icons.IconLockSm
.fl-grow1 Invite
p.s-description.m0 In-app direct invite from another user
p.s-description.m0 Shareable invite links, like Discord
p.s-description.m0 Publicly listed in directory, like Discord server discovery
h3.mt32.fs-category Default roles
.s-card
form(method="post" action=rel("/api/default-roles") hx-post=rel("/api/default-roles") hx-sync="this:drop" hx-indicator="#add-role-loading" hx-target="#default-roles-list" hx-select="#default-roles-list" hx-swap="outerHTML")#default-roles
input(type="hidden" name="guild_id" value=guild_id)
.d-flex.fw-wrap.g4
.s-tag.s-tag__md.fs-body1.s-tag__required @everyone
+default-roles-list(guild, guild_id)
button(type="button" popovertarget="role-add").s-btn__dropdown.s-tag.s-tag__md.fs-body1.p0
.s-tag--dismiss.m1
!= icons.Icons.IconPlusSm
#role-add.s-popover(popover style="display: revert").ws2.px0.py4.bs-lg.overflow-visible
.s-popover--arrow.s-popover--arrow__tc
+add-roles-menu(guild, guild_id)
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
.s-card.d-grid.px0.g16
form.d-flex.ai-center.g16
#url-preview-loading.p8
- let value = !!select("guild_space", "url_preview", {guild_id}).pluck().get()
input(type="hidden" name="guild_id" value=guild_id)
input.s-toggle-switch#url-preview(name="url_preview" type="checkbox" hx-post=rel("/api/url-preview") hx-indicator="#url-preview-loading" hx-disabled-elt="this" checked=value autocomplete="off")
label.s-label.fl-grow1(for="url-preview")
| Show Discord's URL previews on Matrix
p.s-description Shows info about links posted to chat. Discord's previews are generally better quality than Synapse's, especially for social media and videos.
form.d-flex.ai-center.g16
#presence-loading.p8
- value = !!select("guild_space", "presence", {guild_id}).pluck().get()
input(type="hidden" name="guild_id" value=guild_id)
input.s-toggle-switch#presence(name="presence" type="checkbox" hx-post=rel("/api/presence") hx-indicator="#presence-loading" hx-disabled-elt="this" checked=value autocomplete="off")
label.s-label(for="presence")
| Show online statuses on Matrix
p.s-description This might cause lag on really big Discord servers.
form.d-flex.ai-center.g16
#webhook-profile-loading.p8
- value = !!select("guild_space", "webhook_profile", {guild_id}).pluck().get()
input(type="hidden" name="guild_id" value=guild_id)
input.s-toggle-switch#webhook-profile(name="webhook_profile" type="checkbox" hx-post=rel("/api/webhook-profile") hx-indicator="#webhook-profile-loading" hx-disabled-elt="this" checked=value autocomplete="off")
label.s-label(for="webhook-profile")
| Create persistent Matrix sims for webhooks
p.s-description Useful when using other Discord bridges. Otherwise, not ideal, as sims will clutter the Matrix user list and will never be cleaned up.
if space_id
h2.mt48.fs-headline1 Channel setup
h3.mt32.fs-category Linked channels
.s-card.bs-sm.p0
form.s-table-container(method="post" action=rel("/api/unlink"))
input(type="hidden" name="guild_id" value=guild_id)
table.s-table.s-table__bx-simple
each row in linkedChannelsWithDetails
tr
td.w40: +discord(row.channel)
td.p2: button.s-btn.s-btn__muted.s-btn__xs(name="channel_id" cx-prevent-default hx-post=rel("/api/unlink") hx-confirm="Do you want to unlink these channels?\nIt may take a moment to clean up Matrix resources." value=row.channel.id hx-indicator="this" hx-disabled-elt="this")!= icons.Icons.IconLinkSm
td: +matrix(row)
else
tr
td(colspan="3")
.s-empty-state No channels linked between Discord and Matrix yet...
h3.fs-category.mt32 Auto-create
.s-card.d-grid.px0
form.d-flex.ai-center.g16
#autocreate-loading.p8
- let value = !!select("guild_active", "autocreate", {guild_id}).pluck().get()
input(type="hidden" name="guild_id" value=guild_id)
input.s-toggle-switch#autocreate(name="autocreate" type="checkbox" hx-post=rel("/api/autocreate") hx-indicator="#autocreate-loading" hx-disabled-elt="this" checked=value autocomplete="off")
label.s-label.fl-grow1(for="autocreate")
| Create new Matrix rooms automatically
p.s-description If you want, OOYE can automatically create new Matrix rooms and link them when an unlinked Discord channel is spoken in.
if space_id
h3.mt32.fs-category Manually link channels
form.d-flex.g16.ai-start(hx-post=rel("/api/link") hx-trigger="submit" hx-disabled-elt="input, button" hx-indicator="#link-button")
.fl-grow2.s-btn-group.fd-column.w40
each channel in unlinkedChannels
input.s-btn--radio(type="radio" name="discord" required id=channel.id value=channel.id)
label.s-btn.s-btn__muted.ta-left.truncate(for=channel.id)
+discord(channel, true, "Announcement")
else
.s-empty-state.p8 All Discord channels are linked.
.fl-grow1.s-btn-group.fd-column.w30
each room in unlinkedRooms
input.s-btn--radio(type="radio" name="matrix" required id=room.room_id value=room.room_id)
label.s-btn.s-btn__muted.ta-left.truncate(for=room.room_id)
+matrix(room, true)
else
.s-empty-state.p8 All Matrix rooms are linked.
input(type="hidden" name="guild_id" value=guild_id)
div
button.s-btn.s-btn__icon.s-btn__filled#link-button
!= icons.Icons.IconMerge
= ` Link`
h3.mt32.fs-category Unlink server
form.s-card.d-flex.gx16.ai-center(method="post" action=rel("/api/unlink-space"))
input(type="hidden" name="guild_id" value=guild.id)
.fl-grow1.s-prose.s-prose__sm.lh-lg
p.fc-medium.
Not using this bridge, or just made a mistake? You can unlink the whole server and all its channels.#[br]
This may take a minute to process. Please be patient and wait until the page refreshes.
div
button.s-btn.s-btn__icon.s-btn__danger.s-btn__outlined(cx-prevent-default hx-post=rel("/api/unlink-space") hx-confirm="Do you want to unlink this server and all its channels?\nIt may take a minute to clean up Matrix resources." hx-indicator="this" hx-disabled-elt="this")
!= icons.Icons.IconUnsync
span.ml4= ` Unlink`
if space_id
details.mt48
summary Debug room list
.d-grid.grid__2.gx24
div
h3.mt24 Channels
p Channels are read from the channel_room table and then merged with the discord.channels memory cache to make the merged list. Anything in memory cache that's not in channel_room is considered unlinked.
div
h3.mt24 Rooms
p Rooms use the same merged list as channels, based on augmented channel_room data. Then, rooms are read from the space. Anything in the space that's not merged is considered unlinked.
div
h3.mt24 Unavailable channels: Deleted from Discord
.s-card.p0
ul.my8.ml24
each row in removedUncachedChannels
li: a(href=`https://discord.com/channels/${guild_id}/${row.id}`)= row.nick || row.name
h3.mt24 Unavailable channels: Wrong type
.s-card.p0
ul.my8.ml24
each row in removedWrongTypeChannels
li: a(href=`https://discord.com/channels/${guild_id}/${row.id}`) (#{row.type}) #{row.name}
h3.mt24 Unavailable channels: Discord bot can't access
.s-card.p0
ul.my8.ml24
each row in removedPrivateChannels
li: a(href=`https://discord.com/channels/${guild_id}/${row.id}`)= row.name
div- // Rooms
h3.mt24 Unavailable rooms: Already linked
.s-card.p0
ul.my8.ml24
each row in removedLinkedRooms
li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name
h3.mt24 Unavailable rooms: Encryption not supported
.s-card.p0
ul.my8.ml24
each row in removedEncryptedRooms
li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name
h3.mt24 Unavailable rooms: Wrong type
.s-card.p0
ul.my8.ml24
each row in removedWrongTypeRooms
li: a(href=`https://matrix.to/#/${row.room_id}`) (#{row.room_type}) #{row.name}
h3.mt24 Unavailable rooms: Archived thread
.s-card.p0
ul.my8.ml24
each row in removedArchivedThreadRooms
li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name
extends includes/template.pug
include includes/default-roles-list.pug
mixin badge-readonly
.s-badge.s-badge__xs.s-badge__icon.s-badge__muted
!= icons.Icons.IconEyeSm
| Read-only
mixin badge-private
.s-badge.s-badge__xs.s-badge__icon.s-badge__warning
!= icons.Icons.IconLockSm
| Private
mixin discord(channel, radio=false)
//- Previously, we passed guild.roles as the second parameter, but this doesn't quite match Discord's behaviour. See issue #42 for why this was changed.
//- Basically we just want to assign badges based on the channel overwrites, without considering the guild's base permissions. /shrug
- let permissions = dUtils.getPermissions(guild_id, [], [{id: guild_id, name: "@everyone", permissions: 1<<10 | 1<<11}], null, channel.permission_overwrites)
.s-user-card.s-user-card__small
if !dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.ViewChannel)
!= icons.Icons.IconLock
else if channel.type === 5
!= icons.Icons.IconBullhorn
else if channel.type === 2
!= icons.Icons.IconPhone
else if channel.type === 11 || channel.type === 12
!= icons.Icons.IconCollection
else
include includes/hash.svg
.s-user-card--info.ws-nowrap
if radio
= channel.name
else
.s-user-card--link.fs-body1
a(href=`https://discord.com/channels/${channel.guild_id}/${channel.id}`)= channel.name
if channel.parent_id
.s-user-card--location= discord.channels.get(channel.parent_id).name
if !dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.ViewChannel)
+badge-private
else if !dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.SendMessages)
+badge-readonly
mixin matrix(row, radio=false, badge="")
.s-user-card.s-user-card__small
!= icons.Icons.IconMessage
.s-user-card--info.ws-nowrap
if radio
= row.nick || row.name
else
.s-user-card--link.fs-body1
a(href=`https://matrix.to/#/${row.room_id}`)= row.nick || row.name
if row.join_rule === "invite"
+badge-private
block body
.s-page-title.mb24
h1.s-page-title--header= guild.name
.d-flex.g16(class="sm:fw-wrap")
.fl-grow1
h2.fs-headline1 Invite a Matrix user
form.d-grid.g-af-column.gy4.gx8.jc-start(method="post" action=rel("/api/invite") hx-post=rel("/api/invite") hx-trigger="submit" hx-swap="none" hx-on::after-request="if (event.detail.successful) this.reset()" hx-disabled-elt="input, button" hx-indicator="#invite-button")
label.s-label(for="mxid") Matrix ID
input.fl-grow1.s-input.wmx3#mxid(name="mxid" required placeholder="@user:example.org" pattern="@([^:]+):([a-z0-9:\\-]+\\.[a-z0-9.:\\-]+)")
label.s-label(for="permissions") Permissions
.s-select
select#permissions(name="permissions")
option(value="default") Default
option(value="moderator") Moderator
option(value="admin") Admin
input(type="hidden" name="guild_id" value=guild_id)
.grid--row-start2
button.s-btn.s-btn__filled#invite-button Invite
div
.s-card.d-flex.ai-center.jc-center(style="min-width: 132px; min-height: 132px;")
button.s-btn(class=space_id ? "s-btn__muted" : "s-btn__filled" hx-get=rel(`/qr?guild_id=${guild_id}`) hx-indicator="closest button" hx-swap="outerHTML" hx-disabled-elt="this") Show QR
if space_id
h2.mt48.fs-headline1 Server settings
h3.mt32.fs-category How Matrix users join
span#privacy-level-loading
.s-card
form(hx-post=rel("/api/privacy-level") hx-trigger="change" hx-indicator="#privacy-level-loading" hx-disabled-elt="input")
input(type="hidden" name="guild_id" value=guild_id)
.s-toggle-switch.s-toggle-switch__multiple.s-toggle-switch__incremental.d-grid.gx16.ai-center(style="grid-template-columns: auto 1fr")
input(type="radio" name="privacy_level" value="directory" id="privacy-level-directory" checked=(privacy_level === 2))
label.d-flex.gx8.jc-center.grid--row-start3(for="privacy-level-directory")
!= icons.Icons.IconPlusSm
!= icons.Icons.IconInternationalSm
.fl-grow1 Directory
input(type="radio" name="privacy_level" value="link" id="privacy-level-link" checked=(privacy_level === 1))
label.d-flex.gx8.jc-center.grid--row-start2(for="privacy-level-link")
!= icons.Icons.IconPlusSm
!= icons.Icons.IconLinkSm
.fl-grow1 Link
input(type="radio" name="privacy_level" value="invite" id="privacy-level-invite" checked=(privacy_level === 0))
label.d-flex.gx8.jc-center.grid--row-start1(for="privacy-level-invite")
svg.svg-icon(width="14" height="14" viewBox="0 0 14 14")
!= icons.Icons.IconLockSm
.fl-grow1 Invite
p.s-description.m0 In-app direct invite from another user
p.s-description.m0 Shareable invite links, like Discord
p.s-description.m0 Publicly listed in directory, like Discord server discovery
h3.mt32.fs-category Default roles
.s-card
form(method="post" action=rel("/api/default-roles") hx-post=rel("/api/default-roles") hx-sync="this:drop" hx-indicator="#add-role-loading" hx-target="#default-roles-list" hx-select="#default-roles-list" hx-swap="outerHTML")#default-roles
input(type="hidden" name="guild_id" value=guild_id)
.d-flex.fw-wrap.g4
.s-tag.s-tag__md.fs-body1.s-tag__required @everyone
+default-roles-list(guild, guild_id)
button(type="button" popovertarget="role-add").s-btn__dropdown.s-tag.s-tag__md.fs-body1.p0
.s-tag--dismiss.m1
!= icons.Icons.IconPlusSm
#role-add.s-popover(popover style="display: revert").ws2.px0.py4.bs-lg.overflow-visible
.s-popover--arrow.s-popover--arrow__tc
+add-roles-menu(guild, guild_id)
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
.s-card.d-grid.px0.g16
form.d-flex.ai-center.g16
#url-preview-loading.p8
- let value = !!select("guild_space", "url_preview", {guild_id}).pluck().get()
input(type="hidden" name="guild_id" value=guild_id)
input.s-toggle-switch#url-preview(name="url_preview" type="checkbox" hx-post=rel("/api/url-preview") hx-indicator="#url-preview-loading" hx-disabled-elt="this" checked=value autocomplete="off")
label.s-label.fl-grow1(for="url-preview")
| Show Discord's URL previews on Matrix
p.s-description Shows info about links posted to chat. Discord's previews are generally better quality than Synapse's, especially for social media and videos.
form.d-flex.ai-center.g16
#presence-loading.p8
- value = !!select("guild_space", "presence", {guild_id}).pluck().get()
input(type="hidden" name="guild_id" value=guild_id)
input.s-toggle-switch#presence(name="presence" type="checkbox" hx-post=rel("/api/presence") hx-indicator="#presence-loading" hx-disabled-elt="this" checked=value autocomplete="off")
label.s-label(for="presence")
| Show online statuses on Matrix
p.s-description This might cause lag on really big Discord servers.
form.d-flex.ai-center.g16
#webhook-profile-loading.p8
- value = !!select("guild_space", "webhook_profile", {guild_id}).pluck().get()
input(type="hidden" name="guild_id" value=guild_id)
input.s-toggle-switch#webhook-profile(name="webhook_profile" type="checkbox" hx-post=rel("/api/webhook-profile") hx-indicator="#webhook-profile-loading" hx-disabled-elt="this" checked=value autocomplete="off")
label.s-label(for="webhook-profile")
| Create persistent Matrix sims for webhooks
p.s-description Useful when using other Discord bridges. Otherwise, not ideal, as sims will clutter the Matrix user list and will never be cleaned up.
if space_id
h2.mt48.fs-headline1 Channel setup
h3.mt32.fs-category Linked channels
.s-card.bs-sm.p0
form.s-table-container(method="post" action=rel("/api/unlink"))
input(type="hidden" name="guild_id" value=guild_id)
table.s-table.s-table__bx-simple
each row in linkedChannelsWithDetails
tr
td.w40: +discord(row.channel)
td.p2: button.s-btn.s-btn__muted.s-btn__xs(name="channel_id" cx-prevent-default hx-post=rel("/api/unlink") hx-confirm="Do you want to unlink these channels?\nIt may take a moment to clean up Matrix resources." value=row.channel.id hx-indicator="this" hx-disabled-elt="this")!= icons.Icons.IconLinkSm
td: +matrix(row)
else
tr
td(colspan="3")
.s-empty-state No channels linked between Discord and Matrix yet...
h3.fs-category.mt32 Auto-create
.s-card.d-grid.px0
form.d-flex.ai-center.g16
#autocreate-loading.p8
- let value = !!select("guild_active", "autocreate", {guild_id}).pluck().get()
input(type="hidden" name="guild_id" value=guild_id)
input.s-toggle-switch#autocreate(name="autocreate" type="checkbox" hx-post=rel("/api/autocreate") hx-indicator="#autocreate-loading" hx-disabled-elt="this" checked=value autocomplete="off")
label.s-label.fl-grow1(for="autocreate")
| Create new Matrix rooms automatically
p.s-description If you want, OOYE can automatically create new Matrix rooms and link them when an unlinked Discord channel is spoken in.
if space_id
h3.mt32.fs-category Manually link channels
form.d-flex.g16.ai-start(hx-post=rel("/api/link") hx-trigger="submit" hx-disabled-elt="input, button" hx-indicator="#link-button")
.fl-grow2.s-btn-group.fd-column.w40
each channel in unlinkedChannels
input.s-btn--radio(type="radio" name="discord" required id=channel.id value=channel.id)
label.s-btn.s-btn__muted.ta-left.truncate(for=channel.id)
+discord(channel, true, "Announcement")
else
.s-empty-state.p8 All Discord channels are linked.
.fl-grow1.s-btn-group.fd-column.w30
each room in unlinkedRooms
input.s-btn--radio(type="radio" name="matrix" required id=room.room_id value=room.room_id)
label.s-btn.s-btn__muted.ta-left.truncate(for=room.room_id)
+matrix(room, true)
else
.s-empty-state.p8 All Matrix rooms are linked.
input(type="hidden" name="guild_id" value=guild_id)
div
button.s-btn.s-btn__icon.s-btn__filled#link-button
!= icons.Icons.IconMerge
= ` Link`
h3.mt32.fs-category Unlink server
form.s-card.d-flex.gx16.ai-center(method="post" action=rel("/api/unlink-space"))
input(type="hidden" name="guild_id" value=guild.id)
.fl-grow1.s-prose.s-prose__sm.lh-lg
p.fc-medium.
Not using this bridge, or just made a mistake? You can unlink the whole server and all its channels.#[br]
This may take a minute to process. Please be patient and wait until the page refreshes.
div
button.s-btn.s-btn__icon.s-btn__danger.s-btn__outlined(cx-prevent-default hx-post=rel("/api/unlink-space") hx-confirm="Do you want to unlink this server and all its channels?\nIt may take a minute to clean up Matrix resources." hx-indicator="this" hx-disabled-elt="this")
!= icons.Icons.IconUnsync
span.ml4= ` Unlink`
if space_id
details.mt48
summary Debug room list
.d-grid.grid__2.gx24
div
h3.mt24 Channels
p Channels are read from the channel_room table and then merged with the discord.channels memory cache to make the merged list. Anything in memory cache that's not in channel_room is considered unlinked.
div
h3.mt24 Rooms
p Rooms use the same merged list as channels, based on augmented channel_room data. Then, rooms are read from the space. Anything in the space that's not merged is considered unlinked.
div
h3.mt24 Unavailable channels: Deleted from Discord
.s-card.p0
ul.my8.ml24
each row in removedUncachedChannels
li: a(href=`https://discord.com/channels/${guild_id}/${row.id}`)= row.nick || row.name
h3.mt24 Unavailable channels: Wrong type
.s-card.p0
ul.my8.ml24
each row in removedWrongTypeChannels
li
a(href=`https://discord.com/channels/${guild_id}/${row.id}`) (#{row.type}) #{row.name}
span |
a(href=rel(`/explain?type=${row.type}`)) Why?
h3.mt24 Unavailable channels: Discord bot can't access
.s-card.p0
ul.my8.ml24
each row in removedPrivateChannels
li: a(href=`https://discord.com/channels/${guild_id}/${row.id}`)= row.name
div- // Rooms
h3.mt24 Unavailable rooms: Already linked
.s-card.p0
ul.my8.ml24
each row in removedLinkedRooms
li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name
h3.mt24 Unavailable rooms: Encryption not supported
.s-card.p0
ul.my8.ml24
each row in removedEncryptedRooms
li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name
h3.mt24 Unavailable rooms: Root space
.s-card.p0
ul.my8.ml24
each row in removedRootSpaceRooms
li: a(href=`https://matrix.to/#/${row.room_id}`) (#{row.room_type}) #{row.name}
h3.mt24 Unavailable rooms: Archived thread
p If you still want to link with any of these rooms (eg. you accidentally unlinked it and want to bring it back, or you're migrating from a different bridge that happens to use OOYE's prefixes), please remove the [⛓️] or [🔒⛓️] prefix in Matrix's room settings and refresh the page.
.s-card.p0
ul.my8.ml24
each row in removedArchivedThreadRooms
li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name

View file

@ -65,7 +65,8 @@ mixin define-themed-button(name, theme)
doctype html
html(lang="en")
head
title Out Of Your Element
block title
title Out Of Your Element
<meta name="viewport" content="width=device-width, initial-scale=1">
link(rel="stylesheet" type="text/css" href=rel("/static/stacks.min.css"))
//- Please use responsibly!!!!!

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

@ -38,6 +38,9 @@ const schema = {
}),
inviteNonce: z.object({
nonce: z.string()
}),
explain: z.object({
type: z.string()
})
}
@ -53,6 +56,27 @@ function getAPI(event) {
/** @type {LRUCache<string, string>} nonce to guild id */
const validNonce = new LRUCache({max: 200})
/**
* TYPING = Channels on which Discord messages can be sent. They should be bridgeable to anything other than an m.space (because if it did end up as a space, no one would be able to actually see the text messages sent there).
* SPACE = Channels on which Discord messages cannot be received. They should be bridgeable to m.space only (because not only does m.space make sending messages impossible on any sane client (thus preventing Discord-caused errors), but it also just-so-happens that both currently-existing message-unsupporting channel types (Categories and School hubs) are sort of "indexes", which fits nicely to m.space).
* MIXED = Forum-like channels. They can be bridged to both m.space and anything other than an m.space - hence the name.
* @type {Map<DiscordTypes.ChannelType, {type: "TYPING"|"MIXED"|"SPACE", humanName:string, unsupported?: string}>}*/
const linkRules = new Map([
[0, {type: "TYPING", humanName:"Normal text channels"}],
[1, {type: "TYPING", humanName:"Normal DMs", unsupported: "OOYE won't support DMs until a good way of doing it can be figured out. Please see https://gitdab.com/cadence/out-of-your-element#caveats for more."}],
[2, {type: "TYPING", humanName:"Normal VCs"}],
[3, {type: "TYPING", humanName:"Group DMs", unsupported: "OOYE won't support DMs until a good way of doing it can be figured out. Please see https://gitdab.com/cadence/out-of-your-element#caveats for more."}],
[4, {type: "SPACE", humanName:"Categories", unsupported: "There is no concept of categories on Matrix."}], //...at least officially. In practice, some clients will render sub-spaces as categories. TODO: Bridge categories to sub-spaces.
[5, {type: "TYPING", humanName:"Announcement text channels"}],
[10, {type: "TYPING", humanName:"Announcement threads"}],
[11, {type: "TYPING", humanName:"Normal threads"}],
[12, {type: "TYPING", humanName:"Private threads"}],
[13, {type: "TYPING", humanName:"Stage VCs"}],
[14, {type: "SPACE", humanName:"School hubs", unsupported: "Bots cannot be members of school hubs. How in the sweet hell did you manage to put OOYE on one, anyway??? ~~Emma, please stop breaking Discord API in cursed ways again.~~"}],
[15, {type: "MIXED", humanName:"Normal forums"}],
[16, {type: "MIXED", humanName:"Media forums"}],
])
/**
* @param {{type: number, parent_id?: string | null, position?: number}} channel
* @param {Map<string, {type: number, parent_id?: string | null, position?: number}>} channels
@ -94,8 +118,9 @@ function getPosition(channel, channels) {
* @param {DiscordTypes.APIGuild} guild
* @param {Ty.R.Hierarchy[]} rooms
* @param {string[]} roles
* @param {string?} space
*/
function getChannelRoomsLinks(guild, rooms, roles) {
function getChannelRoomsLinks(guild, rooms, roles, space) {
let channelIDs = discord.guildChannelMap.get(guild.id)
assert(channelIDs)
@ -112,7 +137,10 @@ function getChannelRoomsLinks(guild, rooms, roles) {
let unlinkedChannelIDs = channelIDs.filter(c => !linkedChannelIDs.includes(c))
/** @type {DiscordTypes.APIGuildChannel[]} */ // @ts-ignore
let unlinkedChannels = unlinkedChannelIDs.map(c => discord.channels.get(c))
let removedWrongTypeChannels = dUtils.filterTo(unlinkedChannels, c => c && [0, 5].includes(c.type))
let removedWrongTypeChannels = dUtils.filterTo(unlinkedChannels, c => {
const rule = linkRules.get(c?.type)
return rule && !rule.unsupported
})
let removedPrivateChannels = dUtils.filterTo(unlinkedChannels, c => {
const permissions = dUtils.getPermissions(guild.id, roles, guild.roles, botID, c["permission_overwrites"])
return dUtils.hasSomePermissions(permissions, ["Administrator", "ViewChannel"])
@ -122,7 +150,7 @@ function getChannelRoomsLinks(guild, rooms, roles) {
let linkedRoomIDs = linkedChannels.map(c => c.room_id)
let unlinkedRooms = [...rooms]
let removedLinkedRooms = dUtils.filterTo(unlinkedRooms, r => !linkedRoomIDs.includes(r.room_id))
let removedWrongTypeRooms = dUtils.filterTo(unlinkedRooms, r => !r.room_type)
let removedRootSpaceRooms = dUtils.filterTo(unlinkedRooms, r => r.room_id !== space)
let removedEncryptedRooms = dUtils.filterTo(unlinkedRooms, r => !r.encryption && !r["im.nheko.summary.encryption"])
// https://discord.com/developers/docs/topics/threads#active-archived-threads
// need to filter out linked archived threads from unlinkedRooms, will just do that by comparing against the name
@ -130,7 +158,7 @@ function getChannelRoomsLinks(guild, rooms, roles) {
return {
linkedChannelsWithDetails, unlinkedChannels, unlinkedRooms,
removedUncachedChannels, removedWrongTypeChannels, removedPrivateChannels, removedLinkedRooms, removedWrongTypeRooms, removedArchivedThreadRooms, removedEncryptedRooms
removedUncachedChannels, removedWrongTypeChannels, removedPrivateChannels, removedLinkedRooms, removedRootSpaceRooms, removedArchivedThreadRooms, removedEncryptedRooms
}
}
@ -171,17 +199,25 @@ as.router.get("/guild", defineEventHandler(async event => {
// Easy mode guild that hasn't been linked yet - need to remove elements that would require an existing space
if (!row.space_id) {
const links = getChannelRoomsLinks(guild, [], roles)
const links = getChannelRoomsLinks(guild, [], roles, row.space_id)
return pugSync.render(event, "guild.pug", {guild, guild_id, ...links, ...row})
}
// Linked guild
const api = getAPI(event)
const rooms = await api.getFullHierarchy(row.space_id)
const links = getChannelRoomsLinks(guild, rooms, roles)
const links = getChannelRoomsLinks(guild, rooms, roles, row.space_id)
return pugSync.render(event, "guild.pug", {guild, guild_id, ...links, ...row})
}))
as.router.get("/explain", defineEventHandler(async event => {
const {type} = await getValidatedQuery(event, schema.explain.parse)
const rule = linkRules.get(Number.parseInt(type))
if (!rule) return pugSync.render(event, "explain.pug", {msg: "You cannot bridge to type-" + type + " channels because OOYE doesn't even know what they are."})
else if (rule.unsupported) return pugSync.render(event, "explain.pug", {msg: "You cannot bridge to " + rule.humanName + " (type-" + type + " channels) because: " + rule.unsupported})
else return pugSync.render(event, "explain.pug", {msg: "You can bridge to " + rule.humanName + " (type-" + type + " channels) just fine. Why are you even here?"})
}))
as.router.get("/qr", defineEventHandler(async event => {
const {guild_id} = await getValidatedQuery(event, schema.qr.parse)
const managed = await auth.getManagedGuilds(event)
@ -267,3 +303,4 @@ as.router.post("/api/invite", defineEventHandler(async event => {
module.exports._getPosition = getPosition
module.exports.getInviteTargetSpaces = getInviteTargetSpaces
module.exports.linkRules = linkRules

View file

@ -173,8 +173,9 @@ as.router.post("/api/link", defineEventHandler(async event => {
const row = from("channel_room").select("channel_id", "room_id").and("WHERE channel_id = ? OR room_id = ?").get(channel.id, parsedBody.matrix)
if (row) throw createError({status: 400, message: "Bad Request", data: `Channel ID ${row.channel_id} or room ID ${parsedBody.matrix} are already bridged and cannot be reused`})
// Check room is part of the guild's space
// Check whether the room is an actual room or a space, and if it's a part of the guild's space
let foundRoom = false
let foundSpace = false
/** @type {string[]?} */
let foundVia = null
for await (const room of api.generateFullHierarchy(spaceID)) {
@ -186,13 +187,21 @@ as.router.post("/api/link", defineEventHandler(async event => {
}
// When finding a room during iteration, see if it was the requested room (to confirm that the room is in the space)
if (room.room_id === parsedBody.matrix && !room.room_type) {
if (room.room_id === parsedBody.matrix) {
foundRoom = true
// And also, now that we know that the room object is our intended room - we can test for its type.
if (room.room_type && room.room_type === "m.space") foundSpace = true
}
if (foundRoom && foundVia) break
}
if (!foundRoom) throw createError({status: 400, message: "Bad Request", data: "Matrix room needs to be part of the bridged space"})
// Ensure link rules are upheld
const rule = guildRoute.linkRules.get(channel.type)
if (!rule || rule.unsupported) throw createError({status:400, message: "Bad Request", data: "You cannot bridge to " + (rule ? (rule.humanName+" (type-"+channel.type+" channels)") : ("unknown-type ("+channel.type+") channels")) + " because: " + (rule ? rule.unsupported : "OOYE doesn't even know what they are.")})
else if (foundSpace && rule.type === "TYPING") throw createError({status: 400, message: "Bad Request", data: "Matrix room cannot be of type m.space when bridging to "+rule.humanName})
else if (!foundSpace && rule.type === "SPACE") throw createError({status: 400, message: "Bad Request", data: "Matrix room must be of type m.space when bridging to "+rule.humanName})
// Check room exists and bridge is joined
try {

View file

@ -125,6 +125,7 @@ as.router.get("/icon.png", defineEventHandler(async event => {
pugSync.createRoute(as.router, "/ok", "ok.pug")
sync.require("./routes/agi")
sync.require("./routes/download-matrix")
sync.require("./routes/download-discord")
sync.require("./routes/guild-settings")

View file

@ -95,12 +95,14 @@ WITH a (message_id, channel_id) AS (VALUES
('1381212840957972480', '112760669178241024'),
('1401760355339862066', '112760669178241024'),
('1439351590262800565', '1438284564815548418'),
('1404133238414376971', '112760669178241024'))
('1404133238414376971', '112760669178241024'),
('1162005314908999790', '1100319550446252084'))
SELECT message_id, max(historical_room_index) as historical_room_index FROM a INNER JOIN historical_channel_room ON historical_channel_room.reference_channel_id = a.channel_id GROUP BY message_id;
INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part, reaction_part, source) VALUES
('$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg', 'm.room.message', 'm.text', '1126786462646550579', 0, 0, 1),
('$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4', 'm.room.message', 'm.text', '1128118177155526666', 0, 0, 0),
('$fdD9o7NxMA4VPexlAiIx2CB9JbsiGhJeyJgnZG7U5xg', 'm.room.message', 'm.text', '1162005314908999790', 0, 0, 1),
('$zXSlyI78DQqQwwfPUSzZ1b-nXzbUrCDljJgnGDdoI10', 'm.room.message', 'm.text', '1141619794500649020', 0, 0, 1),
('$fdD9OZ55xg3EAsfvLZza5tMhtjUO91Wg3Otuo96TplY', 'm.room.message', 'm.text', '1141206225632112650', 0, 0, 1),
('$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA', 'm.room.message', 'm.text', '1141501302736695316', 0, 1, 1),

View file

@ -175,4 +175,5 @@ file._actuallyUploadDiscordFileToMxc = function(url, res) { throw new Error(`Not
require("../src/web/routes/log-in-with-matrix.test")
require("../src/web/routes/oauth.test")
require("../src/web/routes/password.test")
require("../src/agi/generator.test")
})()