Compare commits

..

122 commits

Author SHA1 Message Date
f62468511b unfuck my mess
Why did I make it this way???

#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
91bce76fc8 Use HTML to strip per-message profile fallback 2026-03-29 15:41:23 +13:00
nemesio65
12f4103870 d2m: Create voice channels as call rooms 2026-03-28 11:46:08 +13:00
e28eac6bfa Update domino 2026-03-28 11:45:51 +13:00
857fb7583b v3.5 2026-03-27 19:20:04 +13:00
59012d9613 Fix pinning random messages 2026-03-27 19:13:03 +13:00
953b3e7741 Attach message to error
Apparently this was causing detached logs, so just stop those
complaints if the error isn't being bubbled
2026-03-26 00:16:30 +13:00
8c023cc936 Add ping() function to REPL 2026-03-25 16:24:07 +13:00
e9fe820666 Registration changes should be instant now 2026-03-25 16:22:37 +13:00
f742d8572a MSC4144 minor changes for merge 2026-03-25 03:10:54 +00:00
Bea
8224ed5341 feat(discord): show per-message profile info in matrix info command 2026-03-25 03:10:54 +00:00
Bea
0b513b7ee0 fix(m2d): implement MSC4144 avatar clearing algorithm
- Empty string "" -> undefined (Discord uses default avatar)
- Valid MXC URI -> convert to public URL
- Omitted/null -> keep member avatar
2026-03-25 03:10:54 +00:00
Bea
07ec9832b2 fix(m2d): only use unstable com.beeper.per_message_profile prefix 2026-03-25 03:10:54 +00:00
Bea
a8b7d64e91 feat(m2d): strip per-message profile fallbacks from message content
Remove data-mx-profile-fallback elements from formatted_body and
displayname prefix from plain body when per-message profile is used.
2026-03-25 03:10:54 +00:00
Bea
41692b11ff feat(m2d): support MSC4144 per-message profiles
Override webhook username and avatar_url from m.per_message_profile
(and unstable com.beeper.per_message_profile) when present.
The stable key takes priority over the unstable prefix.
2026-03-25 03:10:54 +00:00
d8c0a947f2 Automatically reload registration 2026-03-25 15:39:26 +13:00
5c9e569a2a Support channel follow messages 2026-03-25 15:29:18 +13:00
201814e9f4 Update dependencies 2026-03-23 21:22:33 +13:00
7367fb3b65 Fix weird background clipping on icons 2026-03-20 01:37:22 +13:00
c75e87f403 Stream files in serveStatic for lower memory use 2026-03-20 01:27:34 +13:00
8b9d8ec0cc Widen newline tag detection 2026-03-20 00:59:52 +13:00
0dac3d2898 Internal language adjusted 2026-03-20 00:53:09 +13:00
9dbd871e0b Defuse mentions in m->d reply if client says so 2026-03-20 00:42:51 +13:00
8c87d93011 Remove member repetition bugfixes 2026-03-20 00:17:40 +13:00
e8d9a5e4ae Script to remove uncached bridged users 2026-03-19 14:30:19 +13:00
876d91fbf4 Remove sims when the Discord user leaves 2026-03-19 14:30:10 +13:00
d2557f73bb Let sims rejoin after being unbanned
The sim_member cache was getting stuck, so OOYE thought it was already
in the room when it actually wasn't.
2026-03-19 13:35:53 +13:00
f8896dce7f Type fixes in set-presence.js 2026-03-19 13:34:19 +13:00
5b04b5d712 Reformat /plu/ral emulated replies 2026-03-19 13:33:50 +13:00
711e024caa Update dependencies 2026-03-17 14:02:11 +13:00
f1b111a8a4 Refuse to operate on encrypted rooms
- Refuse to link to encrypted rooms
- Do not show encrypted rooms as link candidates (if server supports)
- Reject invites to encrypted rooms with message
- Unbridge and leave room if it becomes encrypted
2026-03-17 12:35:42 +13:00
d3afa728ed Fix m->d posting embeds even when setting is off 2026-03-15 20:53:41 +13:00
6716b432ba Wait for response before next click (don't queue) 2026-03-15 01:33:29 +13:00
3365023fe3 Sync default roles changes immediately 2026-03-15 01:21:38 +13:00
e6c3013993 Make default permission setting functional 2026-03-14 20:23:43 +13: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
cb4e8df91e Fix package-lock 2026-03-14 14:34:59 +13:00
f90cdfdbb5 Update dependencies, make stream-type independent 2026-03-14 14:25:48 +13:00
ff022e8793 Combine additional embed images into same event 2026-03-13 11:12:44 +13:00
99f4c52beb Fix attempting to follow an upgrade path twice 2026-03-13 10:17:04 +13:00
5f768fee01 d->m: Don't guess mentions in code blocks 2026-03-12 16:23:22 +13:00
6ca1b836e1 Add more debugging information 2026-03-11 12:38:05 +13:00
Bea
ada3933d9c Backfill: Create new rooms when needed
This updates the backfill script to attempt to create rooms for unbridged rooms, rather than bombing out that the room isn't already bridged.

Co-authored-by: Cadence Ember <cadence@disroot.org>
Reviewed-on: cadence/out-of-your-element#75
Co-authored-by: Bea <beanie@theargo.space>
Co-committed-by: Bea <beanie@theargo.space>
2026-03-09 00:22:41 +00:00
Bea
f5ee130463 Handle expired invites & fix test registration (#73)
This PR addresses a bridge crash discovered while backfilling old channels, alongside a wee QoL fix for the test suite.

* **Expired Events (`d2m`):** Wraps Discord scheduled event/invite link lookups in a try-catch block. If a link is expired (404 or Discord error 10006), the bridge now posts a fallback `m.notice` rather than throwing an error and halting message conversion.
* **Test Suite Setup:** Updates `test.js` to initialize the mock registration object using `getTemplateRegistration()` preventing test runner crashes when running without a local `registration.yaml` file.

Co-authored-by: Cadence Ember <cadence@disroot.org>
Reviewed-on: cadence/out-of-your-element#73
Co-authored-by: Bea <beanie@theargo.space>
Co-committed-by: Bea <beanie@theargo.space>
2026-03-08 22:11:28 +00:00
cd8549da38 Fix sticker tests and coverage 2026-03-08 23:32:36 +13:00
f7a5b2d74c Update tryToCatch dependency and usages 2026-03-08 22:36:05 +13:00
6a2606cbdb Add UI for defining default roles 2026-03-08 22:35:10 +13:00
9eaa85c072 Add /invite Matrix command to get Discord invite 2026-03-08 22:34:51 +13:00
74c0c28cf4 Update dependencies 2026-03-08 22:34:04 +13: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: #9
2026-03-02 15:21:12 +00:00
e807d1fbf2 Merge branch 'fuckery' into main 2026-03-02 15:20:07 +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: #8
2026-03-02 13:00:11 +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
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
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
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
79 changed files with 3131 additions and 1041 deletions

View file

@ -89,15 +89,14 @@ Whether you read those or not, I'm more than happy to help you 1-on-1 with codin
# Dependency justification # Dependency justification
Total transitive production dependencies: 134 Total transitive production dependencies: 144
### <font size="+2">🦕</font> ### <font size="+2">🦕</font>
* (31) better-sqlite3: SQLite is the best database, and this is the best library for it. * (35) better-sqlite3: SQLite is the best database, and this is the best library for it.
* (27) @cloudrac3r/pug: Language for dynamic web pages. This is my fork. (I released code that hadn't made it to npm, and removed the heavy pug-filters feature.) * (29) sharp: Image resizing and compositing. OOYE needs this for the emoji sprite sheets. It has libvips prebuilts for each platform.
* (16) stream-mime-type@1: This seems like the best option. Version 1 is used because version 2 is ESM-only. * (26) @cloudrac3r/pug: Language for dynamic web pages. This is my fork. (I released code that hadn't made it to npm, and removed the heavy pug-filters feature.)
* (9) h3: Web server. OOYE needs this for the appservice listener, authmedia proxy, self-service, and more. * (9) h3: Web server. OOYE needs this for the web UI, appservice listener, authmedia proxy, and more.
* (11) sharp: Image resizing and compositing. OOYE needs this for the emoji sprite sheets.
### <font size="-1">🪱</font> ### <font size="-1">🪱</font>
@ -108,6 +107,7 @@ Total transitive production dependencies: 134
* (0) @cloudrac3r/in-your-element: This is my Matrix Appservice API library. It depends on h3 and zod, which are already pulled in by OOYE. * (0) @cloudrac3r/in-your-element: This is my Matrix Appservice API library. It depends on h3 and zod, which are already pulled in by OOYE.
* (0) @cloudrac3r/mixin-deep: This is my fork. (It fixes a bug in regular mixin-deep.) * (0) @cloudrac3r/mixin-deep: This is my fork. (It fixes a bug in regular mixin-deep.)
* (0) @cloudrac3r/pngjs: Lottie stickers are converted to bitmaps with the vendored Rlottie WASM build, then the bitmaps are converted to PNG with pngjs. * (0) @cloudrac3r/pngjs: Lottie stickers are converted to bitmaps with the vendored Rlottie WASM build, then the bitmaps are converted to PNG with pngjs.
* (0) @cloudrac3r/stream-type: Determine type of Matrix files that don't specify it in info. Switched from stream-mime-type to this.
* (0) @cloudrac3r/turndown: This HTML-to-Markdown converter looked the most suitable. I forked it to change the escaping logic to match the way Discord works. * (0) @cloudrac3r/turndown: This HTML-to-Markdown converter looked the most suitable. I forked it to change the escaping logic to match the way Discord works.
* (3) @stackoverflow/stacks: Stack Overflow design language and icons. * (3) @stackoverflow/stacks: Stack Overflow design language and icons.
* (0) ansi-colors: Helps with interactive prompting for the initial setup, and it's already pulled in by enquirer. * (0) ansi-colors: Helps with interactive prompting for the initial setup, and it's already pulled in by enquirer.
@ -115,12 +115,12 @@ Total transitive production dependencies: 134
* (0) cloudstorm: Discord gateway library with bring-your-own-caching that I trust. * (0) cloudstorm: Discord gateway library with bring-your-own-caching that I trust.
* (0) discord-api-types: Bitfields needed at runtime and types needed for development. * (0) discord-api-types: Bitfields needed at runtime and types needed for development.
* (0) domino: DOM implementation that's already pulled in by turndown. * (0) domino: DOM implementation that's already pulled in by turndown.
* (1) enquirer: Interactive prompting for the initial setup rather than forcing users to edit YAML non-interactively. * (2) enquirer: Interactive prompting for the initial setup rather than forcing users to edit YAML non-interactively.
* (0) entities: Looks fine. No dependencies. * (0) entities: Looks fine. No dependencies.
* (0) get-relative-path: Looks fine. No dependencies. * (0) get-relative-path: Looks fine. No dependencies.
* (1) heatsync: Module hot-reloader that I trust. * (1) heatsync: Module hot-reloader that I trust.
* (0) lru-cache: For holding unused nonce in memory and letting them be overwritten later if never used. * (0) lru-cache: For holding unused nonce in memory and letting them be overwritten later if never used.
* (0) mime-type: File extension to mime type mapping that's already pulled in by stream-mime-type. * (1) mime-types: List of mime type mappings. Needed to serve static files.
* (0) prettier-bytes: It does what I want and has no dependencies. * (0) prettier-bytes: It does what I want and has no dependencies.
* (0) snowtransfer: Discord API library with bring-your-own-caching that I trust. * (0) snowtransfer: Discord API library with bring-your-own-caching that I trust.
* (0) try-to-catch: Not strictly necessary, but it's already pulled in by supertape, so I may as well. * (0) try-to-catch: Not strictly necessary, but it's already pulled in by supertape, so I may as well.

1109
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{ {
"name": "out-of-your-element", "name": "out-of-your-element",
"version": "3.4.0", "version": "3.5.0",
"description": "A bridge between Matrix and Discord", "description": "A bridge between Matrix and Discord",
"main": "index.js", "main": "index.js",
"repository": { "repository": {
@ -19,35 +19,35 @@
}, },
"dependencies": { "dependencies": {
"@chriscdn/promise-semaphore": "^3.0.1", "@chriscdn/promise-semaphore": "^3.0.1",
"@cloudrac3r/discord-markdown": "^2.6.10", "@cloudrac3r/discord-markdown": "^2.7.0",
"@cloudrac3r/giframe": "^0.4.3", "@cloudrac3r/giframe": "^0.4.3",
"@cloudrac3r/html-template-tag": "^5.0.1", "@cloudrac3r/html-template-tag": "^5.0.1",
"@cloudrac3r/in-your-element": "^1.1.1", "@cloudrac3r/in-your-element": "^1.1.1",
"@cloudrac3r/mixin-deep": "^3.0.1", "@cloudrac3r/mixin-deep": "^3.0.1",
"@cloudrac3r/pngjs": "^7.0.3", "@cloudrac3r/pngjs": "^7.0.3",
"@cloudrac3r/pug": "^4.0.4", "@cloudrac3r/pug": "^4.0.4",
"@cloudrac3r/stream-type": "^1.0.0",
"@cloudrac3r/turndown": "^7.1.4", "@cloudrac3r/turndown": "^7.1.4",
"@stackoverflow/stacks": "^2.5.4", "@stackoverflow/stacks": "^2.5.4",
"@stackoverflow/stacks-icons": "^6.0.2", "@stackoverflow/stacks-icons": "^6.0.2",
"ansi-colors": "^4.1.3", "ansi-colors": "^4.1.3",
"better-sqlite3": "^12.2.0", "better-sqlite3": "^12.2.0",
"chunk-text": "^2.0.1", "chunk-text": "^2.0.1",
"cloudstorm": "^0.15.2", "cloudstorm": "^0.17.0",
"discord-api-types": "^0.38.38", "discord-api-types": "^0.38.38",
"domino": "^2.1.6", "domino": "^2.1.6",
"enquirer": "^2.4.1", "enquirer": "^2.4.1",
"entities": "^5.0.0", "entities": "^5.0.0",
"get-relative-path": "^1.0.2", "get-relative-path": "^1.0.2",
"h3": "^1.15.1", "h3": "^1.15.10",
"heatsync": "^2.7.2", "heatsync": "^2.7.2",
"htmx.org": "^2.0.4", "htmx.org": "^2.0.4",
"lru-cache": "^11.0.2", "lru-cache": "^11.0.2",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
"prettier-bytes": "^1.0.4", "prettier-bytes": "^1.0.4",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"snowtransfer": "^0.17.1", "snowtransfer": "^0.17.5",
"stream-mime-type": "^1.0.2", "try-to-catch": "^4.0.5",
"try-to-catch": "^3.0.1",
"uqr": "^0.1.2", "uqr": "^0.1.2",
"xxhash-wasm": "^1.0.2", "xxhash-wasm": "^1.0.2",
"zod": "^4.0.17" "zod": "^4.0.17"
@ -58,7 +58,7 @@
"devDependencies": { "devDependencies": {
"@cloudrac3r/tap-dot": "^2.0.3", "@cloudrac3r/tap-dot": "^2.0.3",
"@types/node": "^22.17.1", "@types/node": "^22.17.1",
"c8": "^10.1.2", "c8": "^11.0.0",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"supertape": "^12.0.12" "supertape": "^12.0.12"
}, },

View file

@ -10,7 +10,6 @@ if (!channelID) {
process.exit(1) process.exit(1)
} }
const assert = require("assert/strict")
const sqlite = require("better-sqlite3") const sqlite = require("better-sqlite3")
const backfill = new sqlite("scripts/backfill.db") const backfill = new sqlite("scripts/backfill.db")
backfill.prepare("CREATE TABLE IF NOT EXISTS backfill (channel_id TEXT NOT NULL, message_id INTEGER NOT NULL, PRIMARY KEY (channel_id, message_id))").run() backfill.prepare("CREATE TABLE IF NOT EXISTS backfill (channel_id TEXT NOT NULL, message_id INTEGER NOT NULL, PRIMARY KEY (channel_id, message_id))").run()
@ -38,12 +37,8 @@ passthrough.select = orm.select
/** @type {import("../src/d2m/event-dispatcher")}*/ /** @type {import("../src/d2m/event-dispatcher")}*/
const eventDispatcher = sync.require("../src/d2m/event-dispatcher") const eventDispatcher = sync.require("../src/d2m/event-dispatcher")
/** @type {import("../src/d2m/actions/create-room")} */
const roomID = passthrough.select("channel_room", "room_id", {channel_id: channelID}).pluck().get() const createRoom = sync.require("../src/d2m/actions/create-room")
if (!roomID) {
console.error("Please choose a channel that's already bridged.")
process.exit(1)
}
;(async () => { ;(async () => {
await discord.cloud.connect() await discord.cloud.connect()
@ -60,6 +55,8 @@ async function event(event) {
if (!channel) return if (!channel) return
const guild_id = event.d.id const guild_id = event.d.id
try {
await createRoom.syncRoom(channelID)
let last = backfill.prepare("SELECT cast(max(message_id) as TEXT) FROM backfill WHERE channel_id = ?").pluck().get(channelID) || "0" let last = backfill.prepare("SELECT cast(max(message_id) as TEXT) FROM backfill WHERE channel_id = ?").pluck().get(channelID) || "0"
console.log(`OK, processing messages for #${channel.name}, continuing from ${last}`) console.log(`OK, processing messages for #${channel.name}, continuing from ${last}`)
@ -79,4 +76,8 @@ async function event(event) {
} }
process.exit() process.exit()
} catch (e) {
console.error(e)
process.exit(1) // won't exit automatically on thrown error due to living discord connection, so manual exit is necessary
}
} }

View file

@ -0,0 +1,36 @@
// @ts-check
const HeatSync = require("heatsync")
const sync = new HeatSync({watchFS: false})
const sqlite = require("better-sqlite3")
const db = new sqlite("ooye.db", {fileMustExist: true})
const passthrough = require("../src/passthrough")
Object.assign(passthrough, {db, sync})
const api = require("../src/matrix/api")
const utils = require("../src/matrix/utils")
const {reg} = require("../src/matrix/read-registration")
const rooms = db.prepare("select room_id, name, nick from channel_room").all()
;(async () => {
// Search for members starting with @_ooye_ and kick them if they are not in sim_member cache
for (const room of rooms) {
try {
const members = await api.getJoinedMembers(room.room_id)
for (const mxid of Object.keys(members.joined)) {
if (!mxid.startsWith("@" + reg.sender_localpart) && utils.eventSenderIsFromDiscord(mxid) && !db.prepare("select mxid from sim_member where mxid = ? and room_id = ?").get(mxid, room.room_id)) {
await api.leaveRoom(room.room_id, mxid)
}
}
} catch (e) {
if (e.message.includes("Appservice not in room")) {
// ok
} else {
throw e
}
}
}
})()

View file

@ -13,5 +13,5 @@ const {prompt} = require("enquirer")
reg.ooye.web_password = passwordResponse.web_password reg.ooye.web_password = passwordResponse.web_password
writeRegistration(reg) writeRegistration(reg)
console.log("Saved. Restart Out Of Your Element to apply this change.") console.log("Saved. This change should be applied instantly.")
})() })()

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

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

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

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

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

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

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

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

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

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

View file

@ -54,7 +54,7 @@ function convertNameAndTopic(channel, guild, customName) {
// @ts-ignore // @ts-ignore
const parentChannel = discord.channels.get(channel.parent_id) const parentChannel = discord.channels.get(channel.parent_id)
let channelPrefix = let channelPrefix =
( parentChannel?.type === DiscordTypes.ChannelType.GuildForum ? "" ( parentChannel?.type === DiscordTypes.ChannelType.GuildForum ? "[❓] "
: channel.type === DiscordTypes.ChannelType.PublicThread ? "[⛓️] " : channel.type === DiscordTypes.ChannelType.PublicThread ? "[⛓️] "
: channel.type === DiscordTypes.ChannelType.AnnouncementThread ? "[⛓️] " : channel.type === DiscordTypes.ChannelType.AnnouncementThread ? "[⛓️] "
: channel.type === DiscordTypes.ChannelType.PrivateThread ? "[🔒⛓️] " : channel.type === DiscordTypes.ChannelType.PrivateThread ? "[🔒⛓️] "
@ -65,10 +65,11 @@ function convertNameAndTopic(channel, guild, customName) {
const maybeTopicWithNewlines = channel.topic ? `${channel.topic}\n\n` : ''; const maybeTopicWithNewlines = channel.topic ? `${channel.topic}\n\n` : '';
const channelIDPart = `Channel ID: ${channel.id}`; const channelIDPart = `Channel ID: ${channel.id}`;
const guildIDPart = `Guild ID: ${guild.id}`; const guildIDPart = `Guild ID: ${guild.id}`;
const maybeThreadWithinPart = parentChannel ? `Thread within: ${parentChannel.name} (ID: ${parentChannel.id})\n` : '';
const convertedTopic = customName const convertedTopic = customName
? `#${channel.name}${maybeTopicWithPipe}\n\n${channelIDPart}\n${guildIDPart}` ? `#${channel.name}${maybeTopicWithPipe}\n\n${channelIDPart}\n${maybeThreadWithinPart}${guildIDPart}`
: `${maybeTopicWithNewlines}${channelIDPart}\n${guildIDPart}`; : `${maybeTopicWithNewlines}${channelIDPart}\n${maybeThreadWithinPart}${guildIDPart}`;
return [chosenName, convertedTopic]; return [chosenName, convertedTopic];
} }
@ -122,7 +123,7 @@ async function channelToKState(channel, guild, di) {
join_rules = {join_rule: PRIVACY_ENUMS.ROOM_JOIN_RULES[privacyLevel]} join_rules = {join_rule: PRIVACY_ENUMS.ROOM_JOIN_RULES[privacyLevel]}
} }
const everyonePermissions = dUtils.getPermissions(guild.id, [], guild.roles, undefined, channel.permission_overwrites) const everyonePermissions = dUtils.getDefaultPermissions(guild, channel.permission_overwrites)
const everyoneCanSend = dUtils.hasPermission(everyonePermissions, DiscordTypes.PermissionFlagsBits.SendMessages) const everyoneCanSend = dUtils.hasPermission(everyonePermissions, DiscordTypes.PermissionFlagsBits.SendMessages)
const everyoneCanMentionEveryone = dUtils.hasPermission(everyonePermissions, DiscordTypes.PermissionFlagsBits.MentionEveryone) const everyoneCanMentionEveryone = dUtils.hasPermission(everyonePermissions, DiscordTypes.PermissionFlagsBits.MentionEveryone)
@ -193,6 +194,16 @@ async function channelToKState(channel, guild, di) {
// Don't overwrite room topic if the topic has been customised // Don't overwrite room topic if the topic has been customised
if (hasCustomTopic) delete channelKState["m.room.topic/"] if (hasCustomTopic) delete channelKState["m.room.topic/"]
// Make voice channels be a Matrix voice room (MSC3417)
if (channel.type === DiscordTypes.ChannelType.GuildVoice) {
creationContent.type = "org.matrix.msc3417.call"
channelKState["org.matrix.msc3401.call/"] = {
"m.intent": "m.room",
"m.type": "m.voice",
"m.name": customName || channel.name
}
}
// Don't add a space parent if it's self service // Don't add a space parent if it's self service
// (The person setting up self-service has already put it in their preferred space to be able to get this far.) // (The person setting up self-service has already put it in their preferred space to be able to get this far.)
const autocreate = select("guild_active", "autocreate", {guild_id: guild.id}).pluck().get() const autocreate = select("guild_active", "autocreate", {guild_id: guild.id}).pluck().get()
@ -256,7 +267,7 @@ async function createRoom(channel, guild, spaceID, kstate, privacyLevel) {
/** /**
* Handling power levels separately. The spec doesn't specify what happens, Dendrite differs, * Handling power levels separately. The spec doesn't specify what happens, Dendrite differs,
* and Synapse does an absolutely insane *shallow merge* of what I provide on top of what it creates. * and Synapse does a very poorly thought out *shallow merge* of what I provide on top of what it creates.
* We don't want the `events` key to be overridden completely. * We don't want the `events` key to be overridden completely.
* https://github.com/matrix-org/synapse/blob/develop/synapse/handlers/room.py#L1170-L1210 * https://github.com/matrix-org/synapse/blob/develop/synapse/handlers/room.py#L1170-L1210
* https://github.com/matrix-org/matrix-spec/issues/492 * https://github.com/matrix-org/matrix-spec/issues/492
@ -429,12 +440,12 @@ async function _syncRoom(channelID, shouldActuallySync) {
return roomID return roomID
} }
/** Ensures the room exists. If it doesn't, creates the room with an accurate initial state. Please check that a channel_room entry exists or guild autocreate = 1 before calling this. */ /** Ensures the room exists. If it doesn't, creates the room with an accurate initial state. Before calling this, please make sure that: a channel_room entry exists, guild autocreate = 1, or you're operating on a thread.*/
function ensureRoom(channelID) { function ensureRoom(channelID) {
return _syncRoom(channelID, false) return _syncRoom(channelID, false)
} }
/** Actually syncs. Gets all room state from the homeserver in order to diff, and uploads the icon to mxc if it has changed. Please check that a channel_room entry exists or guild autocreate = 1 before calling this. */ /** Actually syncs. Gets all room state from the homeserver in order to diff, and uploads the icon to mxc if it has changed. Before calling this, please make sure that: a channel_room entry exists, guild autocreate = 1, or you're operating on a thread.*/
function syncRoom(channelID) { function syncRoom(channelID) {
return _syncRoom(channelID, true) return _syncRoom(channelID, true)
} }
@ -442,8 +453,9 @@ function syncRoom(channelID) {
/** /**
* @param {{id: string, topic?: string?}} channel channel-ish (just needs an id, topic is optional) * @param {{id: string, topic?: string?}} channel channel-ish (just needs an id, topic is optional)
* @param {string} guildID * @param {string} guildID
* @param {string} messageBeforeLeave
*/ */
async function unbridgeChannel(channel, guildID) { async function unbridgeChannel(channel, guildID, messageBeforeLeave = "This room was removed from the bridge.") {
const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get() const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get()
assert.ok(roomID) assert.ok(roomID)
const row = from("guild_space").join("guild_active", "guild_id").select("space_id", "autocreate").where({guild_id: guildID}).get() const row = from("guild_space").join("guild_active", "guild_id").select("space_id", "autocreate").where({guild_id: guildID}).get()
@ -493,7 +505,7 @@ async function unbridgeChannel(channel, guildID) {
// send a notification in the room // send a notification in the room
await api.sendEvent(roomID, "m.room.message", { await api.sendEvent(roomID, "m.room.message", {
msgtype: "m.notice", msgtype: "m.notice",
body: "⚠️ This room was removed from the bridge." body: `⚠️ ${messageBeforeLeave}`
}) })
// if it is an easy mode room, clean up the room from the managed space and make it clear it's not being bridged // if it is an easy mode room, clean up the room from the managed space and make it clear it's not being bridged

View file

@ -190,6 +190,17 @@ test("channel2room: read-only discord channel", async t => {
t.equal(api.getCalled(), 2) t.equal(api.getCalled(), 2)
}) })
test("channel2room: voice channel", async t => {
const api = mockAPI(t)
const state = kstateStripConditionals(await channelToKState(testData.channel.voice, testData.guild.general, {api}).then(x => x.channelKState))
t.equal(state["m.room.create/"].type, "org.matrix.msc3417.call")
t.deepEqual(state["org.matrix.msc3401.call/"], {
"m.intent": "m.room",
"m.name": "🍞丨[8user] Piece",
"m.type": "m.voice"
})
})
test("convertNameAndTopic: custom name and topic", t => { test("convertNameAndTopic: custom name and topic", t => {
t.deepEqual( t.deepEqual(
_convertNameAndTopic({id: "123", name: "the-twilight-zone", topic: "Spooky stuff here. :ghost:", type: 0}, {id: "456"}, "hauntings"), _convertNameAndTopic({id: "123", name: "the-twilight-zone", topic: "Spooky stuff here. :ghost:", type: 0}, {id: "456"}, "hauntings"),

View file

@ -34,7 +34,10 @@ async function emojisToState(emojis, guild) {
if (e.data?.errcode === "M_TOO_LARGE") { // Very unlikely to happen. Only possible for 3x-series emojis uploaded shortly after animated emojis were introduced, when there was no 256 KB size limit. if (e.data?.errcode === "M_TOO_LARGE") { // Very unlikely to happen. Only possible for 3x-series emojis uploaded shortly after animated emojis were introduced, when there was no 256 KB size limit.
return return
} }
console.error(`Trying to handle emoji ${emoji.name} (${emoji.id}), but...`) e["emoji"] = {
name: emoji.name,
id: emoji.id
}
throw e throw e
}) })
)) ))

View file

@ -154,7 +154,7 @@ function memberToPowerLevel(user, member, guild, channel) {
if (!member) return 0 if (!member) return 0
const permissions = dUtils.getPermissions(guild.id, member.roles, guild.roles, user.id, channel.permission_overwrites) const permissions = dUtils.getPermissions(guild.id, member.roles, guild.roles, user.id, channel.permission_overwrites)
const everyonePermissions = dUtils.getPermissions(guild.id, [], guild.roles, undefined, channel.permission_overwrites) const everyonePermissions = dUtils.getDefaultPermissions(guild, channel.permission_overwrites)
/* /*
* PL 100 = Administrator = People who can brick the room. RATIONALE: * PL 100 = Administrator = People who can brick the room. RATIONALE:
* - Administrator. * - Administrator.
@ -206,14 +206,16 @@ function _hashProfileContent(content, powerLevel) {
* 3. Calculate the power level the user should get based on their Discord permissions * 3. Calculate the power level the user should get based on their Discord permissions
* 4. Compare against the previously known state content, which is helpfully stored in the database * 4. Compare against the previously known state content, which is helpfully stored in the database
* 5. If the state content or power level have changed, send them to Matrix and update them in the database for next time * 5. If the state content or power level have changed, send them to Matrix and update them in the database for next time
* 6. If the sim is for a user-installed app, check which user it was added by
* @param {DiscordTypes.APIUser} user * @param {DiscordTypes.APIUser} user
* @param {Omit<DiscordTypes.APIGuildMember, "user"> | undefined} member * @param {Omit<DiscordTypes.APIGuildMember, "user"> | undefined} member
* @param {DiscordTypes.APIGuildChannel} channel * @param {DiscordTypes.APIGuildChannel} channel
* @param {DiscordTypes.APIGuild} guild * @param {DiscordTypes.APIGuild} guild
* @param {string} roomID * @param {string} roomID
* @param {DiscordTypes.APIMessageInteractionMetadata} [interactionMetadata]
* @returns {Promise<string>} mxid of the updated sim * @returns {Promise<string>} mxid of the updated sim
*/ */
async function syncUser(user, member, channel, guild, roomID) { async function syncUser(user, member, channel, guild, roomID, interactionMetadata) {
const mxid = await ensureSimJoined(user, roomID) const mxid = await ensureSimJoined(user, roomID)
const content = await memberToStateContent(user, member, guild.id) const content = await memberToStateContent(user, member, guild.id)
const powerLevel = memberToPowerLevel(user, member, guild, channel) const powerLevel = memberToPowerLevel(user, member, guild, channel)
@ -222,6 +224,12 @@ async function syncUser(user, member, channel, guild, roomID) {
allowOverwrite: !!member, allowOverwrite: !!member,
globalProfile: await userToGlobalProfile(user) globalProfile: await userToGlobalProfile(user)
}) })
const appInstalledByUser = user.bot && interactionMetadata?.authorizing_integration_owners?.[DiscordTypes.ApplicationIntegrationType.UserInstall]
if (appInstalledByUser) {
db.prepare("INSERT OR IGNORE INTO app_user_install (app_bot_id, user_id, guild_id) VALUES (?, ?, ?)").run(user.id, appInstalledByUser, guild.id)
}
return mxid return mxid
} }

View file

@ -0,0 +1,37 @@
// @ts-check
const passthrough = require("../../passthrough")
const {sync, db, select, from} = passthrough
/** @type {import("../../matrix/api")} */
const api = sync.require("../../matrix/api")
/** @type {import("../converters/remove-member-mxids")} */
const removeMemberMxids = sync.require("../converters/remove-member-mxids")
/**
* @param {string} userID discord user ID that left
* @param {string} guildID discord guild ID that they left
*/
async function removeMember(userID, guildID) {
const {userAppDeletions, membership} = removeMemberMxids.removeMemberMxids(userID, guildID)
db.transaction(() => {
for (const d of userAppDeletions) {
db.prepare("DELETE FROM app_user_install WHERE guild_id = ? and user_id = ?").run(guildID, d)
}
})()
for (const m of membership) {
try {
await api.leaveRoom(m.room_id, m.mxid)
} catch (e) {
if (String(e).includes("not in room")) {
// no further action needed
} else {
throw e
}
}
// Update cache to say that the member isn't in the room any more
// You'd think this would happen automatically when the leave event arrives at Matrix's event dispatcher, but that isn't 100% reliable.
db.prepare("DELETE FROM sim_member WHERE room_id = ? AND mxid = ?").run(m.room_id, m.mxid)
}
}
module.exports.removeMember = removeMember

View file

@ -23,6 +23,8 @@ const pollEnd = sync.require("../actions/poll-end")
const dUtils = sync.require("../../discord/utils") const dUtils = sync.require("../../discord/utils")
/** @type {import("../../m2d/actions/channel-webhook")} */ /** @type {import("../../m2d/actions/channel-webhook")} */
const channelWebhook = sync.require("../../m2d/actions/channel-webhook") const channelWebhook = sync.require("../../m2d/actions/channel-webhook")
/** @type {import("../../agi/listener")} */
const agiListener = sync.require("../../agi/listener")
/** /**
* @param {DiscordTypes.GatewayMessageCreateDispatchData} message * @param {DiscordTypes.GatewayMessageCreateDispatchData} message
@ -51,7 +53,7 @@ async function sendMessage(message, channel, guild, row) {
if (message.author.id === discord.application.id) { if (message.author.id === discord.application.id) {
// no need to sync the bot's own user // no need to sync the bot's own user
} else { } else {
senderMxid = await registerUser.syncUser(message.author, message.member, channel, guild, roomID) senderMxid = await registerUser.syncUser(message.author, message.member, channel, guild, roomID, message.interaction_metadata)
} }
} }
@ -137,6 +139,8 @@ async function sendMessage(message, channel, guild, row) {
} }
} }
await agiListener.process(message, channel, guild, false)
return eventIDs return eventIDs
} }

View file

@ -1,5 +1,7 @@
// @ts-check // @ts-check
const assert = require("assert").strict
const passthrough = require("../../passthrough") const passthrough = require("../../passthrough")
const {sync, select} = passthrough const {sync, select} = passthrough
/** @type {import("../../matrix/api")} */ /** @type {import("../../matrix/api")} */
@ -26,7 +28,7 @@ const presenceLoopInterval = 28e3
// Cache the list of enabled guilds rather than accessing it like multiple times per second when any user changes presence // Cache the list of enabled guilds rather than accessing it like multiple times per second when any user changes presence
const guildPresenceSetting = new class { const guildPresenceSetting = new class {
/** @private @type {Set<string>} */ guilds /** @private @type {Set<string>} */ guilds = new Set()
constructor() { constructor() {
this.update() this.update()
} }
@ -40,7 +42,7 @@ const guildPresenceSetting = new class {
class Presence extends sync.reloadClassMethods(() => Presence) { class Presence extends sync.reloadClassMethods(() => Presence) {
/** @type {string} */ userID /** @type {string} */ userID
/** @type {{presence: "online" | "offline" | "unavailable", status_msg?: string}} */ data /** @type {{presence: "online" | "offline" | "unavailable", status_msg?: string} | undefined} */ data
/** @private @type {?string | undefined} */ mxid /** @private @type {?string | undefined} */ mxid
/** @private @type {number} */ delay = Math.random() /** @private @type {number} */ delay = Math.random()
@ -66,6 +68,7 @@ class Presence extends sync.reloadClassMethods(() => Presence) {
// I haven't tried, but I assume Synapse explodes if you try to update too many presences at the same time. // I haven't tried, but I assume Synapse explodes if you try to update too many presences at the same time.
// This random delay will space them out over the whole 28 second cycle. // This random delay will space them out over the whole 28 second cycle.
setTimeout(() => { setTimeout(() => {
assert(this.data)
api.setPresence(this.data, mxid).catch(() => {}) api.setPresence(this.data, mxid).catch(() => {})
}, this.delay * presenceLoopInterval).unref() }, this.delay * presenceLoopInterval).unref()
} }

View file

@ -151,9 +151,11 @@ async function editToChanges(message, guild, api) {
const messageReallyOld = message.timestamp && new Date(message.timestamp).getTime() < Date.now() - 2 * 60 * 1000 // older than 2 minutes ago const messageReallyOld = message.timestamp && new Date(message.timestamp).getTime() < Date.now() - 2 * 60 * 1000 // older than 2 minutes ago
// Don't post new generated embeds for messages if the setting was disabled. // Don't post new generated embeds for messages if the setting was disabled.
const embedsEnabled = select("guild_space", "url_preview", {guild_id: guild?.id}).pluck().get() ?? 1 const embedsEnabled = select("guild_space", "url_preview", {guild_id: guild?.id}).pluck().get() ?? 1
// Bots may rely on embeds to send new content, so the rules may be more lax for them.
const botEmbedsApproved = message.author?.bot && !originallyFromMatrix
if (messageReallyOld) { if (messageReallyOld) {
eventsToSend = [] // Only allow edits to change and delete, but not send new. eventsToSend = [] // Only allow edits to change and delete, but not send new.
} else if ((messageQuiteOld || !embedsEnabled) && !message.author?.bot) { } else if ((messageQuiteOld || !embedsEnabled) && !botEmbedsApproved) {
eventsToSend = eventsToSend.filter(e => e.msgtype !== "m.notice") // Only send events that aren't embeds. eventsToSend = eventsToSend.filter(e => e.msgtype !== "m.notice") // Only send events that aren't embeds.
} }

View file

@ -78,7 +78,7 @@ test("edit2changes: bot response", async t => {
newContent: { newContent: {
$type: "m.room.message", $type: "m.room.message",
msgtype: "m.text", msgtype: "m.text",
body: "* :ae_botrac4r: [@cadence](https://matrix.to/#/@cadence:cadence.moe) asked ``­``, I respond: Stop drinking paint. (No)\n\nHit :bn_re: to reroll.", body: "* :ae_botrac4r: @cadence asked ``­``, I respond: Stop drinking paint. (No)\n\nHit :bn_re: to reroll.",
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: '* <img data-mx-emoticon height="32" src="mxc://cadence.moe/skqfuItqxNmBYekzmVKyoLzs" title=":ae_botrac4r:" alt=":ae_botrac4r:"> <a href="https://matrix.to/#/@cadence:cadence.moe">@cadence</a> asked <code>­</code>, I respond: Stop drinking paint. (No)<br><br>Hit <img data-mx-emoticon height="32" src="mxc://cadence.moe/OIpqpfxTnHKokcsYqDusxkBT" title=":bn_re:" alt=":bn_re:"> to reroll.', formatted_body: '* <img data-mx-emoticon height="32" src="mxc://cadence.moe/skqfuItqxNmBYekzmVKyoLzs" title=":ae_botrac4r:" alt=":ae_botrac4r:"> <a href="https://matrix.to/#/@cadence:cadence.moe">@cadence</a> asked <code>­</code>, I respond: Stop drinking paint. (No)<br><br>Hit <img data-mx-emoticon height="32" src="mxc://cadence.moe/OIpqpfxTnHKokcsYqDusxkBT" title=":bn_re:" alt=":bn_re:"> to reroll.',
"m.mentions": { "m.mentions": {
@ -87,7 +87,7 @@ test("edit2changes: bot response", async t => {
// *** Replaced With: *** // *** Replaced With: ***
"m.new_content": { "m.new_content": {
msgtype: "m.text", msgtype: "m.text",
body: ":ae_botrac4r: [@cadence](https://matrix.to/#/@cadence:cadence.moe) asked ``­``, I respond: Stop drinking paint. (No)\n\nHit :bn_re: to reroll.", body: ":ae_botrac4r: @cadence asked ``­``, I respond: Stop drinking paint. (No)\n\nHit :bn_re: to reroll.",
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: '<img data-mx-emoticon height="32" src="mxc://cadence.moe/skqfuItqxNmBYekzmVKyoLzs" title=":ae_botrac4r:" alt=":ae_botrac4r:"> <a href="https://matrix.to/#/@cadence:cadence.moe">@cadence</a> asked <code>­</code>, I respond: Stop drinking paint. (No)<br><br>Hit <img data-mx-emoticon height="32" src="mxc://cadence.moe/OIpqpfxTnHKokcsYqDusxkBT" title=":bn_re:" alt=":bn_re:"> to reroll.', formatted_body: '<img data-mx-emoticon height="32" src="mxc://cadence.moe/skqfuItqxNmBYekzmVKyoLzs" title=":ae_botrac4r:" alt=":ae_botrac4r:"> <a href="https://matrix.to/#/@cadence:cadence.moe">@cadence</a> asked <code>­</code>, I respond: Stop drinking paint. (No)<br><br>Hit <img data-mx-emoticon height="32" src="mxc://cadence.moe/OIpqpfxTnHKokcsYqDusxkBT" title=":bn_re:" alt=":bn_re:"> to reroll.',
"m.mentions": { "m.mentions": {

View file

@ -146,10 +146,18 @@ function findMention(pjr, maximumWrittenSection, baseOffset, prefix, content) {
// Highlight the relevant part of the message // Highlight the relevant part of the message
const start = baseOffset + best.scored.matchedInputTokens[0].index const start = baseOffset + best.scored.matchedInputTokens[0].index
const end = baseOffset + prefix.length + best.scored.matchedInputTokens.slice(-1)[0].end const end = baseOffset + prefix.length + best.scored.matchedInputTokens.slice(-1)[0].end
const newContent = content.slice(0, start) + "[" + content.slice(start, end) + "](https://matrix.to/#/" + best.mxid + ")" + content.slice(end) const newNodes = [{
type: "text", content: content.slice(0, start)
}, {
type: "link", target: `https://matrix.to/#/${best.mxid}`, content: [
{type: "text", content: content.slice(start, end)}
]
}, {
type: "text", content: content.slice(end)
}]
return { return {
mxid: best.mxid, mxid: best.mxid,
newContent newNodes
} }
} }
} }

View file

@ -35,10 +35,10 @@ function getDiscordParseCallbacks(message, guild, useHTML, spoilers = []) {
/** @param {{id: string, type: "discordUser"}} node */ /** @param {{id: string, type: "discordUser"}} node */
user: node => { user: node => {
const mxid = select("sim", "mxid", {user_id: node.id}).pluck().get() const mxid = select("sim", "mxid", {user_id: node.id}).pluck().get()
const interaction = message.interaction_metadata || message.interaction const interactionMetadata = message.interaction_metadata
const username = message.mentions?.find(ment => ment.id === node.id)?.username const username = message.mentions?.find(ment => ment.id === node.id)?.username
|| message.referenced_message?.mentions?.find(ment => ment.id === node.id)?.username || message.referenced_message?.mentions?.find(ment => ment.id === node.id)?.username
|| (interaction?.user.id === node.id ? interaction.user.username : null) || (interactionMetadata?.user.id === node.id ? interactionMetadata.user.username : null)
|| (message.author?.id === node.id ? message.author.username : null) || (message.author?.id === node.id ? message.author.username : null)
|| "unknown-user" || "unknown-user"
if (mxid && useHTML) { if (mxid && useHTML) {
@ -261,6 +261,29 @@ function getFormattedInteraction(interaction, isThinkingInteraction) {
} }
} }
/**
* @param {any} newEvents merge into events
* @param {any} events will be modified
* @param {boolean} forceSameMsgtype whether m.text may only be combined with m.text, etc
*/
function mergeTextEvents(newEvents, events, forceSameMsgtype) {
let prev = events.at(-1)
for (const ne of newEvents) {
const isAllText = prev?.body && prev?.formatted_body && ["m.text", "m.notice"].includes(ne.msgtype) && ["m.text", "m.notice"].includes(prev?.msgtype)
const typesPermitted = !forceSameMsgtype || ne?.msgtype === prev?.msgtype
if (isAllText && typesPermitted) {
const rep = new mxUtils.MatrixStringBuilder()
rep.body = prev.body
rep.formattedBody = prev.formatted_body
rep.addLine(ne.body, ne.formatted_body)
prev.body = rep.body
prev.formatted_body = rep.formattedBody
} else {
events.push(ne)
}
}
}
/** /**
* @param {DiscordTypes.APIMessage} message * @param {DiscordTypes.APIMessage} message
* @param {DiscordTypes.APIGuild} guild * @param {DiscordTypes.APIGuild} guild
@ -334,9 +357,19 @@ async function messageToEvent(message, guild, options = {}, di) {
}] }]
} }
const interaction = message.interaction_metadata || message.interaction if (message.type === DiscordTypes.MessageType.ChannelFollowAdd) {
const isInteraction = message.type === DiscordTypes.MessageType.ChatInputCommand && !!interaction && "name" in interaction return [{
const isThinkingInteraction = isInteraction && !!((message.flags || 0) & DiscordTypes.MessageFlags.Loading) $type: "m.room.message",
msgtype: "m.emote",
body: `set this room to receive announcements from ${message.content}`,
format: "org.matrix.custom.html",
formatted_body: tag`set this room to receive announcements from <strong>${message.content}</strong>`,
"m.mentions": {}
}]
}
let isInteraction = (message.type === DiscordTypes.MessageType.ChatInputCommand || message.type === DiscordTypes.MessageType.ContextMenuCommand) && message.interaction && "name" in message.interaction
let isThinkingInteraction = isInteraction && !!((message.flags || 0) & DiscordTypes.MessageFlags.Loading)
/** /**
@type {{room?: boolean, user_ids?: string[]}} @type {{room?: boolean, user_ids?: string[]}}
@ -377,6 +410,16 @@ async function messageToEvent(message, guild, options = {}, di) {
} else if (message.referenced_message) { } else if (message.referenced_message) {
repliedToUnknownEvent = true repliedToUnknownEvent = true
} }
} else if (message.type === DiscordTypes.MessageType.ContextMenuCommand && message.interaction && message.message_reference?.message_id) {
// It could be a /plu/ral emulated reply
if (message.interaction.name.startsWith("Reply ") && message.content.startsWith("-# [↪](")) {
const row = await getHistoricalEventRow(message.message_reference?.message_id)
if (row && "event_id" in row) {
repliedToEventRow = Object.assign(row, {channel_id: row.reference_channel_id})
message.content = message.content.replace(/^.*\n/, "")
isInteraction = false // declutter
}
}
} else if (dUtils.isWebhookMessage(message) && message.embeds[0]?.author?.name?.endsWith("↩️")) { } else if (dUtils.isWebhookMessage(message) && message.embeds[0]?.author?.name?.endsWith("↩️")) {
// It could be a PluralKit emulated reply, let's see if it has a message link // It could be a PluralKit emulated reply, let's see if it has a message link
const isEmulatedReplyToText = message.embeds[0].description?.startsWith("**[Reply to:]") const isEmulatedReplyToText = message.embeds[0].description?.startsWith("**[Reply to:]")
@ -519,29 +562,60 @@ async function messageToEvent(message, guild, options = {}, di) {
return emojiToKey.emojiToKey({id, name, animated}, message.id) // Register the custom emoji if needed return emojiToKey.emojiToKey({id, name, animated}, message.id) // Register the custom emoji if needed
})) }))
async function transformParsedVia(parsed) { async function transformParsedVia(parsed, scanTextForMentions) {
for (const node of parsed) { for (let n = 0; n < parsed.length; n++) {
const node = parsed[n]
if (node.type === "discordChannel" || node.type === "discordChannelLink") { if (node.type === "discordChannel" || node.type === "discordChannelLink") {
node.row = select("channel_room", ["room_id", "name", "nick"], {channel_id: node.id}).get() node.row = select("channel_room", ["room_id", "name", "nick"], {channel_id: node.id}).get()
if (node.row?.room_id) { if (node.row?.room_id) {
node.via = await getViaServersMemo(node.row.room_id) node.via = await getViaServersMemo(node.row.room_id)
} }
} }
else if (node.type === "text" && typeof node.content === "string") {
// Merge adjacent text nodes into this one
while (parsed[n+1]?.type === "text" && typeof parsed[n+1].content === "string") {
node.content += parsed[n+1].content
parsed.splice(n+1, 1)
}
// Mentions scenario 3: scan the message content for written @mentions of matrix users. Allows for up to one space between @ and mention.
if (scanTextForMentions) {
let content = node.content
const matches = [...content.matchAll(/(@ ?)([a-z0-9_.#$][^@\n]+)/gi)]
for (let i = matches.length; i--;) {
const m = matches[i]
const prefix = m[1]
const maximumWrittenSection = m[2].toLowerCase()
if (m.index > 0 && !content[m.index-1].match(/ |\(|\n/)) continue // must have space before it
if (maximumWrittenSection.match(/^everyone\b/) || maximumWrittenSection.match(/^here\b/)) continue // ignore @everyone/@here
var roomID = roomID ?? select("channel_room", "room_id", {channel_id: message.channel_id}).pluck().get()
assert(roomID)
var pjr = pjr ?? findMentions.processJoined(Object.entries((await di.api.getJoinedMembers(roomID)).joined).map(([mxid, ev]) => ({mxid, displayname: ev.display_name})))
const found = findMentions.findMention(pjr, maximumWrittenSection, m.index, prefix, content)
if (found) {
addMention(found.mxid)
parsed.splice(n, 1, ...found.newNodes)
content = found.newNodes[0].content
}
}
}
}
for (const maybeChildNodesArray of [node, node.content, node.items]) { for (const maybeChildNodesArray of [node, node.content, node.items]) {
if (Array.isArray(maybeChildNodesArray)) { if (Array.isArray(maybeChildNodesArray)) {
await transformParsedVia(maybeChildNodesArray) await transformParsedVia(maybeChildNodesArray, scanTextForMentions && ["blockQuote", "list", "paragraph", "em", "strong", "u", "del", "text"].includes(node.type))
} }
} }
} }
return parsed return parsed
} }
let html = await markdown.toHtmlWithPostParser(content, transformParsedVia, { let html = await markdown.toHtmlWithPostParser(content, parsed => transformParsedVia(parsed, customOptions.isTheMessageContent && options.scanTextForMentions !== false), {
discordCallback: getDiscordParseCallbacks(message, guild, true, spoilers), discordCallback: getDiscordParseCallbacks(message, guild, true, spoilers),
...customOptions ...customOptions
}, customParser, customHtmlOutput) }, customParser, customHtmlOutput)
let body = await markdown.toHtmlWithPostParser(content, transformParsedVia, { let body = await markdown.toHtmlWithPostParser(content, parsed => transformParsedVia(parsed, false), { // not scanning plaintext body for mentions as we don't parse whether they're in code
discordCallback: getDiscordParseCallbacks(message, guild, false), discordCallback: getDiscordParseCallbacks(message, guild, false),
discordOnly: true, discordOnly: true,
escapeHTML: false, escapeHTML: false,
@ -582,7 +656,8 @@ async function messageToEvent(message, guild, options = {}, di) {
// check that condition 1 or 2 is met // check that condition 1 or 2 is met
if (repliedToEventInDifferentRoom || repliedToUnknownEvent) { if (repliedToEventInDifferentRoom || repliedToUnknownEvent) {
let referenced = message.referenced_message let referenced = message.referenced_message
if (!referenced) { // backend couldn't be bothered to dereference the message, have to do it ourselves /* c8 ignore next 4 - backend couldn't be bothered to dereference the message, have to do it ourselves */
if (!referenced) {
assert(message.message_reference?.message_id) assert(message.message_reference?.message_id)
referenced = await discord.snow.channel.getChannelMessage(message.message_reference.channel_id, message.message_reference.message_id) referenced = await discord.snow.channel.getChannelMessage(message.message_reference.channel_id, message.message_reference.message_id)
} }
@ -630,8 +705,8 @@ async function messageToEvent(message, guild, options = {}, di) {
} }
} }
if (isInteraction && !isThinkingInteraction && events.length === 0) { if (isInteraction && !isThinkingInteraction && message.interaction && events.length === 0) {
const formattedInteraction = getFormattedInteraction(interaction, false) const formattedInteraction = getFormattedInteraction(message.interaction, false)
body = `${formattedInteraction.body}\n${body}` body = `${formattedInteraction.body}\n${body}`
html = `${formattedInteraction.html}${html}` html = `${formattedInteraction.html}${html}`
} }
@ -727,49 +802,37 @@ async function messageToEvent(message, guild, options = {}, di) {
events.push(...forwardedEvents) events.push(...forwardedEvents)
} }
if (isThinkingInteraction) { if (isInteraction && isThinkingInteraction && message.interaction) {
const formattedInteraction = getFormattedInteraction(interaction, true) const formattedInteraction = getFormattedInteraction(message.interaction, true)
await addTextEvent(formattedInteraction.body, formattedInteraction.html, "m.notice") await addTextEvent(formattedInteraction.body, formattedInteraction.html, "m.notice")
} }
// Then text content // Then text content
if (message.content && !isOnlyKlipyGIF && !isThinkingInteraction) { if (message.content && !isOnlyKlipyGIF && !isThinkingInteraction) {
// Mentions scenario 3: scan the message content for written @mentions of matrix users. Allows for up to one space between @ and mention.
let content = message.content
if (options.scanTextForMentions !== false) {
const matches = [...content.matchAll(/(@ ?)([a-z0-9_.#$][^@\n]+)/gi)]
for (let i = matches.length; i--;) {
const m = matches[i]
const prefix = m[1]
const maximumWrittenSection = m[2].toLowerCase()
if (m.index > 0 && !content[m.index-1].match(/ |\(|\n/)) continue // must have space before it
if (maximumWrittenSection.match(/^everyone\b/) || maximumWrittenSection.match(/^here\b/)) continue // ignore @everyone/@here
var roomID = roomID ?? select("channel_room", "room_id", {channel_id: message.channel_id}).pluck().get()
assert(roomID)
var pjr = pjr ?? findMentions.processJoined(Object.entries((await di.api.getJoinedMembers(roomID)).joined).map(([mxid, ev]) => ({mxid, displayname: ev.display_name})))
const found = findMentions.findMention(pjr, maximumWrittenSection, m.index, prefix, content)
if (found) {
addMention(found.mxid)
content = found.newContent
}
}
}
// Scan the content for emojihax and replace them with real emojis // Scan the content for emojihax and replace them with real emojis
content = content.replaceAll(/\[([a-zA-Z0-9_-]{2,32})(?:~[0-9]+)?\]\(https:\/\/cdn\.discordapp\.com\/emojis\/([0-9]+)\.[^ \n)`]+\)/g, (_, name, id) => { let content = message.content.replaceAll(/\[([a-zA-Z0-9_-]{2,32})(?:~[0-9]+)?\]\(https:\/\/cdn\.discordapp\.com\/emojis\/([0-9]+)\.[^ \n)`]+\)/g, (_, name, id) => {
return `<:${name}:${id}>` return `<:${name}:${id}>`
}) })
const {body, html} = await transformContent(content) const {body, html} = await transformContent(content, {isTheMessageContent: true})
await addTextEvent(body, html, msgtype) await addTextEvent(body, html, msgtype)
} }
// Then scheduled events // Then scheduled events
if (message.content && di?.snow) { if (message.content && di?.snow) {
for (const match of [...message.content.matchAll(/discord\.gg\/([A-Za-z0-9]+)\?event=([0-9]{18,})/g)]) { // snowflake has minimum 18 because the events feature is at least that old for (const match of [...message.content.matchAll(/discord\.gg\/([A-Za-z0-9]+)\?event=([0-9]{18,})/g)]) { // snowflake has minimum 18 because the events feature is at least that old
const invite = await di.snow.invite.getInvite(match[1], {guild_scheduled_event_id: match[2]}) let invite
try {
invite = await di.snow.invite.getInvite(match[1], {guild_scheduled_event_id: match[2]})
} catch (e) {
// Skip expired/invalid invites and events
if (e.message === `{"message": "Unknown Invite", "code": 10006}`) {
break
} else {
throw e
}
}
const event = invite.guild_scheduled_event const event = invite.guild_scheduled_event
if (!event) continue // the event ID provided was not valid if (!event) continue // the event ID provided was not valid
@ -815,15 +878,7 @@ async function messageToEvent(message, guild, options = {}, di) {
// Try to merge attachment events with the previous event // Try to merge attachment events with the previous event
// This means that if the attachments ended up as a text link, and especially if there were many of them, the events will be joined together. // This means that if the attachments ended up as a text link, and especially if there were many of them, the events will be joined together.
let prev = events.at(-1) mergeTextEvents(attachmentEvents, events, false)
for (const atch of attachmentEvents) {
if (atch.msgtype === "m.text" && prev?.body && prev?.formatted_body && ["m.text", "m.notice"].includes(prev?.msgtype)) {
prev.body = prev.body + "\n" + atch.body
prev.formatted_body = prev.formatted_body + "<br>" + atch.formatted_body
} else {
events.push(atch)
}
}
} }
// Then components // Then components
@ -905,11 +960,8 @@ async function messageToEvent(message, guild, options = {}, di) {
else if (component.type === DiscordTypes.ComponentType.Button) { else if (component.type === DiscordTypes.ComponentType.Button) {
// May only be a section accessory or in an action row (up to 5) // May only be a section accessory or in an action row (up to 5)
if (component.style === DiscordTypes.ButtonStyle.Link) { if (component.style === DiscordTypes.ButtonStyle.Link) {
if (component.label) { assert(component.label) // required for Discord to validate link buttons
stack.msb.add(`[${component.label} ${component.url}] `, tag`<a href="${component.url}">${component.label}</a> `) stack.msb.add(`[${component.label} ${component.url}] `, tag`<a href="${component.url}">${component.label}</a> `)
} else {
stack.msb.add(component.url)
}
} }
} }
@ -964,6 +1016,7 @@ async function messageToEvent(message, guild, options = {}, di) {
// Start building up a replica ("rep") of the embed in Discord-markdown format, which we will convert into both plaintext and formatted body at once // Start building up a replica ("rep") of the embed in Discord-markdown format, which we will convert into both plaintext and formatted body at once
const rep = new mxUtils.MatrixStringBuilder() const rep = new mxUtils.MatrixStringBuilder()
let isAdditionalImage = false
if (isKlipyGIF) { if (isKlipyGIF) {
assert(embed.video?.url) assert(embed.video?.url)
@ -1030,7 +1083,11 @@ async function messageToEvent(message, guild, options = {}, di) {
let chosenImage = embed.image?.url let chosenImage = embed.image?.url
// the thumbnail seems to be used for "article" type but displayed big at the bottom by discord // the thumbnail seems to be used for "article" type but displayed big at the bottom by discord
if (embed.type === "article" && embed.thumbnail?.url && !chosenImage) chosenImage = embed.thumbnail.url if (embed.type === "article" && embed.thumbnail?.url && !chosenImage) chosenImage = embed.thumbnail.url
if (chosenImage) rep.addParagraph(`📸 ${dUtils.getPublicUrlForCdn(chosenImage)}`)
if (chosenImage) {
isAdditionalImage = !rep.body && !!events.length
rep.addParagraph(`📸 ${dUtils.getPublicUrlForCdn(chosenImage)}`)
}
if (embed.video?.url) rep.addParagraph(`🎞️ ${dUtils.getPublicUrlForCdn(embed.video.url)}`) if (embed.video?.url) rep.addParagraph(`🎞️ ${dUtils.getPublicUrlForCdn(embed.video.url)}`)
@ -1039,6 +1096,11 @@ async function messageToEvent(message, guild, options = {}, di) {
body = body.split("\n").map(l => "| " + l).join("\n") body = body.split("\n").map(l => "| " + l).join("\n")
html = `<blockquote>${html}</blockquote>` html = `<blockquote>${html}</blockquote>`
if (isAdditionalImage) {
mergeTextEvents([{...rep.get(), body, html, msgtype: "m.notice"}], events, true)
continue
}
// Send as m.notice to apply the usual automated/subtle appearance, showing this wasn't actually typed by the person // Send as m.notice to apply the usual automated/subtle appearance, showing this wasn't actually typed by the person
await addTextEvent(body, html, "m.notice") await addTextEvent(body, html, "m.notice")
} }

View file

@ -65,7 +65,7 @@ test("message2event components: pk question mark output", async t => {
+ "<hr>" + "<hr>"
+ "<blockquote><p><strong>System:</strong> INX (<code>xffgnx</code>)" + "<blockquote><p><strong>System:</strong> INX (<code>xffgnx</code>)"
+ "<br><strong>Member:</strong> Lillith (<code>pphhoh</code>)" + "<br><strong>Member:</strong> Lillith (<code>pphhoh</code>)"
+ "<br><strong>Sent by:</strong> infinidoge1337 (@unknown-user:)" + "<br><strong>Sent by:</strong> infinidoge1337 (<a href=\"https://matrix.to/#/@_ooye_infinidoge1337:cadence.moe\">@unknown-user</a>)"
+ "<br><br><strong>Account Roles (7)</strong>" + "<br><br><strong>Account Roles (7)</strong>"
+ "<br>§b, !, ‼, Ears Port Ping, Ears Update Ping, Yttr Ping, unsup Ping</p>" + "<br>§b, !, ‼, Ears Port Ping, Ears Update Ping, Yttr Ping, unsup Ping</p>"
+ `🖼️ <a href="https://files.inx.moe/p/cdn/lillith.webp">https://files.inx.moe/p/cdn/lillith.webp</a>` + `🖼️ <a href="https://files.inx.moe/p/cdn/lillith.webp">https://files.inx.moe/p/cdn/lillith.webp</a>`

View file

@ -125,8 +125,8 @@ test("message2event embeds: blockquote in embed", async t => {
t.equal(called, 1, "should call getJoinedMembers once") t.equal(called, 1, "should call getJoinedMembers once")
}) })
test("message2event embeds: crazy html is all escaped", async t => { test("message2event embeds: extreme html is all escaped", async t => {
const events = await messageToEvent(data.message_with_embeds.escaping_crazy_html_tags, data.guild.general) const events = await messageToEvent(data.message_with_embeds.extreme_html_escaping, data.guild.general)
t.deepEqual(events, [{ t.deepEqual(events, [{
$type: "m.room.message", $type: "m.room.message",
msgtype: "m.notice", msgtype: "m.notice",
@ -204,6 +204,44 @@ test("message2event embeds: author url without name", async t => {
}]) }])
}) })
test("message2event embeds: 4 images", async t => {
const events = await messageToEvent(data.message_with_embeds.four_images, data.guild.general)
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.text",
body: "[🔀 Forwarded message]\n» https://fixupx.com/i/status/2032003668787020046",
format: "org.matrix.custom.html",
formatted_body: "🔀 <em>Forwarded message</em><br><blockquote><a href=\"https://fixupx.com/i/status/2032003668787020046\">https://fixupx.com/i/status/2032003668787020046</a></blockquote>",
"m.mentions": {}
}, {
$type: "m.room.message",
msgtype: "m.notice",
body: "» | ## ⏺️ AUTOMATON WEST (@AUTOMATON_ENG) https://x.com/AUTOMATON_ENG/status/2032003668787020046"
+ "\n» | "
+ "\n» | 4chan owner Hiroyuki, Evangelion director Hideaki Anno and GACKT to participate in “humanitys last non\\-AI made social network”"
+ "\n» | "
+ "\n» | [automaton-media.com/en/news/4chan-owner-hiroyuki-evangelion-director-hideaki-anno-and-gackt-to-participate-in-humanitys-last-non-ai-made-social-network/](https://automaton-media.com/en/news/4chan-owner-hiroyuki-evangelion-director-hideaki-anno-and-gackt-to-participate-in-humanitys-last-non-ai-made-social-network/)"
+ "\n» | "
+ "\n» | **[💬](https://x.com/intent/tweet?in_reply_to=2032003668787020046) 36[🔁](https://x.com/intent/retweet?tweet_id=2032003668787020046) 212[❤](https://x.com/intent/like?tweet_id=2032003668787020046) 3\\.0K 👁 131\\.7K**"
+ "\n» | "
+ "\n» | 📸 https://pbs.twimg.com/media/HDMUyf6bQAM3yts.jpg?name=orig"
+ "\n» | — FixupX"
+ "\n» | 📸 https://pbs.twimg.com/media/HDMUgxybQAE4FtJ.jpg?name=orig"
+ "\n» | 📸 https://pbs.twimg.com/media/HDMUrPobgAAeb90.jpg?name=orig"
+ "\n» | 📸 https://pbs.twimg.com/media/HDMUuy5bgAAInj5.jpg?name=orig",
format: "org.matrix.custom.html",
formatted_body: "<blockquote><blockquote><p><strong><a href=\"https://x.com/AUTOMATON_ENG/status/2032003668787020046\">⏺️ AUTOMATON WEST (@AUTOMATON_ENG)</a></strong></p>"
+ "<p>4chan owner Hiroyuki, Evangelion director Hideaki Anno and GACKT to participate in “humanitys last non-AI made social network”"
+ "<br><br><a href=\"https://automaton-media.com/en/news/4chan-owner-hiroyuki-evangelion-director-hideaki-anno-and-gackt-to-participate-in-humanitys-last-non-ai-made-social-network/\">automaton-media.com/en/news/4chan-owner-hiroyuki-evangelion-director-hideaki-anno-and-gackt-to-participate-in-humanitys-last-non-ai-made-social-network/</a>"
+ "<br><br><strong><a href=\"https://x.com/intent/tweet?in_reply_to=2032003668787020046\">💬</a> 36<a href=\"https://x.com/intent/retweet?tweet_id=2032003668787020046\">🔁</a> 212<a href=\"https://x.com/intent/like?tweet_id=2032003668787020046\">❤</a> 3.0K 👁 131.7K</strong></p>"
+ "<p>📸 https://pbs.twimg.com/media/HDMUyf6bQAM3yts.jpg?name=orig</p>— FixupX</blockquote>"
+ "<p>📸 https://pbs.twimg.com/media/HDMUgxybQAE4FtJ.jpg?name=orig</p>"
+ "<p>📸 https://pbs.twimg.com/media/HDMUrPobgAAeb90.jpg?name=orig</p>"
+ "<p>📸 https://pbs.twimg.com/media/HDMUuy5bgAAInj5.jpg?name=orig</p></blockquote>",
"m.mentions": {}
}])
})
test("message2event embeds: vx image", async t => { test("message2event embeds: vx image", async t => {
const events = await messageToEvent(data.message_with_embeds.vx_image, data.guild.general) const events = await messageToEvent(data.message_with_embeds.vx_image, data.guild.general)
t.deepEqual(events, [{ t.deepEqual(events, [{

View file

@ -789,7 +789,7 @@ test("message2event: simple written @mention for matrix user", async t => {
] ]
}, },
msgtype: "m.text", msgtype: "m.text",
body: "[@ash](https://matrix.to/#/@she_who_brings_destruction:cadence.moe) do you need anything from the store btw as I'm heading there after gym", body: "@ash do you need anything from the store btw as I'm heading there after gym",
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: `<a href="https://matrix.to/#/@she_who_brings_destruction:cadence.moe">@ash</a> do you need anything from the store btw as I'm heading there after gym` formatted_body: `<a href="https://matrix.to/#/@she_who_brings_destruction:cadence.moe">@ash</a> do you need anything from the store btw as I'm heading there after gym`
}]) }])
@ -838,7 +838,7 @@ test("message2event: many written @mentions for matrix users", async t => {
] ]
}, },
msgtype: "m.text", msgtype: "m.text",
body: "[@Cadence](https://matrix.to/#/@cadence:cadence.moe), tell me about @Phil, the creator of the Chin Trick, who has become ever more powerful under the mentorship of @botrac4r and [@huck](https://matrix.to/#/@huckleton:cadence.moe)", body: "@Cadence, tell me about @Phil, the creator of the Chin Trick, who has become ever more powerful under the mentorship of @botrac4r and @huck",
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: `<a href="https://matrix.to/#/@cadence:cadence.moe">@Cadence</a>, tell me about @Phil, the creator of the Chin Trick, who has become ever more powerful under the mentorship of @botrac4r and <a href="https://matrix.to/#/@huckleton:cadence.moe">@huck</a>` formatted_body: `<a href="https://matrix.to/#/@cadence:cadence.moe">@Cadence</a>, tell me about @Phil, the creator of the Chin Trick, who has become ever more powerful under the mentorship of @botrac4r and <a href="https://matrix.to/#/@huckleton:cadence.moe">@huck</a>`
}]) }])
@ -890,7 +890,7 @@ test("message2event: written @mentions may match part of the name", async t => {
] ]
}, },
msgtype: "m.text", msgtype: "m.text",
body: "I wonder if [@cadence](https://matrix.to/#/@secret:cadence.moe) saw this?", body: "I wonder if @cadence saw this?",
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: `I wonder if <a href="https://matrix.to/#/@secret:cadence.moe">@cadence</a> saw this?` formatted_body: `I wonder if <a href="https://matrix.to/#/@secret:cadence.moe">@cadence</a> saw this?`
}]) }])
@ -941,7 +941,7 @@ test("message2event: written @mentions may match part of the mxid", async t => {
] ]
}, },
msgtype: "m.text", msgtype: "m.text",
body: "I wonder if [@huck](https://matrix.to/#/@huckleton:cadence.moe) saw this?", body: "I wonder if @huck saw this?",
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: `I wonder if <a href="https://matrix.to/#/@huckleton:cadence.moe">@huck</a> saw this?` formatted_body: `I wonder if <a href="https://matrix.to/#/@huckleton:cadence.moe">@huck</a> saw this?`
}]) }])
@ -962,6 +962,36 @@ test("message2event: written @mentions do not match in URLs", async t => {
}]) }])
}) })
test("message2event: written @mentions do not match in inline code", async t => {
const events = await messageToEvent({
...data.message.advanced_written_at_mention_for_matrix,
content: "`public @Nullable EntityType<?>`"
}, data.guild.general, {}, {})
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.text",
body: "`public @Nullable EntityType<?>`",
format: "org.matrix.custom.html",
formatted_body: `<code>public @Nullable EntityType&lt;?&gt;</code>`
}])
})
test("message2event: written @mentions do not match in code block", async t => {
const events = await messageToEvent({
...data.message.advanced_written_at_mention_for_matrix,
content: "```java\npublic @Nullable EntityType<?>\n```"
}, data.guild.general, {}, {})
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.text",
body: "```java\npublic @Nullable EntityType<?>\n```",
format: "org.matrix.custom.html",
formatted_body: `<pre><code class="language-java">public @Nullable EntityType&lt;?&gt;</code></pre>`
}])
})
test("message2event: entire message may match elaborate display name", async t => { test("message2event: entire message may match elaborate display name", async t => {
let called = 0 let called = 0
const events = await messageToEvent({ const events = await messageToEvent({
@ -1007,7 +1037,7 @@ test("message2event: entire message may match elaborate display name", async t =
] ]
}, },
msgtype: "m.text", msgtype: "m.text",
body: "[@Cadence, Maid of Creation, Eye of Clarity, Empress of Hope ☆](https://matrix.to/#/@wa:cadence.moe)", body: "@Cadence, Maid of Creation, Eye of Clarity, Empress of Hope ☆",
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: `<a href="https://matrix.to/#/@wa:cadence.moe">@Cadence, Maid of Creation, Eye of Clarity, Empress of Hope ☆</a>` formatted_body: `<a href="https://matrix.to/#/@wa:cadence.moe">@Cadence, Maid of Creation, Eye of Clarity, Empress of Hope ☆</a>`
}]) }])
@ -1084,7 +1114,7 @@ test("message2event: multiple attachments are combined into the same event where
formatted_body: "hey" formatted_body: "hey"
+ `<br>📄 Uploaded file: <a href="https://bridge.example.org/download/discordcdn/123/456/789.mega">hey.jpg</a> (100 MB)` + `<br>📄 Uploaded file: <a href="https://bridge.example.org/download/discordcdn/123/456/789.mega">hey.jpg</a> (100 MB)`
+ `<br><blockquote>📸 Uploaded SPOILER file: <a href="https://bridge.example.org/download/discordcdn/123/456/SPOILER_secret.jpg">https://bridge.example.org/download/discordcdn/123/456/SPOILER_secret.jpg</a> (38 KB)</blockquote>` + `<br><blockquote>📸 Uploaded SPOILER file: <a href="https://bridge.example.org/download/discordcdn/123/456/SPOILER_secret.jpg">https://bridge.example.org/download/discordcdn/123/456/SPOILER_secret.jpg</a> (38 KB)</blockquote>`
+ `<br>📄 Uploaded file: <a href="https://bridge.example.org/download/discordcdn/123/456/789.mega">hey.jpg</a> (100 MB)` + `📄 Uploaded file: <a href="https://bridge.example.org/download/discordcdn/123/456/789.mega">hey.jpg</a> (100 MB)`
}, { }, {
$type: "m.room.message", $type: "m.room.message",
"m.mentions": {}, "m.mentions": {},
@ -1112,6 +1142,19 @@ test("message2event: type 4 channel name change", async t => {
}]) }])
}) })
test("message2event: type 12 channel follow add", async t => {
const events = await messageToEvent(data.special_message.channel_follow_add, data.guild.general)
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.emote",
body: "set this room to receive announcements from PluralKit #downtime",
format: "org.matrix.custom.html",
formatted_body: "set this room to receive announcements from <strong>PluralKit #downtime</strong>",
"m.mentions": {}
}])
})
test("message2event: thread start message reference", async t => { test("message2event: thread start message reference", async t => {
const events = await messageToEvent(data.special_message.thread_start_context, data.guild.general, {}, { const events = await messageToEvent(data.special_message.thread_start_context, data.guild.general, {}, {
api: { api: {
@ -1538,6 +1581,28 @@ test("message2event: vc invite event renders embed with room link", async t => {
]) ])
}) })
test("message2event: expired/invalid invites are sent as-is", async t => {
const events = await messageToEvent({content: "https://discord.gg/placeholder?event=1381190945646710824"}, {}, {}, {
snow: {
invite: {
async getInvite() {
throw new Error(`{"message": "Unknown Invite", "code": 10006}`)
}
}
}
})
t.deepEqual(events, [
{
$type: "m.room.message",
body: "https://discord.gg/placeholder?event=1381190945646710824",
format: "org.matrix.custom.html",
formatted_body: "<a href=\"https://discord.gg/placeholder?event=1381190945646710824\">https://discord.gg/placeholder?event=1381190945646710824</a>",
"m.mentions": {},
msgtype: "m.text",
}
])
})
test("message2event: channel links are converted even inside lists (parser post-processer descends into list items)", async t => { test("message2event: channel links are converted even inside lists (parser post-processer descends into list items)", async t => {
let called = 0 let called = 0
const events = await messageToEvent({ const events = await messageToEvent({

View file

@ -22,7 +22,7 @@ function pinsToList(pins, kstate) {
/** @type {string[]} */ /** @type {string[]} */
const result = [] const result = []
for (const pin of pins.items) { for (const pin of pins.items) {
const eventID = select("event_message", "event_id", {message_id: pin.message.id, part: 0}).pluck().get() const eventID = select("event_message", "event_id", {message_id: pin.message.id}, "ORDER BY part ASC").pluck().get()
if (eventID && !alreadyPinned.includes(eventID)) result.push(eventID) if (eventID && !alreadyPinned.includes(eventID)) result.push(eventID)
} }
result.reverse() result.reverse()

View file

@ -0,0 +1,38 @@
// @ts-check
const passthrough = require("../../passthrough")
const {db, select, from} = passthrough
/**
* @param {string} userID discord user ID that left
* @param {string} guildID discord guild ID that they left
*/
function removeMemberMxids(userID, guildID) {
// Get sims for user and remove
let membership = from("sim").join("sim_member", "mxid").join("channel_room", "room_id")
.select("room_id", "mxid").where({user_id: userID, guild_id: guildID}).and("ORDER BY room_id, mxid").all()
membership = membership.concat(from("sim_proxy").join("sim", "user_id").join("sim_member", "mxid").join("channel_room", "room_id")
.select("room_id", "mxid").where({proxy_owner_id: userID, guild_id: guildID}).and("ORDER BY room_id, mxid").all())
// Get user installed apps and remove
/** @type {string[]} */
let userAppDeletions = []
// 1. Select apps that have 1 user remaining
/** @type {Set<string>} */
const appsWithOneUser = new Set(db.prepare("SELECT app_bot_id FROM app_user_install WHERE guild_id = ? GROUP BY app_bot_id HAVING count(*) = 1").pluck().all(guildID))
// 2. Select apps installed by this user
const appsFromThisUser = new Set(select("app_user_install", "app_bot_id", {guild_id: guildID, user_id: userID}).pluck().all())
if (appsFromThisUser.size) userAppDeletions.push(userID)
// Then remove user installed apps if this was the last user with them
const appsToRemove = appsWithOneUser.intersection(appsFromThisUser)
for (const botID of appsToRemove) {
// Remove sims for user installed app
const appRemoval = removeMemberMxids(botID, guildID)
membership = membership.concat(appRemoval.membership)
userAppDeletions = userAppDeletions.concat(appRemoval.userAppDeletions)
}
return {membership, userAppDeletions}
}
module.exports.removeMemberMxids = removeMemberMxids

View file

@ -0,0 +1,43 @@
// @ts-check
const {test} = require("supertape")
const {removeMemberMxids} = require("./remove-member-mxids")
test("remove member mxids: would remove mxid for all rooms in this server", t => {
t.deepEqual(removeMemberMxids("772659086046658620", "112760669178241024"), {
userAppDeletions: [],
membership: [{
mxid: "@_ooye_cadence:cadence.moe",
room_id: "!fGgIymcYWOqjbSRUdV:cadence.moe"
}, {
mxid: "@_ooye_cadence:cadence.moe",
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe"
}]
})
})
test("remove member mxids: removes sims too", t => {
t.deepEqual(removeMemberMxids("196188877885538304", "112760669178241024"), {
userAppDeletions: [],
membership: [{
mxid: '@_ooye_ampflower:cadence.moe',
room_id: '!qzDBLKlildpzrrOnFZ:cadence.moe'
}, {
mxid: '@_ooye__pk_zoego:cadence.moe',
room_id: '!qzDBLKlildpzrrOnFZ:cadence.moe'
}]
})
})
test("remove member mxids: removes apps too", t => {
t.deepEqual(removeMemberMxids("197126718400626689", "66192955777486848"), {
userAppDeletions: ["197126718400626689"],
membership: [{
mxid: '@_ooye_infinidoge1337:cadence.moe',
room_id: '!BnKuBPCvyfOkhcUjEu:cadence.moe'
}, {
mxid: '@_ooye_evil_lillith_sheher:cadence.moe',
room_id: '!BnKuBPCvyfOkhcUjEu:cadence.moe'
}]
})
})

View file

@ -19,26 +19,30 @@ const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex))
*/ */
async function threadToAnnouncement(parentRoomID, threadRoomID, creatorMxid, thread, di) { async function threadToAnnouncement(parentRoomID, threadRoomID, creatorMxid, thread, di) {
const branchedFromEventID = select("event_message", "event_id", {message_id: thread.id}).pluck().get() const branchedFromEventID = select("event_message", "event_id", {message_id: thread.id}).pluck().get()
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"}}} */ /** @type {{"m.mentions"?: any, "m.relates_to"?: {event_id?: string, is_falling_back?: boolean, "m.in_reply_to"?: {event_id: string}, rel_type?: "m.replace"|"m.thread"}}} */
const context = {} const context = {}
let suffix = ""; let suffix = ""
if (branchedFromEventID) { if (branchedFromEventID) {
// Need to figure out who sent that event... // Need to figure out who sent that event...
const event = await di.api.getEvent(parentRoomID, branchedFromEventID) const event = await di.api.getEvent(parentRoomID, branchedFromEventID)
suffix = "\n[Note: You should continue the conversation in that room, rather than in this thread. Any messages sent in Matrix threads will be bridged to Discord as replies, not in-thread messages, which is probably not what you want.]";
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"}
if (event.sender && !userRegex.some(rx => event.sender.match(rx))) context["m.mentions"] = {user_ids: [event.sender]} if (event.sender && !userRegex.some(rx => event.sender.match(rx))) context["m.mentions"] = {user_ids: [event.sender]}
if (!ellieMode) {
//...And actually branch from that event (if configured to do so)
suffix = "\n[Note: You really should continue the conversation in that room, rather than in this Matrix thread. Any messages sent here will be DELETED and instead moved there by the bot, which makes it the author and strips you from control (edits/deletions) over your own message!]"
context["m.relates_to"] = {"m.in_reply_to": {event_id: event.event_id}, is_falling_back:false, event_id: event.event_id, rel_type: "m.thread"}
}
} }
const msgtype = creatorMxid ? "m.emote" : "m.text" const msgtype = creatorMxid ? "m.emote" : "m.text"
const template = creatorMxid ? "started a thread" : "New thread started:" const template = creatorMxid ? "started a thread called" : "New thread started:"
const via = await mxUtils.getViaServersQuery(threadRoomID, di.api) const via = await mxUtils.getViaServersQuery(threadRoomID, di.api)
let body = `${template} ${thread.name} in room: https://matrix.to/#/${threadRoomID}?${via.toString()}${suffix}` let body = `${template} \"${thread.name}\" in room: https://matrix.to/#/${threadRoomID}?${via.toString()}${suffix}`
return { return {
msgtype, msgtype,
body, body,
"m.mentions": {},
...context ...context
} }
} }

View file

@ -49,8 +49,7 @@ test("thread2announcement: no known creator, no branched from event", async t =>
}, {api: viaApi}) }, {api: viaApi})
t.deepEqual(content, { t.deepEqual(content, {
msgtype: "m.text", msgtype: "m.text",
body: "New thread started: „test thread” in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org", body: "New thread started: \"test thread\" in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org",
"m.mentions": {}
}) })
}) })
@ -61,8 +60,7 @@ test("thread2announcement: known creator, no branched from event", async t => {
}, {api: viaApi}) }, {api: viaApi})
t.deepEqual(content, { t.deepEqual(content, {
msgtype: "m.emote", msgtype: "m.emote",
body: "started a thread „test thread” in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org", body: "started a thread called \"test thread\" in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org",
"m.mentions": {}
}) })
}) })
@ -85,8 +83,7 @@ test("thread2announcement: no known creator, branched from discord event", async
}) })
t.deepEqual(content, { t.deepEqual(content, {
msgtype: "m.text", msgtype: "m.text",
body: "New thread started: „test thread” in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org\n[Note: You should continue the conversation in that room, rather than in this thread. Any messages sent in Matrix threads will be bridged to Discord as replies, not in-thread messages, which is probably not what you want.]", body: "New thread started: \"test thread\" in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org\n[Note: You really should continue the conversation in that room, rather than in this Matrix thread. Any messages sent here will be DELETED and instead moved there by the bot, which makes it the author and strips you from control (edits/deletions) over your own message!]",
"m.mentions": {},
"m.relates_to": { "m.relates_to": {
"event_id": "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg", "event_id": "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg",
"is_falling_back": false, "is_falling_back": false,
@ -117,8 +114,7 @@ test("thread2announcement: known creator, branched from discord event", async t
}) })
t.deepEqual(content, { t.deepEqual(content, {
msgtype: "m.emote", msgtype: "m.emote",
body: "started a thread „test thread” in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org\n[Note: You should continue the conversation in that room, rather than in this thread. Any messages sent in Matrix threads will be bridged to Discord as replies, not in-thread messages, which is probably not what you want.]", body: "started a thread called \"test thread\" in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org\n[Note: You really should continue the conversation in that room, rather than in this Matrix thread. Any messages sent here will be DELETED and instead moved there by the bot, which makes it the author and strips you from control (edits/deletions) over your own message!]",
"m.mentions": {},
"m.relates_to": { "m.relates_to": {
"event_id": "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg", "event_id": "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg",
"is_falling_back": false, "is_falling_back": false,
@ -149,7 +145,7 @@ test("thread2announcement: no known creator, branched from matrix event", async
}) })
t.deepEqual(content, { t.deepEqual(content, {
msgtype: "m.text", msgtype: "m.text",
body: "New thread started: „test thread” in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org\n[Note: You should continue the conversation in that room, rather than in this thread. Any messages sent in Matrix threads will be bridged to Discord as replies, not in-thread messages, which is probably not what you want.]", body: "New thread started: \"test thread\" in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org\n[Note: You really should continue the conversation in that room, rather than in this Matrix thread. Any messages sent here will be DELETED and instead moved there by the bot, which makes it the author and strips you from control (edits/deletions) over your own message!]",
"m.mentions": { "m.mentions": {
user_ids: ["@cadence:cadence.moe"] user_ids: ["@cadence:cadence.moe"]
}, },
@ -183,7 +179,7 @@ test("thread2announcement: known creator, branched from matrix event", async t =
}) })
t.deepEqual(content, { t.deepEqual(content, {
msgtype: "m.emote", msgtype: "m.emote",
body: "started a thread „test thread” in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org\n[Note: You should continue the conversation in that room, rather than in this thread. Any messages sent in Matrix threads will be bridged to Discord as replies, not in-thread messages, which is probably not what you want.]", body: "started a thread called \"test thread\" in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org\n[Note: You really should continue the conversation in that room, rather than in this Matrix thread. Any messages sent here will be DELETED and instead moved there by the bot, which makes it the author and strips you from control (edits/deletions) over your own message!]",
"m.mentions": { "m.mentions": {
user_ids: ["@cadence:cadence.moe"] user_ids: ["@cadence:cadence.moe"]
}, },

View file

@ -1,5 +1,5 @@
const {test} = require("supertape") const {test} = require("supertape")
const tryToCatch = require("try-to-catch") const {tryToCatch} = require("try-to-catch")
const assert = require("assert") const assert = require("assert")
const data = require("../../../test/data") const data = require("../../../test/data")
const {userToSimName, webhookAuthorToSimName} = require("./user-to-mxid") const {userToSimName, webhookAuthorToSimName} = require("./user-to-mxid")

View file

@ -52,7 +52,11 @@ class DiscordClient {
/** @type {Map<string, Array<string>>} */ /** @type {Map<string, Array<string>>} */
this.guildChannelMap = new Map() this.guildChannelMap = new Map()
if (listen !== "no") { if (listen !== "no") {
this.cloud.on("event", message => discordPackets.onPacket(this, message, listen)) this.cloud.on("event", message => {
process.nextTick(() => {
discordPackets.onPacket(this, message, listen)
})
})
} }
const addEventLogger = (eventName, logName) => { const addEventLogger = (eventName, logName) => {

View file

@ -26,6 +26,7 @@ const utils = {
client.user = message.d.user client.user = message.d.user
client.application = message.d.application client.application = message.d.application
console.log(`Discord logged in as ${client.user.username}#${client.user.discriminator} (${client.user.id})`) console.log(`Discord logged in as ${client.user.username}#${client.user.discriminator} (${client.user.id})`)
interactions.registerInteractions()
} else if (message.t === "GUILD_CREATE") { } else if (message.t === "GUILD_CREATE") {
message.d.members = message.d.members.filter(m => m.user.id === client.user.id) // only keep the bot's own member - it's needed to determine private channels on web message.d.members = message.d.members.filter(m => m.user.id === client.user.id) // only keep the bot's own member - it's needed to determine private channels on web
@ -47,10 +48,10 @@ const utils = {
if (listen === "full") { if (listen === "full") {
try { try {
interactions.registerInteractions()
await eventDispatcher.checkMissedExpressions(message.d) await eventDispatcher.checkMissedExpressions(message.d)
await eventDispatcher.checkMissedPins(client, message.d)
await eventDispatcher.checkMissedMessages(client, message.d) await eventDispatcher.checkMissedMessages(client, message.d)
await eventDispatcher.checkMissedPins(client, message.d)
await eventDispatcher.checkMissedLeaves(client, message.d)
} catch (e) { } catch (e) {
console.error("Failed to sync missed events. To retry, please fix this error and restart OOYE:") console.error("Failed to sync missed events. To retry, please fix this error and restart OOYE:")
console.error(e) console.error(e)

View file

@ -32,12 +32,16 @@ const speedbump = sync.require("./actions/speedbump")
const retrigger = sync.require("./actions/retrigger") const retrigger = sync.require("./actions/retrigger")
/** @type {import("./actions/set-presence")} */ /** @type {import("./actions/set-presence")} */
const setPresence = sync.require("./actions/set-presence") const setPresence = sync.require("./actions/set-presence")
/** @type {import("./actions/remove-member")} */
const removeMember = sync.require("./actions/remove-member")
/** @type {import("./actions/poll-vote")} */ /** @type {import("./actions/poll-vote")} */
const vote = sync.require("./actions/poll-vote") const vote = sync.require("./actions/poll-vote")
/** @type {import("../m2d/event-dispatcher")} */ /** @type {import("../m2d/event-dispatcher")} */
const matrixEventDispatcher = sync.require("../m2d/event-dispatcher") const matrixEventDispatcher = sync.require("../m2d/event-dispatcher")
/** @type {import("../discord/interactions/matrix-info")} */ /** @type {import("../discord/interactions/matrix-info")} */
const matrixInfoInteraction = sync.require("../discord/interactions/matrix-info") const matrixInfoInteraction = sync.require("../discord/interactions/matrix-info")
/** @type {import("../agi/listener")} */
const agiListener = sync.require("../agi/listener")
const {Semaphore} = require("@chriscdn/promise-semaphore") const {Semaphore} = require("@chriscdn/promise-semaphore")
const checkMissedPinsSema = new Semaphore() const checkMissedPinsSema = new Semaphore()
@ -123,6 +127,7 @@ module.exports = {
// Send in order // Send in order
for (let i = Math.min(messages.length, latestBridgedMessageIndex)-1; i >= 0; i--) { for (let i = Math.min(messages.length, latestBridgedMessageIndex)-1; i >= 0; i--) {
const message = messages[i] const message = messages[i]
if (message.type === DiscordTypes.MessageType.UserJoin) continue // since join announcements don't become events, it would be a repetition to act on them during backfill
if (!members.has(message.author.id)) members.set(message.author.id, await client.snow.guild.getGuildMember(guild.id, message.author.id).catch(() => undefined)) if (!members.has(message.author.id)) members.set(message.author.id, await client.snow.guild.getGuildMember(guild.id, message.author.id).catch(() => undefined))
await module.exports.MESSAGE_CREATE(client, { await module.exports.MESSAGE_CREATE(client, {
@ -172,6 +177,31 @@ module.exports = {
await createSpace.syncSpaceExpressions(data, true) await createSpace.syncSpaceExpressions(data, true)
}, },
/**
* When logging back in, check if any members left while we were gone.
* Do this by getting the member list from Discord and seeing who we still have locally that isn't there in the response.
* @param {import("./discord-client")} client
* @param {DiscordTypes.GatewayGuildCreateDispatchData} guild
*/
async checkMissedLeaves(client, guild) {
const maxLimit = 1000
if (guild.member_count >= maxLimit) return // too large to want to scan
const discordMembers = await client.snow.guild.getGuildMembers(guild.id, {limit: maxLimit})
if (discordMembers.length >= maxLimit) return // response was maxed out, there are guild members that weren't listed, can't act safely
const discordMembersSet = new Set(discordMembers.map(m => m.user.id))
// no indexes on this one but I'll cope
const membersAddedOnMatrix = new Set(from("sim").join("sim_member", "mxid").join("channel_room", "room_id")
.pluck("user_id").selectUnsafe("DISTINCT user_id").where({guild_id: guild.id}).and("AND user_id not like '%-%' and user_id not like '%\\_%' escape '\\'").all())
const userInstalledAppIDs = new Set(from("app_user_install").pluck("app_bot_id").selectUnsafe("DISTINCT app_bot_id").where({guild_id: guild.id}).all())
// loop over members added on matrix and if the member does not exist on discord-side then they should be removed
for (const userID of membersAddedOnMatrix) {
if (userInstalledAppIDs.has(userID)) continue // skip user installed apps here since they're never true members - they'll be removed by removeMember when the associated user is removed
if (!discordMembersSet.has(userID)) {
await removeMember.removeMember(userID, guild.id)
}
}
},
/** /**
* Announces to the parent room that the thread room has been created. * Announces to the parent room that the thread room has been created.
* See notes.md, "Ignore MESSAGE_UPDATE and bridge THREAD_CREATE as the announcement" * See notes.md, "Ignore MESSAGE_UPDATE and bridge THREAD_CREATE as the announcement"
@ -182,7 +212,7 @@ module.exports = {
const channelID = thread.parent_id || undefined const channelID = thread.parent_id || undefined
const parentRoomID = select("channel_room", "room_id", {channel_id: channelID}).pluck().get() const parentRoomID = select("channel_room", "room_id", {channel_id: channelID}).pluck().get()
if (!parentRoomID) return // Not interested in a thread if we aren't interested in its wider channel (won't autocreate) if (!parentRoomID) return // Not interested in a thread if we aren't interested in its wider channel (won't autocreate)
const threadRoomID = await createRoom.syncRoom(thread.id) // Create room (will share the same inflight as the initial message to the thread) const threadRoomID = await createRoom.ensureRoom(thread.id)
await announceThread.announceThread(parentRoomID, threadRoomID, thread) await announceThread.announceThread(parentRoomID, threadRoomID, thread)
}, },
@ -211,6 +241,14 @@ module.exports = {
} }
}, },
/**
* @param {import("./discord-client")} client
* @param {DiscordTypes.GatewayGuildMemberRemoveDispatchData} data
*/
async GUILD_MEMBER_REMOVE(client, data) {
await removeMember.removeMember(data.user.id, data.guild_id)
},
/** /**
* @param {import("./discord-client")} client * @param {import("./discord-client")} client
* @param {DiscordTypes.GatewayChannelUpdateDispatchData} channelOrThread * @param {DiscordTypes.GatewayChannelUpdateDispatchData} channelOrThread
@ -267,7 +305,10 @@ module.exports = {
if (message.webhook_id) { if (message.webhook_id) {
const row = select("webhook", "webhook_id", {webhook_id: message.webhook_id}).pluck().get() const row = select("webhook", "webhook_id", {webhook_id: message.webhook_id}).pluck().get()
if (row) return // The message was sent by the bridge's own webhook on discord. We don't want to reflect this back, so just drop it. if (row) { // The message was sent by the bridge's own webhook on discord. We don't want to reflect this back, so just drop it.
await agiListener.process(message, channel, guild, true)
return
}
} }
if (dUtils.isEphemeralMessage(message)) return // Ephemeral messages are for the eyes of the receiver only! if (dUtils.isEphemeralMessage(message)) return // Ephemeral messages are for the eyes of the receiver only!

View file

@ -0,0 +1,9 @@
BEGIN TRANSACTION;
CREATE TABLE "role_default" (
"guild_id" TEXT NOT NULL,
"role_id" TEXT NOT NULL,
PRIMARY KEY ("guild_id", "role_id")
) WITHOUT ROWID;
COMMIT;

View file

@ -0,0 +1,10 @@
BEGIN TRANSACTION;
CREATE TABLE "app_user_install" (
"guild_id" TEXT NOT NULL,
"app_bot_id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
PRIMARY KEY ("guild_id", "app_bot_id", "user_id")
) WITHOUT ROWID;
COMMIT;

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;

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

@ -1,4 +1,29 @@
export type Models = { export type Models = {
agi_prior_message: {
channel_id: string
username: string
avatar_url: string
use_caps: number
use_punct: number
use_apos: number
timestamp: number
}
agi_optout: {
guild_id: string
}
agi_cooldown: {
guild_id: string
timestamp: number
}
app_user_install: {
guild_id: string
app_bot_id: string
user_id: string
}
auto_emoji: { auto_emoji: {
name: string name: string
emoji_id: string emoji_id: string
@ -104,6 +129,11 @@ export type Models = {
historical_room_index: number historical_room_index: number
} }
role_default: {
guild_id: string
role_id: string
}
room_upgrade_pending: { room_upgrade_pending: {
new_room_id: string new_room_id: string
old_room_id: string old_room_id: string

View file

@ -62,7 +62,29 @@ async function _interact({guild_id, data}, {api}) {
.sort((a, b) => webGuild._getPosition(a, discord.channels) - webGuild._getPosition(b, discord.channels)) .sort((a, b) => webGuild._getPosition(a, discord.channels) - webGuild._getPosition(b, discord.channels))
.filter(channel => from("channel_room").join("member_cache", "room_id").select("mxid").where({channel_id: channel.id, mxid: event.sender}).get()) .filter(channel => from("channel_room").join("member_cache", "room_id").select("mxid").where({channel_id: channel.id, mxid: event.sender}).get())
const matrixMember = select("member_cache", ["displayname", "avatar_url"], {room_id: message.room_id, mxid: event.sender}).get() const matrixMember = select("member_cache", ["displayname", "avatar_url"], {room_id: message.room_id, mxid: event.sender}).get()
const name = matrixMember?.displayname || event.sender let name = matrixMember?.displayname || event.sender
let avatar = utils.getPublicUrlForMxc(matrixMember?.avatar_url)
// Check for per-message profile
const perMessageProfile = event.content?.["com.beeper.per_message_profile"]
let profileNote = ""
if (perMessageProfile) {
if (perMessageProfile.displayname) {
name = perMessageProfile.displayname
}
if ("avatar_url" in perMessageProfile) {
if (perMessageProfile.avatar_url) {
// use provided avatar_url
avatar = utils.getPublicUrlForMxc(perMessageProfile.avatar_url)
} else if (perMessageProfile.avatar_url === "") {
// empty string avatar_url clears the avatar
avatar = undefined
}
// else, omitted/null falls back to member avatar
}
profileNote = "Sent with a per-message profile.\n"
}
return { return {
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
data: { data: {
@ -70,9 +92,9 @@ async function _interact({guild_id, data}, {api}) {
author: { author: {
name, name,
url: `https://matrix.to/#/${event.sender}`, url: `https://matrix.to/#/${event.sender}`,
icon_url: utils.getPublicUrlForMxc(matrixMember?.avatar_url) icon_url: avatar
}, },
description: `This Matrix message was delivered to Discord by **Out Of Your Element**.\n[View on Matrix →](<https://matrix.to/#/${message.room_id}/${message.event_id}?${via}>)\n\n**User ID**: [${event.sender}](<https://matrix.to/#/${event.sender}>)`, description: `This Matrix message was delivered to Discord by **Out Of Your Element**.\n[View on Matrix →](<https://matrix.to/#/${message.room_id}/${message.event_id}?${via}>)\n\n${profileNote}**User ID**: [${event.sender}](<https://matrix.to/#/${event.sender}>)`,
color: 0x0dbd8b, color: 0x0dbd8b,
fields: [{ fields: [{
name: "In Channels", name: "In Channels",

View file

@ -85,3 +85,118 @@ test("matrix info: shows info for matrix source message", async t => {
) )
t.equal(called, 1) t.equal(called, 1)
}) })
test("matrix info: shows username for per-message profile", async t => {
let called = 0
const msg = await _interact({
data: {
target_id: "1128118177155526666",
resolved: {
messages: {
"1141501302736695316": data.message.simple_reply_to_matrix_user
}
}
},
guild_id: "112760669178241024"
}, {
api: {
async getEvent(roomID, eventID) {
called++
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
t.equal(eventID, "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4")
return {
event_id: eventID,
room_id: roomID,
type: "m.room.message",
content: {
msgtype: "m.text",
body: "master chief: i like the halo",
format: "org.matrix.custom.html",
formatted_body: "<strong>master chief: </strong>i like the halo",
"com.beeper.per_message_profile": {
has_fallback: true,
displayname: "master chief",
avatar_url: ""
}
},
sender: "@cadence:cadence.moe"
}
},
async getJoinedMembers(roomID) {
return {
joined: {}
}
},
async getStateEventOuter(roomID, type, key) {
return {
content: {
room_version: "11"
}
}
},
async getStateEvent(roomID, type, key) {
return {}
}
}
})
t.equal(msg.data.embeds[0].author.name, "master chief")
t.match(msg.data.embeds[0].description, "Sent with a per-message profile")
t.equal(called, 1)
})
test("matrix info: shows avatar for per-message profile", async t => {
let called = 0
const msg = await _interact({
data: {
target_id: "1128118177155526666",
resolved: {
messages: {
"1141501302736695316": data.message.simple_reply_to_matrix_user
}
}
},
guild_id: "112760669178241024"
}, {
api: {
async getEvent(roomID, eventID) {
called++
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
t.equal(eventID, "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4")
return {
event_id: eventID,
room_id: roomID,
type: "m.room.message",
content: {
msgtype: "m.text",
body: "?",
format: "org.matrix.custom.html",
formatted_body: "?",
"com.beeper.per_message_profile": {
avatar_url: "mxc://cadence.moe/HXfFuougamkURPPMflTJRxGc"
}
},
sender: "@mystery:cadence.moe"
}
},
async getJoinedMembers(roomID) {
return {
joined: {}
}
},
async getStateEventOuter(roomID, type, key) {
return {
content: {
room_version: "11"
}
}
},
async getStateEvent(roomID, type, key) {
return {}
}
}
})
t.equal(msg.data.embeds[0].author.name, "@mystery:cadence.moe")
t.equal(msg.data.embeds[0].author.icon_url, "https://bridge.example.org/download/matrix/cadence.moe/HXfFuougamkURPPMflTJRxGc")
t.match(msg.data.embeds[0].description, "Sent with a per-message profile")
t.equal(called, 1)
})

View file

@ -5,7 +5,7 @@ const assert = require("assert").strict
const {reg} = require("../matrix/read-registration") const {reg} = require("../matrix/read-registration")
const {db} = require("../passthrough") const {db, select} = require("../passthrough")
/** @type {import("xxhash-wasm").XXHashAPI} */ // @ts-ignore /** @type {import("xxhash-wasm").XXHashAPI} */ // @ts-ignore
let hasher = null let hasher = null
@ -58,6 +58,15 @@ function getPermissions(guildID, userRoles, guildRoles, userID, channelOverwrite
return allowed return allowed
} }
/**
* @param {{id: string, roles: DiscordTypes.APIGuild["roles"]}} guild
* @param {DiscordTypes.APIGuildChannel["permission_overwrites"]} [channel]
*/
function getDefaultPermissions(guild, channel) {
const defaultRoles = select("role_default", "role_id", {guild_id: guild.id}).pluck().all()
return getPermissions(guild.id, defaultRoles, guild.roles, undefined, channel)
}
/** /**
* Note: You can only provide one permission bit to permissionToCheckFor. To check multiple permissions, call `hasAllPermissions` or `hasSomePermissions`. * Note: You can only provide one permission bit to permissionToCheckFor. To check multiple permissions, call `hasAllPermissions` or `hasSomePermissions`.
* It is designed like this to avoid developer error with bit manipulations. * It is designed like this to avoid developer error with bit manipulations.
@ -105,7 +114,7 @@ function hasAllPermissions(resolvedPermissions, permissionsToCheckFor) {
* @param {DiscordTypes.APIMessage} message * @param {DiscordTypes.APIMessage} message
*/ */
function isWebhookMessage(message) { function isWebhookMessage(message) {
return message.webhook_id && message.type !== DiscordTypes.MessageType.ChatInputCommand return message.webhook_id && message.type !== DiscordTypes.MessageType.ChatInputCommand && message.type !== DiscordTypes.MessageType.ContextMenuCommand
} }
/** /**
@ -174,6 +183,7 @@ function filterTo(xs, fn) {
} }
module.exports.getPermissions = getPermissions module.exports.getPermissions = getPermissions
module.exports.getDefaultPermissions = getDefaultPermissions
module.exports.hasPermission = hasPermission module.exports.hasPermission = hasPermission
module.exports.hasSomePermissions = hasSomePermissions module.exports.hasSomePermissions = hasSomePermissions
module.exports.hasAllPermissions = hasAllPermissions module.exports.hasAllPermissions = hasAllPermissions

View file

@ -9,7 +9,7 @@ const sharp = require("sharp")
const api = sync.require("../../matrix/api") const api = sync.require("../../matrix/api")
/** @type {import("../../matrix/mreq")} */ /** @type {import("../../matrix/mreq")} */
const mreq = sync.require("../../matrix/mreq") const mreq = sync.require("../../matrix/mreq")
const streamMimeType = require("stream-mime-type") const {streamType} = require("@cloudrac3r/stream-type")
const WIDTH = 160 const WIDTH = 160
const HEIGHT = 160 const HEIGHT = 160
@ -26,13 +26,13 @@ async function getAndResizeSticker(mxc) {
} }
const streamIn = Readable.fromWeb(res.body) const streamIn = Readable.fromWeb(res.body)
const { stream, mime } = await streamMimeType.getMimeType(streamIn) const {streamThrough, type} = await streamType(streamIn)
const animated = ["image/gif", "image/webp"].includes(mime) const animated = ["image/gif", "image/webp"].includes(type)
const transformer = sharp({animated: animated}) const transformer = sharp({animated: animated})
.resize(WIDTH, HEIGHT, {fit: "inside", background: {r: 0, g: 0, b: 0, alpha: 0}}) .resize(WIDTH, HEIGHT, {fit: "inside", background: {r: 0, g: 0, b: 0, alpha: 0}})
.webp() .webp()
stream.pipe(transformer) streamThrough.pipe(transformer)
return Readable.toWeb(transformer) return Readable.toWeb(transformer)
} }

View file

@ -13,7 +13,7 @@ async function updatePins(pins, prev) {
const diff = diffPins.diffPins(pins, prev) const diff = diffPins.diffPins(pins, prev)
for (const [event_id, added] of diff) { for (const [event_id, added] of diff) {
const row = from("event_message").join("message_room", "message_id").join("historical_channel_room", "historical_room_index") const row = from("event_message").join("message_room", "message_id").join("historical_channel_room", "historical_room_index")
.select("reference_channel_id", "message_id").get() .select("reference_channel_id", "message_id").where({event_id}).and("ORDER BY part ASC").get()
if (!row) continue if (!row) continue
if (added) { if (added) {
discord.snow.channel.addChannelPinnedMessage(row.reference_channel_id, row.message_id, "Message pinned on Matrix") discord.snow.channel.addChannelPinnedMessage(row.reference_channel_id, row.message_id, "Message pinned on Matrix")

View file

@ -6,7 +6,7 @@ const {pipeline} = require("stream").promises
const sharp = require("sharp") const sharp = require("sharp")
const {GIFrame} = require("@cloudrac3r/giframe") const {GIFrame} = require("@cloudrac3r/giframe")
const {PNG} = require("@cloudrac3r/pngjs") const {PNG} = require("@cloudrac3r/pngjs")
const streamMimeType = require("stream-mime-type") const {streamType} = require("@cloudrac3r/stream-type")
const SIZE = 48 const SIZE = 48
const RESULT_WIDTH = 400 const RESULT_WIDTH = 400
@ -54,11 +54,11 @@ async function compositeMatrixEmojis(mxcs, mxcDownloader) {
* @returns {Promise<Buffer | undefined>} Uncompressed PNG image * @returns {Promise<Buffer | undefined>} Uncompressed PNG image
*/ */
async function convertImageStream(streamIn, stopStream) { async function convertImageStream(streamIn, stopStream) {
const {stream, mime} = await streamMimeType.getMimeType(streamIn) const {streamThrough, type} = await streamType(streamIn)
assert(["image/png", "image/jpeg", "image/webp", "image/gif", "image/apng"].includes(mime), `Mime type ${mime} is impossible for emojis`) assert(["image/png", "image/jpeg", "image/webp", "image/gif", "image/apng"].includes(type), `Mime type ${type} is impossible for emojis`)
try { try {
if (mime === "image/png" || mime === "image/jpeg" || mime === "image/webp") { if (type === "image/png" || type === "image/jpeg" || type === "image/webp") {
/** @type {{info: sharp.OutputInfo, buffer: Buffer}} */ /** @type {{info: sharp.OutputInfo, buffer: Buffer}} */
const result = await new Promise((resolve, reject) => { const result = await new Promise((resolve, reject) => {
const transformer = sharp() const transformer = sharp()
@ -70,15 +70,15 @@ async function convertImageStream(streamIn, stopStream) {
resolve({info, buffer}) resolve({info, buffer})
}) })
pipeline( pipeline(
stream, streamThrough,
transformer transformer
) )
}) })
return result.buffer return result.buffer
} else if (mime === "image/gif") { } else if (type === "image/gif") {
const giframe = new GIFrame(0) const giframe = new GIFrame(0)
stream.on("data", chunk => { streamThrough.on("data", chunk => {
giframe.feed(chunk) giframe.feed(chunk)
}) })
const frame = await giframe.getFrame() const frame = await giframe.getFrame()
@ -91,10 +91,10 @@ async function convertImageStream(streamIn, stopStream) {
.toBuffer({resolveWithObject: true}) .toBuffer({resolveWithObject: true})
return buffer.data return buffer.data
} else if (mime === "image/apng") { } else if (type === "image/apng") {
const png = new PNG({maxFrames: 1}) const png = new PNG({maxFrames: 1})
// @ts-ignore // @ts-ignore
stream.pipe(png) streamThrough.pipe(png)
/** @type {Buffer} */ // @ts-ignore /** @type {Buffer} */ // @ts-ignore
const frame = await new Promise(resolve => png.on("parsed", resolve)) const frame = await new Promise(resolve => png.on("parsed", resolve))
stopStream() stopStream()

View file

@ -472,7 +472,8 @@ async function checkWrittenMentions(content, senderMxid, roomID, guild, di) {
content: content.slice(0, writtenMentionMatch.indices[1][0]-1) + `@everyone` + content.slice(writtenMentionMatch.indices[1][1]), content: content.slice(0, writtenMentionMatch.indices[1][0]-1) + `@everyone` + content.slice(writtenMentionMatch.indices[1][1]),
ensureJoined: [], ensureJoined: [],
/**@type {DiscordTypes.AllowedMentionsTypes[]}*/ // @ts-ignore - TypeScript is for whatever reason conviced that "everyone" cannot be assigned to AllowedMentionsTypes, but if you „Go to Definition”, you'll see that "everyone" is a valid enum value. /**@type {DiscordTypes.AllowedMentionsTypes[]}*/ // @ts-ignore - TypeScript is for whatever reason conviced that "everyone" cannot be assigned to AllowedMentionsTypes, but if you „Go to Definition”, you'll see that "everyone" is a valid enum value.
allowedMentionsParse: ["everyone"] allowedMentionsParse: ["everyone"],
allowedMentionsUsers: []
} }
} }
} else if (writtenMentionMatch[1].length < 40) { // the API supports up to 100 characters, but really if you're searching more than 40, something messed up } else if (writtenMentionMatch[1].length < 40) { // the API supports up to 100 characters, but really if you're searching more than 40, something messed up
@ -483,7 +484,8 @@ async function checkWrittenMentions(content, senderMxid, roomID, guild, di) {
// @ts-ignore - typescript doesn't know about indices yet // @ts-ignore - typescript doesn't know about indices yet
content: content.slice(0, writtenMentionMatch.indices[1][0]-1) + `<@${results[0].user.id}>` + content.slice(writtenMentionMatch.indices[1][1]), content: content.slice(0, writtenMentionMatch.indices[1][0]-1) + `<@${results[0].user.id}>` + content.slice(writtenMentionMatch.indices[1][1]),
ensureJoined: [results[0].user], ensureJoined: [results[0].user],
allowedMentionsParse: [] allowedMentionsParse: [],
allowedMentionsUsers: [results[0].user.id]
} }
} }
} }
@ -546,16 +548,34 @@ async function eventToMessage(event, guild, channel, di) {
let avatarURL = undefined let avatarURL = undefined
/**@type {DiscordTypes.AllowedMentionsTypes[]}*/ // @ts-ignore - TypeScript is for whatever reason conviced that neither "users" no "roles" cannot be assigned to AllowedMentionsTypes, but if you „Go to Definition”, you'll see that both are valid enum values. /**@type {DiscordTypes.AllowedMentionsTypes[]}*/ // @ts-ignore - TypeScript is for whatever reason conviced that neither "users" no "roles" cannot be assigned to AllowedMentionsTypes, but if you „Go to Definition”, you'll see that both are valid enum values.
const allowedMentionsParse = ["users", "roles"] const allowedMentionsParse = ["users", "roles"]
const allowedMentionsUsers = []
/** @type {string[]} */ /** @type {string[]} */
let messageIDsToEdit = [] let messageIDsToEdit = []
let replyLine = "" let replyLine = ""
// Extract a basic display name from the sender // Extract a basic display name from the sender
const match = event.sender.match(/^@(.*?):/) const match = event.sender.match(/^@(.*?):/)
if (match) displayName = match[1] if (match) displayName = match[1]
// Try to extract an accurate display name and avatar URL from the member event // Try to extract an accurate display name and avatar URL from the member event
const member = await getMemberFromCacheOrHomeserver(event.room_id, event.sender, di?.api) const member = await getMemberFromCacheOrHomeserver(event.room_id, event.sender, di?.api)
if (member.displayname) displayName = member.displayname if (member.displayname) displayName = member.displayname
if (member.avatar_url) avatarURL = mxUtils.getPublicUrlForMxc(member.avatar_url) if (member.avatar_url) avatarURL = mxUtils.getPublicUrlForMxc(member.avatar_url)
// MSC4144: Override display name and avatar from per-message profile if present
const perMessageProfile = event.content["com.beeper.per_message_profile"]
if (perMessageProfile?.displayname) displayName = perMessageProfile.displayname
if (perMessageProfile && "avatar_url" in perMessageProfile) {
if (perMessageProfile.avatar_url) {
// use provided avatar_url
avatarURL = mxUtils.getPublicUrlForMxc(perMessageProfile.avatar_url)
} else if (perMessageProfile.avatar_url === "") {
// empty string avatar_url clears the avatar
avatarURL = undefined
}
// else, omitted/null falls back to member avatar
}
// If the display name is too long to be put into the webhook (80 characters is the maximum), // If the display name is too long to be put into the webhook (80 characters is the maximum),
// put the excess characters into displayNameRunoff, later to be put at the top of the message // put the excess characters into displayNameRunoff, later to be put at the top of the message
let [displayNameShortened, displayNameRunoff] = splitDisplayName(displayName) let [displayNameShortened, displayNameRunoff] = splitDisplayName(displayName)
@ -765,7 +785,7 @@ async function eventToMessage(event, guild, channel, di) {
// Generate a reply preview for a standard message // Generate a reply preview for a standard message
repliedToContent = repliedToContent.replace(/.*<\/mx-reply>/s, "") // Remove everything before replies, so just use the actual message body repliedToContent = repliedToContent.replace(/.*<\/mx-reply>/s, "") // Remove everything before replies, so just use the actual message body
repliedToContent = repliedToContent.replace(/^\s*<blockquote>.*?<\/blockquote>(.....)/s, "$1") // If the message starts with a blockquote, don't count it and use the message body afterwards repliedToContent = repliedToContent.replace(/^\s*<blockquote>.*?<\/blockquote>(.....)/s, "$1") // If the message starts with a blockquote, don't count it and use the message body afterwards
repliedToContent = repliedToContent.replace(/(?:\n|<br>)+/g, " ") // Should all be on one line repliedToContent = repliedToContent.replace(/(?:\n|<br ?\/?>)+/g, " ") // Should all be on one line
repliedToContent = repliedToContent.replace(/<span [^>]*data-mx-spoiler\b[^>]*>.*?<\/span>/g, "[spoiler]") // Good enough method of removing spoiler content. (I don't want to break out the HTML parser unless I have to.) repliedToContent = repliedToContent.replace(/<span [^>]*data-mx-spoiler\b[^>]*>.*?<\/span>/g, "[spoiler]") // Good enough method of removing spoiler content. (I don't want to break out the HTML parser unless I have to.)
repliedToContent = repliedToContent.replace(/<img([^>]*)>/g, (_, att) => { // Convert Matrix emoji images into Discord emoji markdown repliedToContent = repliedToContent.replace(/<img([^>]*)>/g, (_, att) => { // Convert Matrix emoji images into Discord emoji markdown
const mxcUrlMatch = att.match(/\bsrc="(mxc:\/\/[^"]+)"/) const mxcUrlMatch = att.match(/\bsrc="(mxc:\/\/[^"]+)"/)
@ -858,8 +878,9 @@ async function eventToMessage(event, guild, channel, di) {
const doc = domino.createDocument( const doc = domino.createDocument(
// DOM parsers arrange elements in the <head> and <body>. Wrapping in a custom element ensures elements are reliably arranged in a single element. // DOM parsers arrange elements in the <head> and <body>. Wrapping in a custom element ensures elements are reliably arranged in a single element.
'<x-turndown id="turndown-root">' + input + '</x-turndown>' '<x-turndown id="turndown-root">' + input + '</x-turndown>'
); )
const root = doc.getElementById("turndown-root"); const root = doc.getElementById("turndown-root")
assert(root)
async function forEachNode(event, node) { async function forEachNode(event, node) {
for (; node; node = node.nextSibling) { for (; node; node = node.nextSibling) {
// Check written mentions // Check written mentions
@ -900,7 +921,7 @@ async function eventToMessage(event, guild, channel, di) {
let shouldSuppress = inBody !== -1 && event.content.body[inBody-1] === "<" let shouldSuppress = inBody !== -1 && event.content.body[inBody-1] === "<"
if (!shouldSuppress && guild?.roles) { if (!shouldSuppress && guild?.roles) {
// Suppress if regular users don't have permission // Suppress if regular users don't have permission
const permissions = dUtils.getPermissions(guild.id, [], guild.roles) const permissions = dUtils.getDefaultPermissions(guild, channel?.permission_overwrites)
const canEmbedLinks = dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.EmbedLinks) const canEmbedLinks = dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.EmbedLinks)
shouldSuppress = !canEmbedLinks shouldSuppress = !canEmbedLinks
} }
@ -912,6 +933,7 @@ async function eventToMessage(event, guild, channel, di) {
} }
} }
await forEachNode(event, root) await forEachNode(event, root)
if (perMessageProfile?.has_fallback) root.querySelectorAll("[data-mx-profile-fallback]").forEach(x => x.remove())
// SPRITE SHEET EMOJIS FEATURE: Emojis at the end of the message that we don't know about will be reuploaded as a sprite sheet. // SPRITE SHEET EMOJIS FEATURE: Emojis at the end of the message that we don't know about will be reuploaded as a sprite sheet.
// First we need to determine which emojis are at the end. // First we need to determine which emojis are at the end.
@ -943,6 +965,10 @@ async function eventToMessage(event, guild, channel, di) {
} else { } else {
// Looks like we're using the plaintext body! // Looks like we're using the plaintext body!
content = event.content.body content = event.content.body
if (perMessageProfile?.has_fallback && perMessageProfile.displayname && content.startsWith(perMessageProfile.displayname + ": ")) {
// Strip the display name prefix fallback added for clients that don't support per-message profiles
content = content.slice(perMessageProfile.displayname.length + 2)
}
if (event.content.msgtype === "m.emote") { if (event.content.msgtype === "m.emote") {
content = `* ${displayName} ${content}` content = `* ${displayName} ${content}`
@ -963,7 +989,7 @@ async function eventToMessage(event, guild, channel, di) {
// Suppress if regular users don't have permission // Suppress if regular users don't have permission
if (!shouldSuppress && guild?.roles) { if (!shouldSuppress && guild?.roles) {
const permissions = dUtils.getPermissions(guild.id, [], guild.roles, undefined, channel.permission_overwrites) const permissions = dUtils.getDefaultPermissions(guild, channel.permission_overwrites)
const canEmbedLinks = dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.EmbedLinks) const canEmbedLinks = dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.EmbedLinks)
shouldSuppress = !canEmbedLinks shouldSuppress = !canEmbedLinks
} }
@ -988,16 +1014,34 @@ async function eventToMessage(event, guild, channel, di) {
} }
} }
// Complete content
content = displayNameRunoff + replyLine + content content = displayNameRunoff + replyLine + content
// Split into 2000 character chunks // Split into 2000 character chunks
const chunks = chunk(content, 2000) const chunks = chunk(content, 2000)
// If m.mentions is specified and valid, overwrite allowedMentionsParse with a converted m.mentions
let allowed_mentions = {parse: allowedMentionsParse}
if (event.content["m.mentions"]) {
// Combine requested mentions with detected written mentions to get the full list
if (Array.isArray(event.content["m.mentions"].user_ids)) {
for (const mxid of event.content["m.mentions"].user_ids) {
const user_id = select("sim", "user_id", {mxid}).pluck().get()
if (!user_id) continue
allowedMentionsUsers.push(
select("sim_proxy", "proxy_owner_id", {user_id}).pluck().get() || user_id
)
}
}
// Specific mentions were requested, so do not parse users
allowed_mentions.parse = allowed_mentions.parse.filter(x => x !== "users")
allowed_mentions.users = allowedMentionsUsers
}
// Assemble chunks into Discord messages content
/** @type {(DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | stream.Readable}[]})[]} */ /** @type {(DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | stream.Readable}[]})[]} */
const messages = chunks.map(content => ({ const messages = chunks.map(content => ({
content, content,
allowed_mentions: { allowed_mentions,
parse: allowedMentionsParse
},
username: displayNameShortened, username: displayNameShortened,
avatar_url: avatarURL avatar_url: avatarURL
})) }))

View file

@ -266,7 +266,8 @@ test("event2message: markdown in link text does not attempt to be escaped becaus
content: "hey [@mario sports mix [she/her]](<https://matrix.to/#/%40cadence%3Acadence.moe>), is it possible to listen on a unix socket?", content: "hey [@mario sports mix [she/her]](<https://matrix.to/#/%40cadence%3Acadence.moe>), is it possible to listen on a unix socket?",
avatar_url: undefined, avatar_url: undefined,
allowed_mentions: { allowed_mentions: {
parse: ["users", "roles"] parse: ["roles"],
users: []
} }
}] }]
} }
@ -547,7 +548,8 @@ test("event2message: links don't have angle brackets added by accident", async t
content: "Wanted to automate WG→AWG config enrichment and ended up basically coding a batch INI processor.\nhttps://github.com/Erquint/wgcbp", content: "Wanted to automate WG→AWG config enrichment and ended up basically coding a batch INI processor.\nhttps://github.com/Erquint/wgcbp",
avatar_url: undefined, avatar_url: undefined,
allowed_mentions: { allowed_mentions: {
parse: ["users", "roles"] parse: ["roles"],
users: []
} }
}] }]
} }
@ -1296,7 +1298,8 @@ test("event2message: lists have appropriate line breaks", async t => {
content: `i am not certain what you mean by "already exists with as discord". my goals are\n\n* bridgeing specific channels with existing matrix rooms\n * optionally maybe entire "servers"\n* offering the bridge as a public service`, content: `i am not certain what you mean by "already exists with as discord". my goals are\n\n* bridgeing specific channels with existing matrix rooms\n * optionally maybe entire "servers"\n* offering the bridge as a public service`,
avatar_url: undefined, avatar_url: undefined,
allowed_mentions: { allowed_mentions: {
parse: ["users", "roles"] parse: ["roles"],
users: []
} }
}] }]
} }
@ -1337,7 +1340,8 @@ test("event2message: ordered list start attribute works", async t => {
content: `i am not certain what you mean by "already exists with as discord". my goals are\n\n1. bridgeing specific channels with existing matrix rooms\n 2. optionally maybe entire "servers"\n2. offering the bridge as a public service`, content: `i am not certain what you mean by "already exists with as discord". my goals are\n\n1. bridgeing specific channels with existing matrix rooms\n 2. optionally maybe entire "servers"\n2. offering the bridge as a public service`,
avatar_url: undefined, avatar_url: undefined,
allowed_mentions: { allowed_mentions: {
parse: ["users", "roles"] parse: ["roles"],
users: []
} }
}] }]
} }
@ -1463,6 +1467,118 @@ test("event2message: rich reply to a sim user", async t => {
) )
}) })
test("event2message: rich reply to a sim user, explicitly enabling mentions in client", async t => {
t.deepEqual(
await eventToMessage({
"type": "m.room.message",
"sender": "@cadence:cadence.moe",
"content": {
"msgtype": "m.text",
"body": "> <@_ooye_kyuugryphon:cadence.moe> Slow news day.\n\nTesting this reply, ignore",
"format": "org.matrix.custom.html",
"formatted_body": "<mx-reply><blockquote><a href=\"https://matrix.to/#/!fGgIymcYWOqjbSRUdV:cadence.moe/$Fxy8SMoJuTduwReVkHZ1uHif9EuvNx36Hg79cltiA04?via=cadence.moe&via=feather.onl\">In reply to</a> <a href=\"https://matrix.to/#/@_ooye_kyuugryphon:cadence.moe\">@_ooye_kyuugryphon:cadence.moe</a><br>Slow news day.</blockquote></mx-reply>Testing this reply, ignore",
"m.relates_to": {
"m.in_reply_to": {
"event_id": "$Fxy8SMoJuTduwReVkHZ1uHif9EuvNx36Hg79cltiA04"
}
},
"m.mentions": {
user_ids: ["@_ooye_kyuugryphon:cadence.moe"]
}
},
"origin_server_ts": 1693029683016,
"unsigned": {
"age": 91,
"transaction_id": "m1693029682894.510"
},
"event_id": "$v_Gtr-bzv9IVlSLBO5DstzwmiDd-GSFaNfHX66IupV8",
"room_id": "!fGgIymcYWOqjbSRUdV:cadence.moe"
}, data.guild.general, data.channel.general, {
api: {
getEvent: mockGetEvent(t, "!fGgIymcYWOqjbSRUdV:cadence.moe", "$Fxy8SMoJuTduwReVkHZ1uHif9EuvNx36Hg79cltiA04", {
type: "m.room.message",
content: {
msgtype: "m.text",
body: "Slow news day."
},
sender: "@_ooye_kyuugryphon:cadence.moe"
})
}
}),
{
ensureJoined: [],
messagesToDelete: [],
messagesToEdit: [],
messagesToSend: [{
username: "cadence [they]",
content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/687028734322147344/1144865310588014633 <@111604486476181504>:"
+ " Slow news day."
+ "\nTesting this reply, ignore",
avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU",
allowed_mentions: {
parse: ["roles"],
users: ["111604486476181504"]
}
}]
}
)
})
test("event2message: rich reply to a sim user, explicitly disabling mentions in client", async t => {
t.deepEqual(
await eventToMessage({
"type": "m.room.message",
"sender": "@cadence:cadence.moe",
"content": {
"msgtype": "m.text",
"body": "> <@_ooye_kyuugryphon:cadence.moe> Slow news day.\n\nTesting this reply, ignore",
"format": "org.matrix.custom.html",
"formatted_body": "<mx-reply><blockquote><a href=\"https://matrix.to/#/!fGgIymcYWOqjbSRUdV:cadence.moe/$Fxy8SMoJuTduwReVkHZ1uHif9EuvNx36Hg79cltiA04?via=cadence.moe&via=feather.onl\">In reply to</a> <a href=\"https://matrix.to/#/@_ooye_kyuugryphon:cadence.moe\">@_ooye_kyuugryphon:cadence.moe</a><br>Slow news day.</blockquote></mx-reply>Testing this reply, ignore",
"m.relates_to": {
"m.in_reply_to": {
"event_id": "$Fxy8SMoJuTduwReVkHZ1uHif9EuvNx36Hg79cltiA04"
}
},
"m.mentions": {}
},
"origin_server_ts": 1693029683016,
"unsigned": {
"age": 91,
"transaction_id": "m1693029682894.510"
},
"event_id": "$v_Gtr-bzv9IVlSLBO5DstzwmiDd-GSFaNfHX66IupV8",
"room_id": "!fGgIymcYWOqjbSRUdV:cadence.moe"
}, data.guild.general, data.channel.general, {
api: {
getEvent: mockGetEvent(t, "!fGgIymcYWOqjbSRUdV:cadence.moe", "$Fxy8SMoJuTduwReVkHZ1uHif9EuvNx36Hg79cltiA04", {
type: "m.room.message",
content: {
msgtype: "m.text",
body: "Slow news day."
},
sender: "@_ooye_kyuugryphon:cadence.moe"
})
}
}),
{
ensureJoined: [],
messagesToDelete: [],
messagesToEdit: [],
messagesToSend: [{
username: "cadence [they]",
content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/687028734322147344/1144865310588014633 <@111604486476181504>:"
+ " Slow news day."
+ "\nTesting this reply, ignore",
avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU",
allowed_mentions: {
parse: ["roles"],
users: []
}
}]
}
)
})
test("event2message: rich reply to a rich reply to a multi-line message should correctly strip reply fallback", async t => { test("event2message: rich reply to a rich reply to a multi-line message should correctly strip reply fallback", async t => {
t.deepEqual( t.deepEqual(
await eventToMessage({ await eventToMessage({
@ -1827,9 +1943,9 @@ test("event2message: should suppress embeds for links in reply preview", async t
sender: "@rnl:cadence.moe", sender: "@rnl:cadence.moe",
content: { content: {
msgtype: "m.text", msgtype: "m.text",
body: `> <@cadence:cadence.moe> https://www.youtube.com/watch?v=uX32idb1jMw\n\nEveryone in the comments is like "you're so ahead of the curve" and "crazy that this came out before the scandal" but I thought Mr Beast sucking was a common sentiment`, body: `> <@cadence:cadence.moe> https://www.youtube.com/watch?v=uX32idb1jMw\n\nEveryone in the comments is like "you're so ahead of the curve" and "can't believe this came out before the scandal" but I thought Mr Beast sucking was a common sentiment`,
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: `<mx-reply><blockquote><a href="https://matrix.to/#/!fGgIymcYWOqjbSRUdV:cadence.moe/$qmyjr-ISJtnOM5WTWLI0fT7uSlqRLgpyin2d2NCglCU?via=cadence.moe">In reply to</a> <a href="https://matrix.to/#/@cadence:cadence.moe">@cadence:cadence.moe</a><br>https://www.youtube.com/watch?v=uX32idb1jMw</blockquote></mx-reply>Everyone in the comments is like "you're so ahead of the curve" and "crazy that this came out before the scandal" but I thought Mr Beast sucking was a common sentiment`, formatted_body: `<mx-reply><blockquote><a href="https://matrix.to/#/!fGgIymcYWOqjbSRUdV:cadence.moe/$qmyjr-ISJtnOM5WTWLI0fT7uSlqRLgpyin2d2NCglCU?via=cadence.moe">In reply to</a> <a href="https://matrix.to/#/@cadence:cadence.moe">@cadence:cadence.moe</a><br>https://www.youtube.com/watch?v=uX32idb1jMw</blockquote></mx-reply>Everyone in the comments is like "you're so ahead of the curve" and "can't believe this came out before the scandal" but I thought Mr Beast sucking was a common sentiment`,
"m.relates_to": { "m.relates_to": {
"m.in_reply_to": { "m.in_reply_to": {
event_id: "$qmyjr-ISJtnOM5WTWLI0fT7uSlqRLgpyin2d2NCglCU" event_id: "$qmyjr-ISJtnOM5WTWLI0fT7uSlqRLgpyin2d2NCglCU"
@ -1859,7 +1975,7 @@ test("event2message: should suppress embeds for links in reply preview", async t
username: "RNL", username: "RNL",
content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/687028734322147344/1273204543739396116 **Ⓜcadence [they]**:" content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/687028734322147344/1273204543739396116 **Ⓜcadence [they]**:"
+ " <https://www.youtube.com/watch?v=uX32idb1jMw>" + " <https://www.youtube.com/watch?v=uX32idb1jMw>"
+ `\nEveryone in the comments is like "you're so ahead of the curve" and "crazy that this came out before the scandal" but I thought Mr Beast sucking was a common sentiment`, + `\nEveryone in the comments is like "you're so ahead of the curve" and "can't believe this came out before the scandal" but I thought Mr Beast sucking was a common sentiment`,
avatar_url: undefined, avatar_url: undefined,
allowed_mentions: { allowed_mentions: {
parse: ["users", "roles"] parse: ["users", "roles"]
@ -4747,17 +4863,17 @@ test("event2message: stickers work", async t => {
messagesToEdit: [], messagesToEdit: [],
messagesToSend: [{ messagesToSend: [{
username: "cadence [they]", username: "cadence [they]",
content: "", content: "[get_real2](https://bridge.example.org/download/sticker/cadence.moe/NyMXQFAAdniImbHzsygScbmN/_.webp)",
avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU",
attachments: [{id: "0", filename: "get_real2.gif"}], allowed_mentions: {
pendingFiles: [{name: "get_real2.gif", mxc: "mxc://cadence.moe/NyMXQFAAdniImbHzsygScbmN"}] parse: ["users", "roles"]
}
}] }]
} }
) )
}) })
test("event2message: stickers fetch mimetype from server when mimetype not provided", async t => { test("event2message: stickers fetch mimetype from server when mimetype not provided", async t => {
let called = 0
t.deepEqual( t.deepEqual(
await eventToMessage({ await eventToMessage({
type: "m.sticker", type: "m.sticker",
@ -4768,20 +4884,6 @@ test("event2message: stickers fetch mimetype from server when mimetype not provi
}, },
event_id: "$mL-eEVWCwOvFtoOiivDP7gepvf-fTYH6_ioK82bWDI0", event_id: "$mL-eEVWCwOvFtoOiivDP7gepvf-fTYH6_ioK82bWDI0",
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe"
}, {}, {}, {
api: {
async getMedia(mxc, options) {
called++
t.equal(mxc, "mxc://cadence.moe/ybOWQCaXysnyUGuUCaQlTGJf")
t.equal(options.method, "HEAD")
return {
status: 200,
headers: new Map([
["content-type", "image/gif"]
])
}
}
}
}), }),
{ {
ensureJoined: [], ensureJoined: [],
@ -4789,48 +4891,14 @@ test("event2message: stickers fetch mimetype from server when mimetype not provi
messagesToEdit: [], messagesToEdit: [],
messagesToSend: [{ messagesToSend: [{
username: "cadence [they]", username: "cadence [they]",
content: "", content: "[YESYESYES](https://bridge.example.org/download/sticker/cadence.moe/ybOWQCaXysnyUGuUCaQlTGJf/_.webp)",
avatar_url: undefined, avatar_url: undefined,
attachments: [{id: "0", filename: "YESYESYES.gif"}], allowed_mentions: {
pendingFiles: [{name: "YESYESYES.gif", mxc: "mxc://cadence.moe/ybOWQCaXysnyUGuUCaQlTGJf"}] parse: ["users", "roles"]
}
}] }]
} }
) )
t.equal(called, 1, "sticker headers should be fetched")
})
test("event2message: stickers with unknown mimetype are not allowed", async t => {
let called = 0
try {
await eventToMessage({
type: "m.sticker",
sender: "@cadence:cadence.moe",
content: {
body: "something",
url: "mxc://cadence.moe/ybOWQCaXysnyUGuUCaQlTGJe"
},
event_id: "$mL-eEVWCwOvFtoOiivDP7gepvf-fTYH6_ioK82bWDI0",
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe"
}, {}, {}, {
api: {
async getMedia(mxc, options) {
called++
t.equal(mxc, "mxc://cadence.moe/ybOWQCaXysnyUGuUCaQlTGJe")
t.equal(options.method, "HEAD")
return {
status: 404,
headers: new Map([
["content-type", "application/json"]
])
}
}
}
})
/* c8 ignore next */
t.fail("should throw an error")
} catch (e) {
t.match(e.toString(), "mimetype")
}
}) })
test("event2message: static emojis work", async t => { test("event2message: static emojis work", async t => {
@ -5458,6 +5526,141 @@ test("event2message: known and unknown emojis in the end are used for sprite she
) )
}) })
test("event2message: com.beeper.per_message_profile overrides displayname and avatar_url", async t => {
t.deepEqual(
await eventToMessage({
type: "m.room.message",
sender: "@cadence:cadence.moe",
content: {
msgtype: "m.text",
body: "hello from unstable profile",
"com.beeper.per_message_profile": {
id: "custom-id",
displayname: "Unstable Name",
avatar_url: "mxc://maunium.net/hgXsKqlmRfpKvCZdUoWDkFQo"
}
},
event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU",
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe"
}),
{
ensureJoined: [],
messagesToDelete: [],
messagesToEdit: [],
messagesToSend: [{
username: "Unstable Name",
content: "hello from unstable profile",
avatar_url: "https://bridge.example.org/download/matrix/maunium.net/hgXsKqlmRfpKvCZdUoWDkFQo",
allowed_mentions: {
parse: ["users", "roles"]
}
}]
}
)
})
test("event2message: com.beeper.per_message_profile empty avatar_url clears avatar", async t => {
t.deepEqual(
await eventToMessage({
type: "m.room.message",
sender: "@cadence:cadence.moe",
content: {
msgtype: "m.text",
body: "hello with cleared avatar",
"com.beeper.per_message_profile": {
id: "no-avatar",
displayname: "No Avatar User",
avatar_url: ""
}
},
event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU",
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe"
}),
{
ensureJoined: [],
messagesToDelete: [],
messagesToEdit: [],
messagesToSend: [{
username: "No Avatar User",
content: "hello with cleared avatar",
avatar_url: undefined,
allowed_mentions: {
parse: ["users", "roles"]
}
}]
}
)
})
test("event2message: data-mx-profile-fallback element is stripped from formatted_body when per-message profile is present", async t => {
t.deepEqual(
await eventToMessage({
type: "m.room.message",
sender: "@cadence:cadence.moe",
content: {
msgtype: "m.text",
body: "Tidus Herboren: one more test",
format: "org.matrix.custom.html",
formatted_body: "<strong data-mx-profile-fallback>Tidus Herboren: </strong>one more test",
"com.beeper.per_message_profile": {
id: "tidus",
displayname: "Tidus Herboren",
avatar_url: "mxc://maunium.net/hgXsKqlmRfpKvCZdUoWDkFQo",
has_fallback: true
}
},
event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU",
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe"
}),
{
ensureJoined: [],
messagesToDelete: [],
messagesToEdit: [],
messagesToSend: [{
username: "Tidus Herboren",
content: "one more test",
avatar_url: "https://bridge.example.org/download/matrix/maunium.net/hgXsKqlmRfpKvCZdUoWDkFQo",
allowed_mentions: {
parse: ["users", "roles"]
}
}]
}
)
})
test("event2message: displayname prefix is stripped from plain body when per-message profile has_fallback", async t => {
t.deepEqual(
await eventToMessage({
type: "m.room.message",
sender: "@cadence:cadence.moe",
content: {
msgtype: "m.text",
body: "Tidus Herboren: one more test",
"com.beeper.per_message_profile": {
id: "tidus",
displayname: "Tidus Herboren",
has_fallback: true
}
},
event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU",
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe"
}),
{
ensureJoined: [],
messagesToDelete: [],
messagesToEdit: [],
messagesToSend: [{
username: "Tidus Herboren",
content: "one more test",
avatar_url: undefined,
allowed_mentions: {
parse: ["users", "roles"]
}
}]
}
)
})
test("event2message: all unknown chess emojis are used for sprite sheet", async t => { test("event2message: all unknown chess emojis are used for sprite sheet", async t => {
t.deepEqual( t.deepEqual(
await eventToMessage({ await eventToMessage({

View file

@ -210,38 +210,66 @@ sync.addTemporaryListener(as, "type:m.room.message", guard("m.room.message",
*/ */
async event => { async event => {
if (utils.eventSenderIsFromDiscord(event.sender)) return if (utils.eventSenderIsFromDiscord(event.sender)) return
let processCommands = true
if (event.content["m.relates_to"]?.rel_type === "m.thread") {
const toRedact = event.room_id
const bridgedTo = utils.getThreadRoomFromThreadEvent(event.content["m.relates_to"].event_id)
processCommands = false
if (bridgedTo) event.room_id = bridgedTo;
else if (await bridgeThread(event)) {
api.redactEvent(toRedact, event.event_id)
event.content["m.relates_to"] = undefined
api.sendEvent(event.room_id, event.type, {...event.content, body: event.content.body+"\n ~ "+event.sender, formatted_body: event.content.formatted_body ? event.content.formatted_body+"<br> ~ "+event.sender :undefined })
}
}
const messageResponses = await sendEvent.sendEvent(event) const messageResponses = await sendEvent.sendEvent(event)
if (!messageResponses.length) return if (!messageResponses.length) return
/** @type {string|undefined} */ if (event.type === "m.room.message" && event.content.msgtype === "m.text" && processCommands) {
let executedCommand await matrixCommandHandler.parseAndExecute(
if (event.type === "m.room.message" && event.content.msgtype === "m.text") {
executedCommand = 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) // @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 event
) )
} }
if (event.content["m.relates_to"]?.rel_type === "m.thread" && executedCommand !== "thread"){
const bridgedTo = utils.getThreadRoomFromThreadEvent(event.content["m.relates_to"].event_id)
api.sendEvent(event.room_id, "m.room.message", {
body: "⚠️ **This message may not have been bridged to Discord in the way you thought it was gonna be!**\n\nIt seems like you sent this message inside a Matrix thread. Matrix threads don't work like Discord threads - they are effectively just „fancy replies”, not independent rooms/channels (any „thread-like appearance” is handled purely client-side - and even then, most Matrix clients don't handle it particularly well, with Element being the only one known to actually render threads as threads), and as such, they are bridged as replies to Discord. *In other words: __Discord users will not be aware that you sent this message inside a thread - the reply will go directly onto the main channel.__ If the thread you sent this message in is old, such a random reply **may be distracting** to Discord users!*\n\nFor the sake of Discord parity (and for better support in numerous Matrix clients - as stated above, most Matrix clients don't handle threads particularly well, and they just render in-thread messages as fancy replies), it is recommended to send threaded messages inside a separate Matrix room that gets bridged to Discord. "+ (bridgedTo ? "Luckily for you, this thread already has one! You can access it on https://matrix.to/#/"+bridgedTo+"?"+(await utils.getViaServersQuery(bridgedTo, api)).toString() : "Please run `/thread [Optional: Thread Name]` to create such a room for this thread, or get a link to it if someone else has already done so. If you run `/thread` (without any arguments) outside any threads and not as a reply, you'll get more info about this command")+".\n\n*You can read more about the rationale behind this design choice [here](https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/threads-as-rooms.md).*",
format: "org.matrix.custom.html",
formatted_body: "⚠️ <strong>This message may not have been bridged to Discord in the way you thought it was gonna be!</strong><br><br>It seems like you sent this message inside a Matrix thread. Matrix threads don't work like Discord threads - they are effectively just „fancy replies”, not independent rooms/channels (any „thread-like appearance” is handled purely client-side - and even then, most Matrix clients don't handle it particularly well, with Element being the only one known to actually render threads as threads), and as such, they are bridged as replies to Discord. <em>In other words: <u>Discord users will not be aware that you sent this message inside a thread - the reply will go directly onto the main channel.</u> If the thread you sent this message in is old, such a random reply <strong>may be distracting</strong> to Discord users!</em><br><br>For the sake of Discord parity (and for better support in numerous Matrix clients - as stated above, most Matrix clients don't handle threads particularly well, and they just render in-thread messages as fancy replies), it is recommended to send threaded messages inside a separate Matrix room that gets bridged to Discord. "+ (bridgedTo ? "Luckily for you, this thread already has one! You can access it on <a href=\"https://matrix.to/#/"+bridgedTo+"?"+(await utils.getViaServersQuery(bridgedTo, api)).toString()+"\">"+bridgedTo+"</a>" : "Please run <code>/thread [Optional: Thread Name]</code> to create such a room for this thread, or get a link to it if someone else has already done so. If you run <code>/thread</code> (without any arguments) outside any threads and not as a reply, you'll get more info about this command")+".<br><br><em>You can read more about the rationale behind this design choice <a href=\"https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/threads-as-rooms.md\">here</a>.</em>",
"m.mentions": { "user_ids": [event.sender]},
"m.relates_to": {
event_id: event.content["m.relates_to"].event_id,
is_falling_back: false,
"m.in_reply_to": { event_id: event.event_id },
rel_type: "m.thread"
},
msgtype: "m.text"
})
}
retrigger.messageFinishedBridging(event.event_id) retrigger.messageFinishedBridging(event.event_id)
await api.ackEvent(event) await api.ackEvent(event)
})) }))
/**
* @param {Ty.Event.Outer_M_Room_Message | Ty.Event.Outer_M_Room_Message_File} event Used for determining the branching-point and the title; any relation data will be stripped and its room_id will mutate to the target thread-room, if one gets created.
* @returns {Promise<boolean>} whether a thread-room was created
*/
async function bridgeThread(event) {
/** @type {string} */ // @ts-ignore
const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get()
const channel = discord.channels.get(channelID)
const guildID = channel?.["guild_id"]
if (!guildID) return false; //Room not bridged? We don't care. It's a Matrix-native room, let Matrix users have standard Matrix-native threads there.
const eventID = event.content["m.relates_to"]?.event_id
if (!eventID) throw new Error("There was an event sent inside SOME Matrix thread, but it lacked any information as to what thread it actually was!"); //An „ugly error” is justified because if something like this DOES happen, then that means that it should be reported to us, as there is some broken client out there that we should account for.
const messageID = select("event_message", "message_id", {event_id: eventID}).pluck().get()
if (!messageID) return false; //Message not bridged? Too bad! Discord users will just see normal replies, and Matrix uses won't get a thread-room. We COULD technically create a "headless" thread on Discord side and bridge it to a new thread-room, but that comes with a whole host of complications on its own (notably: what do we do if the message gets bridged later (by reaction emoji), and then hypothetically gets its own thread; and: getThreadRoomFromThreadEvent will have to be much more complex than a simple DB call (probably a whole new DB table would have to be created, just to hold these Matrix-branched-but-headless-on-Discord threads) because the simple „MX event --(db)--> Discord Message --(Discord spec)--> Discord thread” relation would no longer hold true), which may not be worth it, as an unbridged message in a bridged channel is already an edge-case (so it seems pointless to introduce a whole bunch of edgier-cases that handling this edge-case "properly" would bring).
let name = event.content.body
if (name.startsWith("/thread ")) name = name.substring(8);
else name = (await api.getEvent(event.room_id, eventID)).content.body;
name = name.length < 100 ? name.replaceAll("\n", " ") : name.slice(0, 96).replaceAll("\n", " ") + "..."
try {
event.room_id = await createRoom.ensureRoom((await discord.snow.channel.createThreadWithMessage(channelID, messageID, {name})).id)
return true;
}
catch (e){
if (e.message?.includes("50024")) return false; //Tried to created a thread in a thread (see: https://docs.discord.com/developers/topics/opcodes-and-status-codes)? Too bad! Discord users will just see normal replies, and Matrix uses won't get a thread-room. Same case as for message-not-bridged, except there at least exists a HYPOTHETICAL solution (just one so unwieldly that it's nonsensical to dedicate resources to), wheres here I don't know what could possibly be done at all.
else throw e; //In here (unlike in matrix-command-handler.js), there are much fewer things that could "intentionally" go wrong (both thread double-creation and too-long names shouldn't be possible due to earlier checks). As such, if anything breaks, it should be reported to OOYE for further investigation, which the user should do when encountering an "ugly error" (if they follow the „every error should be reported” directive), so this is re-thrown as-is (no stacktrace-breaking exception wrapping) to be turned into such an "ugly error" upstream.
}
}
sync.addTemporaryListener(as, "type:m.sticker", guard("m.sticker", sync.addTemporaryListener(as, "type:m.sticker", guard("m.sticker",
/** /**
* @param {Ty.Event.Outer_M_Sticker} event it is a m.sticker because that's what this listener is filtering for * @param {Ty.Event.Outer_M_Sticker} event it is a m.sticker because that's what this listener is filtering for
@ -440,6 +468,7 @@ async event => {
console.error(e) console.error(e)
return await api.leaveRoomWithReason(event.room_id, `I wasn't able to find out what this room is. Please report this as a bug. Check console for more details. (${e.toString()})`) return await api.leaveRoomWithReason(event.room_id, `I wasn't able to find out what this room is. Please report this as a bug. Check console for more details. (${e.toString()})`)
} }
if (inviteRoomState?.encryption) return await api.leaveRoomWithReason(event.room_id, "Encrypted rooms are not supported for bridging. Please use an unencrypted room.")
if (!inviteRoomState?.name) return await api.leaveRoomWithReason(event.room_id, `Please only invite me to rooms that have a name/avatar set. Update the room details and reinvite.`) if (!inviteRoomState?.name) return await api.leaveRoomWithReason(event.room_id, `Please only invite me to rooms that have a name/avatar set. Update the room details and reinvite.`)
await api.joinRoom(event.room_id) await api.joinRoom(event.room_id)
db.prepare("REPLACE INTO invite (mxid, room_id, type, name, topic, avatar) VALUES (?, ?, ?, ?, ?, ?)").run(event.sender, event.room_id, inviteRoomState.type, inviteRoomState.name, inviteRoomState.topic, inviteRoomState.avatar) db.prepare("REPLACE INTO invite (mxid, room_id, type, name, topic, avatar) VALUES (?, ?, ?, ?, ?, ?)").run(event.sender, event.room_id, inviteRoomState.type, inviteRoomState.name, inviteRoomState.topic, inviteRoomState.avatar)
@ -449,7 +478,10 @@ async event => {
if (event.content.membership === "leave" || event.content.membership === "ban") { if (event.content.membership === "leave" || event.content.membership === "ban") {
// Member is gone // Member is gone
// if Matrix member, data was cached in member_cache
db.prepare("DELETE FROM member_cache WHERE room_id = ? and mxid = ?").run(event.room_id, event.state_key) db.prepare("DELETE FROM member_cache WHERE room_id = ? and mxid = ?").run(event.room_id, event.state_key)
// if Discord member (so kicked/banned by Matrix user), data was cached in sim_member
db.prepare("DELETE FROM sim_member WHERE room_id = ? and mxid = ?").run(event.room_id, event.state_key)
// Unregister room's use as a direct chat and/or an invite target if the bot itself left // Unregister room's use as a direct chat and/or an invite target if the bot itself left
if (event.state_key === utils.bot) { if (event.state_key === utils.bot) {
@ -510,6 +542,20 @@ async event => {
await roomUpgrade.onTombstone(event, api) await roomUpgrade.onTombstone(event, api)
})) }))
sync.addTemporaryListener(as, "type:m.room.encryption", guard("m.room.encryption",
/**
* @param {Ty.Event.StateOuter<Ty.Event.M_Room_Encryption>} event
*/
async event => {
// Dramatically unbridge rooms if they become encrypted
if (event.state_key !== "") return
const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get()
if (!channelID) return
const channel = discord.channels.get(channelID)
if (!channel) return
await createRoom.unbridgeChannel(channel, channel["guild_id"], "Encrypted rooms are not supported. This room was removed from the bridge.")
}))
module.exports.stringifyErrorStack = stringifyErrorStack module.exports.stringifyErrorStack = stringifyErrorStack
module.exports.sendError = sendError module.exports.sendError = sendError
module.exports.printError = printError module.exports.printError = printError

View file

@ -172,7 +172,7 @@ function getStateEventOuter(roomID, type, key) {
/** /**
* @param {string} roomID * @param {string} roomID
* @param {{unsigned?: {invite_room_state?: Ty.Event.InviteStrippedState[]}}} [event] * @param {{unsigned?: {invite_room_state?: Ty.Event.InviteStrippedState[]}}} [event]
* @returns {Promise<{name: string?, topic: string?, avatar: string?, type: string?}>} * @returns {Promise<{name: string?, topic: string?, avatar: string?, type: string?, encryption: string?}>}
*/ */
async function getInviteState(roomID, event) { async function getInviteState(roomID, event) {
function getFromInviteRoomState(strippedState, nskey, key) { function getFromInviteRoomState(strippedState, nskey, key) {
@ -191,7 +191,8 @@ async function getInviteState(roomID, event) {
name: getFromInviteRoomState(event.unsigned.invite_room_state, "m.room.name", "name"), name: getFromInviteRoomState(event.unsigned.invite_room_state, "m.room.name", "name"),
topic: getFromInviteRoomState(event.unsigned.invite_room_state, "m.room.topic", "topic"), topic: getFromInviteRoomState(event.unsigned.invite_room_state, "m.room.topic", "topic"),
avatar: getFromInviteRoomState(event.unsigned.invite_room_state, "m.room.avatar", "url"), avatar: getFromInviteRoomState(event.unsigned.invite_room_state, "m.room.avatar", "url"),
type: getFromInviteRoomState(event.unsigned.invite_room_state, "m.room.create", "type") type: getFromInviteRoomState(event.unsigned.invite_room_state, "m.room.create", "type"),
encryption: getFromInviteRoomState(event.unsigned.invite_room_state, "m.room.encryption", "algorithm")
} }
} }
@ -227,7 +228,8 @@ async function getInviteState(roomID, event) {
name: getFromInviteRoomState(strippedState, "m.room.name", "name"), name: getFromInviteRoomState(strippedState, "m.room.name", "name"),
topic: getFromInviteRoomState(strippedState, "m.room.topic", "topic"), topic: getFromInviteRoomState(strippedState, "m.room.topic", "topic"),
avatar: getFromInviteRoomState(strippedState, "m.room.avatar", "url"), avatar: getFromInviteRoomState(strippedState, "m.room.avatar", "url"),
type: getFromInviteRoomState(strippedState, "m.room.create", "type") type: getFromInviteRoomState(strippedState, "m.room.create", "type"),
encryption: getFromInviteRoomState(strippedState, "m.room.encryption", "algorithm")
} }
} }
} catch (e) {} } catch (e) {}
@ -240,7 +242,8 @@ async function getInviteState(roomID, event) {
name: room.name ?? null, name: room.name ?? null,
topic: room.topic ?? null, topic: room.topic ?? null,
avatar: room.avatar_url ?? null, avatar: room.avatar_url ?? null,
type: room.room_type ?? null type: room.room_type ?? null,
encryption: (room.encryption || room["im.nheko.summary.encryption"]) ?? null
} }
} }

View file

@ -85,6 +85,7 @@ async function _actuallyUploadDiscordFileToMxc(url) {
writeRegistration(reg) writeRegistration(reg)
return root return root
} }
e.uploadURL = url
throw e throw e
} }
} }

View file

@ -1,6 +1,7 @@
// @ts-check // @ts-check
const assert = require("assert").strict const assert = require("assert").strict
const DiscordTypes = require("discord-api-types/v10")
const Ty = require("../types") const Ty = require("../types")
const {pipeline} = require("stream").promises const {pipeline} = require("stream").promises
const sharp = require("sharp") const sharp = require("sharp")
@ -96,33 +97,6 @@ function replyctx(execute) {
} }
} }
/**
* @param {Error & {code?: string|number}} e
* @returns {e}
*/
function unmarshallDiscordError(e) {
if (e.name === "DiscordAPIError"){
try{
const unmarshaled = JSON.parse(e.message)
return {
...e,
...unmarshaled
}
} catch (err) {
return {
...err,
code: "JSON_PARSE_FAILED",
message: JSON.stringify({
original_error_where_message_failed_to_parse: e,
json_parser_error_message: err.message,
json_parser_error_code: err.code,
})
}
}
}
return e;
}
/** @type {Command[]} */ /** @type {Command[]} */
const commands = [{ const commands = [{
aliases: ["emoji"], aliases: ["emoji"],
@ -131,7 +105,8 @@ const commands = [{
// Guard // Guard
/** @type {string} */ // @ts-ignore /** @type {string} */ // @ts-ignore
const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get() const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get()
const guildID = discord.channels.get(channelID)?.["guild_id"] const channel = discord.channels.get(channelID)
const guildID = channel?.["guild_id"]
let matrixOnlyReason = null let matrixOnlyReason = null
const matrixOnlyConclusion = "So the emoji will be uploaded on Matrix-side only. It will still be usable over the bridge, but may have degraded functionality." const matrixOnlyConclusion = "So the emoji will be uploaded on Matrix-side only. It will still be usable over the bridge, but may have degraded functionality."
// Check if we can/should upload to Discord, for various causes // Check if we can/should upload to Discord, for various causes
@ -141,7 +116,7 @@ const commands = [{
const guild = discord.guilds.get(guildID) const guild = discord.guilds.get(guildID)
assert(guild) assert(guild)
const slots = getSlotCount(guild.premium_tier) const slots = getSlotCount(guild.premium_tier)
const permissions = dUtils.getPermissions(guild.id, [], guild.roles) const permissions = dUtils.getDefaultPermissions(guild, channel["permission_overwrites"])
if (guild.emojis.length >= slots) { if (guild.emojis.length >= slots) {
matrixOnlyReason = "CAPACITY" matrixOnlyReason = "CAPACITY"
} else if (!(permissions & 0x40000000n)) { // MANAGE_GUILD_EXPRESSIONS (apparently CREATE_GUILD_EXPRESSIONS isn't good enough...) } else if (!(permissions & 0x40000000n)) { // MANAGE_GUILD_EXPRESSIONS (apparently CREATE_GUILD_EXPRESSIONS isn't good enough...)
@ -266,7 +241,8 @@ const commands = [{
// Guard // Guard
/** @type {string} */ // @ts-ignore /** @type {string} */ // @ts-ignore
const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get() const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get()
const guildID = discord.channels.get(channelID)?.["guild_id"] const channel = discord.channels.get(channelID)
const guildID = channel?.["guild_id"]
if (!guildID) { if (!guildID) {
return api.sendEvent(event.room_id, "m.room.message", { return api.sendEvent(event.room_id, "m.room.message", {
...ctx, ...ctx,
@ -277,12 +253,12 @@ const commands = [{
const guild = discord.guilds.get(guildID) const guild = discord.guilds.get(guildID)
assert(guild) assert(guild)
const permissions = dUtils.getPermissions(guild.id, [], guild.roles) const permissions = dUtils.getDefaultPermissions(guild, channel["permission_overwrites"])
if (!(permissions & 0x800000000n)) { // CREATE_PUBLIC_THREADS if (!(permissions & 0x800000000n)) { // CREATE_PUBLIC_THREADS
return api.sendEvent(event.room_id, "m.room.message", { return api.sendEvent(event.room_id, "m.room.message", {
...ctx, ...ctx,
msgtype: "m.text", msgtype: "m.text",
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." // NOTE: Currently, this assumes that all Matrix users have no Discord roles (see: empty [] in the getPermissions call above), and makes that „If you were a Discord user...” claim based on that assumption. If Discord permission emulation is gonna happen in the future, this is certainly one among many places that will need to be changed. 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."
}) })
} }
@ -309,56 +285,94 @@ const commands = [{
} }
try { try {
if (branchedFromDiscordMessage) await discord.snow.channel.createThreadWithMessage(channelID, branchedFromDiscordMessage, {name: words.slice(1).join(" ")}) 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 throw {code: "NO_BRANCH_SOURCE", was_supposed_to_be: branchedFromMxEvent}; 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){ catch (e){
switch (unmarshallDiscordError(e).code) { /**@type {string|undefined} */
case "NO_BRANCH_SOURCE": return api.sendEvent(event.room_id, "m.room.message", { let err = e.message // see: https://docs.discord.com/developers/topics/opcodes-and-status-codes
...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 `"+e.was_supposed_to_be+"` 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>"+e.was_supposed_to_be+"</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."
})
case (160004): // see: https://docs.discord.com/developers/topics/opcodes-and-status-codes if (err?.includes("160004")) {
if (isFallingBack){ 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.
await api.sendEvent(event.room_id, "m.room.message", {
...ctx,
msgtype: "m.text",
body: "⚠️ Discord claims that there already exists a thread for the message you ran this command on, but that doesn't make logical sense, as it doesn't seem like you ran this command on any message. Either your Matrix client did something funny with reply/thread tags, or this is a logic error on OOYE's side. At any rate, this should be reported for further investigation. you should also attach the error message that's about to be sent below (or on the main room timeline, if the command was ran inside a thread).",
})
throw e;
}
const thread = mxUtils.getThreadRoomFromThreadEvent(branchedFromMxEvent) const thread = mxUtils.getThreadRoomFromThreadEvent(branchedFromMxEvent)
return api.sendEvent(event.room_id, "m.room.message", { return api.sendEvent(event.room_id, "m.room.message", {
...ctx, ...ctx,
msgtype: "m.text", msgtype: "m.text",
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.)") 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.)")
}) })
}
case (50024): return api.sendEvent(event.room_id, "m.room.message", { if (err?.includes("50024")) return api.sendEvent(event.room_id, "m.room.message", {
...ctx, ...ctx,
msgtype: "m.text", msgtype: "m.text",
body: "You cannot create threads in a Discord channel of the type, to which this Matrix room is bridged to. Did you try to create a thread inside a thread?" body: "You cannot create threads in a Discord channel of the type, to which this Matrix room is bridged to. Did you try to create a thread inside a thread?"
}) })
if (err?.includes("50035")) return api.sendEvent(event.room_id, "m.room.message", {
case (50035): return api.sendEvent(event.room_id, "m.room.message", {
...ctx, ...ctx,
msgtype: "m.text", 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. ;)" body: "Specified thread name is too long - thread creation failed. Please yap a bit less in the title, the thread body is for that. ;)"
}) })
default: 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.
await api.sendEvent(event.room_id, "m.room.message", { }
}
)
}, {
aliases: ["invite"],
execute: replyctx(
async (event, realBody, words, ctx) => {
// Guard
/** @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 api.sendEvent(event.room_id, "m.room.message", {
...ctx, ...ctx,
msgtype: "m.text", msgtype: "m.text",
body: "⚠️ Unknown error occurred during thread creation. See error message below (or on the main room timeline, if the command was ran inside a thread) for details." body: "This room isn't bridged to the other side."
}) })
}
const guild = discord.guilds.get(guildID)
assert(guild)
const permissions = dUtils.getDefaultPermissions(guild, channel["permission_overwrites"])
if (!dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.CreateInstantInvite)) {
return api.sendEvent(event.room_id, "m.room.message", {
...ctx,
msgtype: "m.text",
body: "This command creates an invite link to the Discord side. But you aren't allowed to do this, because if you were a Discord user, you wouldn't have the Create Invite permission."
})
}
try {
var invite = await discord.snow.channel.createChannelInvite(channelID)
} catch (e) {
if (e.message === `{"message": "Missing Permissions", "code": 50013}`) {
return api.sendEvent(event.room_id, "m.room.message", {
...ctx,
msgtype: "m.text",
body: "I don't have permission to create invites to the Discord channel/server."
})
} else {
throw e throw e
} }
} }
const validHours = Math.ceil(invite.max_age / (60 * 60))
const validUses =
( invite.max_uses === 0 ? "unlimited uses"
: invite.max_uses === 1 ? "single-use"
: `${invite.max_uses} uses`)
return api.sendEvent(event.room_id, "m.room.message", {
...ctx,
msgtype: "m.text",
body: `https://discord.gg/${invite.code}\nValid for next ${validHours} hours, ${validUses}.`
})
} }
) )
}] }]
@ -366,9 +380,9 @@ const commands = [{
/** /**
* @param {Ty.Event.Outer_M_Room_Message} event * @param {Ty.Event.Outer_M_Room_Message} event
* @returns {Promise<string|undefined>} the executed command's name or undefined if no command execution was performed * @returns {Promise<any>|undefined} the executed command's in-process promise or undefined if no command execution was performed
*/ */
async function parseAndExecute(event) { function parseAndExecute(event) {
let realBody = event.content.body let realBody = event.content.body
while (realBody.startsWith("> ")) { while (realBody.startsWith("> ")) {
const i = realBody.indexOf("\n") const i = realBody.indexOf("\n")
@ -388,8 +402,7 @@ async function parseAndExecute(event) {
const command = commands.find(c => c.aliases.includes(commandName)) const command = commands.find(c => c.aliases.includes(commandName))
if (!command) return if (!command) return
await command.execute(event, realBody, words) return command.execute(event, realBody, words)
return words[0]
} }
module.exports.parseAndExecute = parseAndExecute module.exports.parseAndExecute = parseAndExecute

View file

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

View file

@ -1,6 +1,6 @@
// @ts-check // @ts-check
const tryToCatch = require("try-to-catch") const {tryToCatch} = require("try-to-catch")
const {test} = require("supertape") const {test} = require("supertape")
const {reg, checkRegistration, getTemplateRegistration} = require("./read-registration") const {reg, checkRegistration, getTemplateRegistration} = require("./read-registration")

View file

@ -54,6 +54,7 @@ async function onBotMembership(event, api, createRoom) {
assert.equal(event.type, "m.room.member") assert.equal(event.type, "m.room.member")
assert.equal(event.state_key, utils.bot) assert.equal(event.state_key, utils.bot)
return await roomUpgradeSema.request(async () => {
// Check if an upgrade is pending for this room // Check if an upgrade is pending for this room
const newRoomID = event.room_id const newRoomID = event.room_id
const oldRoomID = select("room_upgrade_pending", "old_room_id", {new_room_id: newRoomID}).pluck().get() const oldRoomID = select("room_upgrade_pending", "old_room_id", {new_room_id: newRoomID}).pluck().get()
@ -64,7 +65,6 @@ async function onBotMembership(event, api, createRoom) {
// Check if is join/invite // Check if is join/invite
if (event.content.membership !== "invite" && event.content.membership !== "join") return false if (event.content.membership !== "invite" && event.content.membership !== "join") return false
return await roomUpgradeSema.request(async () => {
// If invited, join // If invited, join
if (event.content.membership === "invite") { if (event.content.membership === "invite") {
await api.joinRoom(newRoomID) await api.joinRoom(newRoomID)

View file

@ -225,19 +225,6 @@ async function getViaServersQuery(roomID, api) {
return qs return qs
} }
function generatePermittedMediaHash(mxc) {
assert(hasher, "xxhash is not ready yet")
const mediaParts = mxc?.match(/^mxc:\/\/([^/]+)\/(\w+)$/)
if (!mediaParts) return undefined
const serverAndMediaID = `${mediaParts[1]}/${mediaParts[2]}`
const unsignedHash = hasher.h64(serverAndMediaID)
const signedHash = unsignedHash - 0x8000000000000000n // shifting down to signed 64-bit range
db.prepare("INSERT OR IGNORE INTO media_proxy (permitted_hash) VALUES (?)").run(signedHash)
return serverAndMediaID
}
/** /**
* Since the introduction of authenticated media, this can no longer just be the /_matrix/media/r0/download URL * Since the introduction of authenticated media, this can no longer just be the /_matrix/media/r0/download URL
* because Discord and Discord users cannot use those URLs. Media now has to be proxied through the bridge. * because Discord and Discord users cannot use those URLs. Media now has to be proxied through the bridge.

View file

@ -23,6 +23,22 @@ const setPresence = sync.require("./d2m/actions/set-presence")
const channelWebhook = sync.require("./m2d/actions/channel-webhook") const channelWebhook = sync.require("./m2d/actions/channel-webhook")
const guildID = "112760669178241024" const guildID = "112760669178241024"
async function ping() {
const result = await api.ping().catch(e => ({ok: false, status: "net", root: e.message}))
if (result.ok) {
return "Ping OK. The homeserver and OOYE are talking to each other fine."
} else {
if (typeof result.root === "string") {
var msg = `Cannot reach homeserver: ${result.root}`
} else if (result.root.error) {
var msg = `Homeserver said: [${result.status}] ${result.root.error}`
} else {
var msg = `Homeserver said: [${result.status}] ${JSON.stringify(result.root)}`
}
return msg + "\nMatrix->Discord won't work until you fix this.\nIf your installation has recently changed, consider `npm run setup` again."
}
}
if (process.stdin.isTTY) { if (process.stdin.isTTY) {
setImmediate(() => { setImmediate(() => {
if (!passthrough.repl) { if (!passthrough.repl) {

9
src/types.d.ts vendored
View file

@ -157,7 +157,7 @@ export namespace Event {
type: string type: string
state_key: string state_key: string
sender: string sender: string
content: Event.M_Room_Create | Event.M_Room_Name | Event.M_Room_Avatar | Event.M_Room_Topic | Event.M_Room_JoinRules | Event.M_Room_CanonicalAlias content: Event.M_Room_Create | Event.M_Room_Name | Event.M_Room_Avatar | Event.M_Room_Topic | Event.M_Room_JoinRules | Event.M_Room_CanonicalAlias | Event.M_Room_Encryption
} }
export type M_Room_Create = { export type M_Room_Create = {
@ -393,6 +393,12 @@ export namespace Event {
body: string body: string
replacement_room: string replacement_room: string
} }
export type M_Room_Encryption = {
algorithm: string
rotation_period_ms?: number
rotation_period_msgs?: number
}
} }
export namespace R { export namespace R {
@ -440,6 +446,7 @@ export namespace R {
num_joined_members: number num_joined_members: number
room_id: string room_id: string
room_type?: string room_type?: string
encryption?: string
} }
export type ResolvedRoom = { export type ResolvedRoom = {

View file

@ -77,6 +77,7 @@ function renderPath(event, path, locals) {
compile() compile()
fs.watch(path, {persistent: false}, compile) fs.watch(path, {persistent: false}, compile)
fs.watch(join(__dirname, "pug", "includes"), {persistent: false}, compile) fs.watch(join(__dirname, "pug", "includes"), {persistent: false}, compile)
fs.watch(join(__dirname, "pug", "fragments"), {persistent: false}, compile)
} }
const cb = pugCache.get(path) const cb = pugCache.get(path)

View file

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

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

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

View file

@ -0,0 +1,5 @@
//- locals: guild, guild_id
include ../includes/default-roles-list.pug
+default-roles-list(guild, guild_id)
+add-roles-menu(guild, guild_id)

View file

@ -1,4 +1,5 @@
extends includes/template.pug extends includes/template.pug
include includes/default-roles-list.pug
mixin badge-readonly mixin badge-readonly
.s-badge.s-badge__xs.s-badge__icon.s-badge__muted .s-badge.s-badge__xs.s-badge__icon.s-badge__muted
@ -76,7 +77,7 @@ block body
if space_id if space_id
h2.mt48.fs-headline1 Server settings h2.mt48.fs-headline1 Server settings
h3.mt32.fs-category Privacy level h3.mt32.fs-category How Matrix users join
span#privacy-level-loading span#privacy-level-loading
.s-card .s-card
form(hx-post=rel("/api/privacy-level") hx-trigger="change" hx-indicator="#privacy-level-loading" hx-disabled-elt="input") form(hx-post=rel("/api/privacy-level") hx-trigger="change" hx-indicator="#privacy-level-loading" hx-disabled-elt="input")
@ -105,6 +106,24 @@ block body
p.s-description.m0 Shareable invite links, like Discord p.s-description.m0 Shareable invite links, like Discord
p.s-description.m0 Publicly listed in directory, like Discord server discovery p.s-description.m0 Publicly listed in directory, like Discord server discovery
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 h3.mt32.fs-category Features
.s-card.d-grid.px0.g16 .s-card.d-grid.px0.g16
form.d-flex.ai-center.g16 form.d-flex.ai-center.g16
@ -230,6 +249,11 @@ block body
ul.my8.ml24 ul.my8.ml24
each row in removedLinkedRooms each row in removedLinkedRooms
li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name 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 h3.mt24 Unavailable rooms: Wrong type
.s-card.p0 .s-card.p0
ul.my8.ml24 ul.my8.ml24

View file

@ -0,0 +1,19 @@
mixin default-roles-list(guild, guild_id)
#default-roles-list(style="display: contents")
each roleID in select("role_default", "role_id", {guild_id}).pluck().all()
- let r = guild.roles.find(r => r.id === roleID)
if r
.s-tag.s-tag__md.fs-body1= r.name
span(id=`role-loading-${roleID}`)
button(name="remove_role" value=roleID hx-post="api/default-roles" hx-trigger="click consume" hx-indicator=`#role-loading-${roleID}`).s-tag--dismiss
!= icons.Icons.IconClearSm
mixin add-roles-menu(guild, guild_id)
ul.s-menu(role="menu" hx-swap-oob="true").overflow-y-auto.overflow-x-hidden#add-roles-menu
li.s-menu--title.d-flex(role="separator") Select role
span#add-role-loading
each r in guild.roles.sort((a, b) => b.position - a.position)
if r.id !== guild_id && !r.managed
- let selected = !!select("role_default", "role_id", {guild_id, role_id: r.id}).get()
li(role="menuitem")
button(name="toggle_role" value=r.id class={"is-selected": selected}).s-block-link.s-block-link__left= r.name

View file

@ -65,6 +65,7 @@ mixin define-themed-button(name, theme)
doctype html doctype html
html(lang="en") html(lang="en")
head head
block title
title Out Of Your Element title Out Of Your Element
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
link(rel="stylesheet" type="text/css" href=rel("/static/stacks.min.css")) link(rel="stylesheet" type="text/css" href=rel("/static/stacks.min.css"))
@ -88,9 +89,28 @@ html(lang="en")
--_ts-multiple-bg: var(--green-400); --_ts-multiple-bg: var(--green-400);
--_ts-multiple-fc: var(--white); --_ts-multiple-fc: var(--white);
} }
.s-avatar {
--_av-bg: var(--white);
}
.s-avatar .s-avatar--letter {
color: var(--white);
}
.s-btn__dropdown:has(+ :popover-open) { .s-btn__dropdown:has(+ :popover-open) {
background-color: var(--theme-topbar-item-background-hover, var(--black-200)) !important; background-color: var(--theme-topbar-item-background-hover, var(--black-200)) !important;
} }
.s-btn__dropdown.s-tag:has(+ :popover-open) .s-tag--dismiss {
background-color: var(--black-500) !important;
color: var(--black-150) !important;
}
.s-tag .is-loading {
margin-right: -4px;
}
.s-tag .is-loading + .s-tag--dismiss {
display: none !important;
}
a.s-block-link, .s-block-link {
--_bl-bs-color: var(--green-400);
}
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
body.theme-system .s-popover { body.theme-system .s-popover {
--_po-bg: var(--black-100); --_po-bg: var(--black-100);
@ -141,11 +161,15 @@ html(lang="en")
//- Guild list popover //- Guild list popover
script. script.
document.querySelectorAll("[popovertarget]").forEach(e => { document.querySelectorAll("[popovertarget]").forEach(e => {
e.addEventListener("click", () => { const target = document.getElementById(e.getAttribute("popovertarget"))
const rect = e.getBoundingClientRect() e.addEventListener("click", calculate)
const t = `:popover-open { position: absolute; top: ${Math.floor(rect.bottom)}px; left: ${Math.floor(rect.left + rect.width / 2)}px; width: ${Math.floor(rect.width)}px; transform: translateX(-50%); box-sizing: content-box; margin: 0 }` target.addEventListener("toggle", calculate)
function calculate() {
const buttonRect = e.getBoundingClientRect()
const targetRect = target.getBoundingClientRect()
const t = `:popover-open { position: absolute; top: ${Math.floor(buttonRect.bottom + window.scrollY)}px; left: ${Math.floor(Math.max(targetRect.width / 2, buttonRect.left + buttonRect.width / 2))}px; width: ${Math.floor(buttonRect.width)}px; transform: translateX(-50%); box-sizing: content-box; margin: 0 }`
document.styleSheets[0].insertRule(t, document.styleSheets[0].cssRules.length) document.styleSheets[0].insertRule(t, document.styleSheets[0].cssRules.length)
}) }
}) })
//- Prevent default //- Prevent default
script. script.

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

@ -1,7 +1,7 @@
// @ts-check // @ts-check
const assert = require("assert").strict const assert = require("assert").strict
const tryToCatch = require("try-to-catch") const {tryToCatch} = require("try-to-catch")
const {test} = require("supertape") const {test} = require("supertape")
const {router} = require("../../../test/web") const {router} = require("../../../test/web")
const {_cache} = require("./download-discord") const {_cache} = require("./download-discord")

View file

@ -2,7 +2,7 @@
const fs = require("fs") const fs = require("fs")
const {convertImageStream} = require("../../m2d/converters/emoji-sheet") const {convertImageStream} = require("../../m2d/converters/emoji-sheet")
const tryToCatch = require("try-to-catch") const {tryToCatch} = require("try-to-catch")
const {test} = require("supertape") const {test} = require("supertape")
const {router} = require("../../../test/web") const {router} = require("../../../test/web")
const streamWeb = require("stream/web") const streamWeb = require("stream/web")

View file

@ -4,10 +4,12 @@ const assert = require("assert/strict")
const {z} = require("zod") const {z} = require("zod")
const {defineEventHandler, createError, readValidatedBody, getRequestHeader, setResponseHeader, sendRedirect, H3Event} = require("h3") const {defineEventHandler, createError, readValidatedBody, getRequestHeader, setResponseHeader, sendRedirect, H3Event} = require("h3")
const {as, db, sync, select} = require("../../passthrough") const {as, db, sync, select, discord} = require("../../passthrough")
/** @type {import("../auth")} */ /** @type {import("../auth")} */
const auth = sync.require("../auth") const auth = sync.require("../auth")
/** @type {import("../pug-sync")} */
const pugSync = sync.require("../pug-sync")
/** @type {import("../../d2m/actions/set-presence")} */ /** @type {import("../../d2m/actions/set-presence")} */
const setPresence = sync.require("../../d2m/actions/set-presence") const setPresence = sync.require("../../d2m/actions/set-presence")
@ -20,6 +22,14 @@ function getCreateSpace(event) {
return event.context.createSpace || sync.require("../../d2m/actions/create-space") return event.context.createSpace || sync.require("../../d2m/actions/create-space")
} }
const schema = {
defaultRoles: z.object({
guild_id: z.string(),
toggle_role: z.string().optional(),
remove_role: z.string().optional()
})
}
/** /**
* @typedef Options * @typedef Options
* @prop {(value: string?) => number} transform * @prop {(value: string?) => number} transform
@ -94,3 +104,39 @@ as.router.post("/api/privacy-level", defineToggle("privacy_level", {
await createSpace.syncSpaceFully(guildID) // this is inefficient but OK to call infrequently on user request await createSpace.syncSpaceFully(guildID) // this is inefficient but OK to call infrequently on user request
} }
})) }))
as.router.post("/api/default-roles", defineEventHandler(async event => {
const parsedBody = await readValidatedBody(event, schema.defaultRoles.parse)
const managed = await auth.getManagedGuilds(event)
const guildID = parsedBody.guild_id
if (!managed.has(guildID)) throw createError({status: 403, message: "Forbidden", data: "Can't change settings for a guild you don't have Manage Server permissions in"})
const roleID = parsedBody.toggle_role || parsedBody.remove_role
assert(roleID)
assert.notEqual(guildID, roleID) // the @everyone role is always default
const guild = discord.guilds.get(guildID)
assert(guild)
let shouldRemove = !!parsedBody.remove_role
if (!shouldRemove) {
shouldRemove = !!select("role_default", "role_id", {guild_id: guildID, role_id: roleID}).get()
}
if (shouldRemove) {
db.prepare("DELETE FROM role_default WHERE guild_id = ? AND role_id = ?").run(guildID, roleID)
} else {
assert(guild.roles.find(r => r.id === roleID))
db.prepare("INSERT OR IGNORE INTO role_default (guild_id, role_id) VALUES (?, ?)").run(guildID, roleID)
}
const createSpace = getCreateSpace(event)
await createSpace.syncSpaceFully(guildID) // this is inefficient but OK to call infrequently on user request
if (getRequestHeader(event, "HX-Request")) {
return pugSync.render(event, "fragments/default-roles-list.pug", {guild, guild_id: guildID})
} else {
return sendRedirect(event, `/guild?guild_id=${guildID}`, 302)
}
}))

View file

@ -1,6 +1,6 @@
// @ts-check // @ts-check
const tryToCatch = require("try-to-catch") const {tryToCatch} = require("try-to-catch")
const {router, test} = require("../../../test/web") const {router, test} = require("../../../test/web")
const {select} = require("../../passthrough") const {select} = require("../../passthrough")
const {MatrixServerError} = require("../../matrix/mreq") const {MatrixServerError} = require("../../matrix/mreq")

View file

@ -123,13 +123,14 @@ function getChannelRoomsLinks(guild, rooms, roles) {
let unlinkedRooms = [...rooms] let unlinkedRooms = [...rooms]
let removedLinkedRooms = dUtils.filterTo(unlinkedRooms, r => !linkedRoomIDs.includes(r.room_id)) let removedLinkedRooms = dUtils.filterTo(unlinkedRooms, r => !linkedRoomIDs.includes(r.room_id))
let removedWrongTypeRooms = dUtils.filterTo(unlinkedRooms, r => !r.room_type) let removedWrongTypeRooms = dUtils.filterTo(unlinkedRooms, r => !r.room_type)
let removedEncryptedRooms = dUtils.filterTo(unlinkedRooms, r => !r.encryption && !r["im.nheko.summary.encryption"])
// https://discord.com/developers/docs/topics/threads#active-archived-threads // 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 // need to filter out linked archived threads from unlinkedRooms, will just do that by comparing against the name
let removedArchivedThreadRooms = dUtils.filterTo(unlinkedRooms, r => r.name && !r.name.match(/^\[(🔒)?⛓️\]/)) let removedArchivedThreadRooms = dUtils.filterTo(unlinkedRooms, r => r.name && !r.name.match(/^\[(🔒)?⛓️\]/))
return { return {
linkedChannelsWithDetails, unlinkedChannels, unlinkedRooms, linkedChannelsWithDetails, unlinkedChannels, unlinkedRooms,
removedUncachedChannels, removedWrongTypeChannels, removedPrivateChannels, removedLinkedRooms, removedWrongTypeRooms, removedArchivedThreadRooms removedUncachedChannels, removedWrongTypeChannels, removedPrivateChannels, removedLinkedRooms, removedWrongTypeRooms, removedArchivedThreadRooms, removedEncryptedRooms
} }
} }

View file

@ -1,7 +1,7 @@
// @ts-check // @ts-check
const DiscordTypes = require("discord-api-types/v10") const DiscordTypes = require("discord-api-types/v10")
const tryToCatch = require("try-to-catch") const {tryToCatch} = require("try-to-catch")
const {router, test} = require("../../../test/web") const {router, test} = require("../../../test/web")
const {MatrixServerError} = require("../../matrix/mreq") const {MatrixServerError} = require("../../matrix/mreq")
const {_getPosition} = require("./guild") const {_getPosition} = require("./guild")

View file

@ -204,6 +204,12 @@ as.router.post("/api/link", defineEventHandler(async event => {
throw createError({status: 403, message: e.errcode, data: `${e.errcode} - ${e.message}`}) throw createError({status: 403, message: e.errcode, data: `${e.errcode} - ${e.message}`})
} }
// Check room is not encrypted
const encryption = await api.getStateEvent(parsedBody.matrix, "m.room.encryption", "").catch(() => null)
if (encryption) {
throw createError({status: 400, message: "Bad Request", data: "Encrypted rooms are not supported for bridging. Please replace it with an unencrypted room."})
}
// Check bridge has PL 100 // Check bridge has PL 100
const {powerLevels, powers: {[utils.bot]: selfPowerLevel}} = await utils.getEffectivePower(parsedBody.matrix, [utils.bot], api) const {powerLevels, powers: {[utils.bot]: selfPowerLevel}} = await utils.getEffectivePower(parsedBody.matrix, [utils.bot], api)
if (selfPowerLevel < (powerLevels?.state_default ?? 50) || selfPowerLevel < 100) throw createError({status: 400, message: "Bad Request", data: "OOYE needs power level 100 (admin) in the target Matrix room"}) if (selfPowerLevel < (powerLevels?.state_default ?? 50) || selfPowerLevel < 100) throw createError({status: 400, message: "Bad Request", data: "OOYE needs power level 100 (admin) in the target Matrix room"})

View file

@ -1,6 +1,6 @@
// @ts-check // @ts-check
const tryToCatch = require("try-to-catch") const {tryToCatch} = require("try-to-catch")
const {router, test} = require("../../../test/web") const {router, test} = require("../../../test/web")
const {MatrixServerError} = require("../../matrix/mreq") const {MatrixServerError} = require("../../matrix/mreq")
const {select, db} = require("../../passthrough") const {select, db} = require("../../passthrough")
@ -435,6 +435,47 @@ test("web link room: check that bridge can join room (uses via for join attempt)
t.equal(called, 2) t.equal(called, 2)
}) })
test("web link room: check that room is not encrypted", async t => {
let called = 0
const [error] = await tryToCatch(() => router.test("post", "/api/link", {
sessionData: {
managedGuilds: ["665289423482519565"]
},
body: {
discord: "665310973967597573",
matrix: "!NDbIqNpJyPvfKRnNcr:cadence.moe",
guild_id: "665289423482519565"
},
api: {
async joinRoom(roomID) {
called++
return roomID
},
async *generateFullHierarchy(spaceID) {
called++
t.equal(spaceID, "!zTMspHVUBhFLLSdmnS:cadence.moe")
yield {
room_id: "!NDbIqNpJyPvfKRnNcr:cadence.moe",
children_state: [],
guest_can_join: false,
num_joined_members: 2
}
/* c8 ignore next */
},
async getStateEvent(roomID, type, key) {
called++
t.equal(roomID, "!NDbIqNpJyPvfKRnNcr:cadence.moe")
if (type === "m.room.encryption" && key === "") {
return {algorithm: "m.megolm.v1.aes-sha2"}
}
throw new Error("Unknown state event")
}
}
}))
t.equal(error.data, "Encrypted rooms are not supported for bridging. Please replace it with an unencrypted room.")
t.equal(called, 3)
})
test("web link room: check that bridge has PL 100 in target room", async t => { test("web link room: check that bridge has PL 100 in target room", async t => {
let called = 0 let called = 0
const [error] = await tryToCatch(() => router.test("post", "/api/link", { const [error] = await tryToCatch(() => router.test("post", "/api/link", {
@ -465,9 +506,10 @@ test("web link room: check that bridge has PL 100 in target room", async t => {
async getStateEvent(roomID, type, key) { async getStateEvent(roomID, type, key) {
called++ called++
t.equal(roomID, "!NDbIqNpJyPvfKRnNcr:cadence.moe") t.equal(roomID, "!NDbIqNpJyPvfKRnNcr:cadence.moe")
t.equal(type, "m.room.power_levels") if (type === "m.room.power_levels" && key === "") {
t.equal(key, "")
return {users_default: 50} return {users_default: 50}
}
throw new Error("Unknown state event")
}, },
async getStateEventOuter(roomID, type, key) { async getStateEventOuter(roomID, type, key) {
called++ called++
@ -489,7 +531,7 @@ test("web link room: check that bridge has PL 100 in target room", async t => {
} }
})) }))
t.equal(error.data, "OOYE needs power level 100 (admin) in the target Matrix room") t.equal(error.data, "OOYE needs power level 100 (admin) in the target Matrix room")
t.equal(called, 4) t.equal(called, 5)
}) })
test("web link room: successfully calls createRoom", async t => { test("web link room: successfully calls createRoom", async t => {

View file

@ -1,6 +1,6 @@
// @ts-check // @ts-check
const tryToCatch = require("try-to-catch") const {tryToCatch} = require("try-to-catch")
const {router, test} = require("../../../test/web") const {router, test} = require("../../../test/web")
const {MatrixServerError} = require("../../matrix/mreq") const {MatrixServerError} = require("../../matrix/mreq")

View file

@ -1,7 +1,7 @@
// @ts-check // @ts-check
const DiscordTypes = require("discord-api-types/v10") const DiscordTypes = require("discord-api-types/v10")
const tryToCatch = require("try-to-catch") const {tryToCatch} = require("try-to-catch")
const assert = require("assert/strict") const assert = require("assert/strict")
const {router, test} = require("../../../test/web") const {router, test} = require("../../../test/web")

View file

@ -1,6 +1,6 @@
// @ts-check // @ts-check
const tryToCatch = require("try-to-catch") const {tryToCatch} = require("try-to-catch")
const {test} = require("supertape") const {test} = require("supertape")
const {router} = require("../../../test/web") const {router} = require("../../../test/web")

View file

@ -83,7 +83,13 @@ function tryStatic(event, fallthrough) {
// Everything else // Everything else
else { else {
const mime = mimeTypes.lookup(id) const mime = mimeTypes.lookup(id)
if (typeof mime === "string") defaultContentType(event, mime) if (typeof mime === "string") {
if (mime.startsWith("text/")) {
defaultContentType(event, mime + "; charset=utf-8") // usually wise
} else {
defaultContentType(event, mime)
}
}
return { return {
size: stats.size size: stats.size
} }
@ -94,7 +100,7 @@ function tryStatic(event, fallthrough) {
const path = join(publicDir, id) const path = join(publicDir, id)
return pugSync.renderPath(event, path, {}) return pugSync.renderPath(event, path, {})
} else { } else {
return fs.promises.readFile(join(publicDir, id)) return fs.createReadStream(join(publicDir, id))
} }
} }
}) })
@ -119,6 +125,7 @@ as.router.get("/icon.png", defineEventHandler(async event => {
pugSync.createRoute(as.router, "/ok", "ok.pug") pugSync.createRoute(as.router, "/ok", "ok.pug")
sync.require("./routes/agi")
sync.require("./routes/download-matrix") sync.require("./routes/download-matrix")
sync.require("./routes/download-discord") sync.require("./routes/download-discord")
sync.require("./routes/guild-settings") sync.require("./routes/guild-settings")

View file

@ -19,6 +19,26 @@ module.exports = {
default_thread_rate_limit_per_user: 0, default_thread_rate_limit_per_user: 0,
guild_id: "112760669178241024" guild_id: "112760669178241024"
}, },
voice: {
voice_background_display: null,
version: 1774469910848,
user_limit: 0,
type: 2,
theme_color: null,
status: null,
rtc_region: null,
rate_limit_per_user: 0,
position: 0,
permission_overwrites: [],
parent_id: "805261291908104252",
nsfw: false,
name: "🍞丨[8user] Piece",
last_message_id: "1459912691098325137",
id: "1036840786093953084",
flags: 0,
bitrate: 256000,
guild_id: "112760669178241024"
},
updates: { updates: {
type: 0, type: 0,
topic: "Updates and release announcements for Out Of Your Element.", topic: "Updates and release announcements for Out Of Your Element.",
@ -4617,7 +4637,7 @@ module.exports = {
flags: 0, flags: 0,
components: [] components: []
}, },
escaping_crazy_html_tags: { extreme_html_escaping: {
id: "1158894131322552391", id: "1158894131322552391",
type: 0, type: 0,
content: "", content: "",
@ -5067,6 +5087,141 @@ module.exports = {
pinned: false, pinned: false,
mention_everyone: false, mention_everyone: false,
tts: false tts: false
},
four_images: {
type: 0,
content: "",
mentions: [],
mention_roles: [],
attachments: [],
embeds: [],
timestamp: "2026-03-12T18:00:50.737000+00:00",
edited_timestamp: null,
flags: 16384,
components: [],
id: "1481713598278533241",
channel_id: "687028734322147344",
author: {
id: "112760500130975744",
username: "minimus",
avatar: "a_a354b9eaff512485b49c82b13691b941",
discriminator: "0",
public_flags: 512,
flags: 512,
banner: null,
accent_color: null,
global_name: "minimus",
avatar_decoration_data: null,
collectibles: null,
display_name_styles: { font_id: 11, effect_id: 5, colors: [ 6106655 ] },
banner_color: null,
clan: null,
primary_guild: null
},
pinned: false,
mention_everyone: false,
tts: false,
message_reference: {
type: 1,
channel_id: "637339857118822430",
message_id: "1481696763483258891",
guild_id: "408573045540651009"
},
message_snapshots: [
{
message: {
type: 0,
content: "https://fixupx.com/i/status/2032003668787020046",
mentions: [],
mention_roles: [],
attachments: [],
embeds: [
{
type: "rich",
url: "https://fixupx.com/i/status/2032003668787020046",
description: "4chan owner Hiroyuki, Evangelion director Hideaki Anno and GACKT to participate in “humanitys last non\\-AI made social network”\n" +
"\n" +
"[automaton-media.com/en/news/4chan-owner-hiroyuki-evangelion-director-hideaki-anno-and-gackt-to-participate-in-humanitys-last-non-ai-made-social-network/](https://automaton-media.com/en/news/4chan-owner-hiroyuki-evangelion-director-hideaki-anno-and-gackt-to-participate-in-humanitys-last-non-ai-made-social-network/)\n" +
"\n" +
"**[💬](https://x.com/intent/tweet?in_reply_to=2032003668787020046) 36[🔁](https://x.com/intent/retweet?tweet_id=2032003668787020046) 212[❤](https://x.com/intent/like?tweet_id=2032003668787020046) 3\\.0K 👁 131\\.7K**",
color: 6513919,
timestamp: "2026-03-12T08:00:02+00:00",
author: {
name: "AUTOMATON WEST (@AUTOMATON_ENG)",
url: "https://x.com/AUTOMATON_ENG/status/2032003668787020046",
icon_url: "https://pbs.twimg.com/profile_images/1353559126693961729/pz-WVnDc_200x200.jpg",
proxy_icon_url: "https://images-ext-1.discordapp.net/external/1OzGhjvZTRstTxM38_7pqHXlmdbMddqh1F8R0-WrKqw/https/pbs.twimg.com/profile_images/1353559126693961729/pz-WVnDc_200x200.jpg"
},
image: {
url: "https://pbs.twimg.com/media/HDMUyf6bQAM3yts.jpg?name=orig",
proxy_url: "https://images-ext-1.discordapp.net/external/NkNgp2SyY1OCH9IdS8hqsUqbnbrp3A9oLNwYusVVCVQ/%3Fname%3Dorig/https/pbs.twimg.com/media/HDMUyf6bQAM3yts.jpg",
width: 872,
height: 886,
content_type: "image/jpeg",
placeholder: "6vcFFwL6R3lye2V3l1mIl5l3WPN5FZ8H",
placeholder_version: 1,
flags: 0
},
footer: {
text: "FixupX",
icon_url: "https://assets.fxembed.com/logos/fixupx64.png",
proxy_icon_url: "https://images-ext-1.discordapp.net/external/LwQ70Uiqfu0OCN4ZbA4f482TGCgQa-xGsnUFYfhIgYA/https/assets.fxembed.com/logos/fixupx64.png"
},
content_scan_version: 4
},
{
type: "rich",
url: "https://fixupx.com/i/status/2032003668787020046",
image: {
url: "https://pbs.twimg.com/media/HDMUgxybQAE4FtJ.jpg?name=orig",
proxy_url: "https://images-ext-1.discordapp.net/external/Rquh1ec-tG9hMqdHqIVSphO7zf5B5Fg_7yTWhCjlsek/%3Fname%3Dorig/https/pbs.twimg.com/media/HDMUgxybQAE4FtJ.jpg",
width: 1114,
height: 991,
content_type: "image/jpeg",
placeholder: "JQgKDoL3epZ8ZIdnlmmHZ4d4CIGmUEc=",
placeholder_version: 1,
flags: 0
},
content_scan_version: 4
},
{
type: "rich",
url: "https://fixupx.com/i/status/2032003668787020046",
image: {
url: "https://pbs.twimg.com/media/HDMUrPobgAAeb90.jpg?name=orig",
proxy_url: "https://images-ext-1.discordapp.net/external/XrkhHNH3CvlZYvjkdykVnf-_xdz6HWX8uwesoAwwSfY/%3Fname%3Dorig/https/pbs.twimg.com/media/HDMUrPobgAAeb90.jpg",
width: 944,
height: 954,
content_type: "image/jpeg",
placeholder: "m/cJDwCbV0mfaoZzlihqeXdqCVN9A6oD",
placeholder_version: 1,
flags: 0
},
content_scan_version: 4
},
{
type: "rich",
url: "https://fixupx.com/i/status/2032003668787020046",
image: {
url: "https://pbs.twimg.com/media/HDMUuy5bgAAInj5.jpg?name=orig",
proxy_url: "https://images-ext-1.discordapp.net/external/lO-5hBMU9bGH13Ax9xum2T2Mg0ATdv0b6BEx_VeVi80/%3Fname%3Dorig/https/pbs.twimg.com/media/HDMUuy5bgAAInj5.jpg",
width: 1200,
height: 630,
content_type: "image/jpeg",
placeholder: "tfcJDIK3mIl1eIiPdY23dX9b9w==",
placeholder_version: 1,
flags: 0
},
content_scan_version: 4
}
],
timestamp: "2026-03-12T16:53:57.009000+00:00",
edited_timestamp: null,
flags: 0,
components: []
}
}
]
} }
}, },
message_with_components: { message_with_components: {
@ -6035,6 +6190,37 @@ module.exports = {
components: [], components: [],
position: 12 position: 12
}, },
channel_follow_add: {
type: 12,
content: "PluralKit #downtime",
attachments: [],
embeds: [],
timestamp: "2026-03-24T23:16:04.097Z",
edited_timestamp: null,
flags: 0,
components: [],
id: "1486141581047369888",
channel_id: "1451125453082591314",
author: {
id: "154058479798059009",
username: "exaptations",
discriminator: "0",
avatar: "57b5cfe09a48a5902f2eb8fa65bb1b80",
bot: false,
flags: 0,
globalName: "Exa",
},
pinned: false,
mentions: [],
mention_roles: [],
mention_everyone: false,
tts: false,
message_reference: {
type: 0,
channel_id: "1015204661701124206",
guild_id: "466707357099884544"
}
},
updated_to_start_thread_from_here: { updated_to_start_thread_from_here: {
t: "MESSAGE_UPDATE", t: "MESSAGE_UPDATE",
s: 19, s: 19,

View file

@ -38,15 +38,28 @@ INSERT INTO sim (user_id, username, sim_name, mxid) VALUES
('1109360903096369153', 'Amanda', 'amanda', '@_ooye_amanda:cadence.moe'), ('1109360903096369153', 'Amanda', 'amanda', '@_ooye_amanda:cadence.moe'),
('43d378d5-1183-47dc-ab3c-d14e21c3fe58', '_pk_zoego', '_pk_zoego', '@_ooye__pk_zoego:cadence.moe'), ('43d378d5-1183-47dc-ab3c-d14e21c3fe58', '_pk_zoego', '_pk_zoego', '@_ooye__pk_zoego:cadence.moe'),
('320067006521147393', 'papiophidian', 'papiophidian', '@_ooye_papiophidian:cadence.moe'), ('320067006521147393', 'papiophidian', 'papiophidian', '@_ooye_papiophidian:cadence.moe'),
('772659086046658620', 'cadence.worm', 'cadence', '@_ooye_cadence:cadence.moe'); ('772659086046658620', 'cadence.worm', 'cadence', '@_ooye_cadence:cadence.moe'),
('196188877885538304', 'ampflower', 'ampflower', '@_ooye_ampflower:cadence.moe'),
('1458668878107381800', 'Evil Lillith (she/her)', 'evil_lillith_sheher', '@_ooye_evil_lillith_sheher:cadence.moe'),
('197126718400626689', 'infinidoge1337', 'infinidoge1337', '@_ooye_infinidoge1337:cadence.moe');
INSERT INTO sim_member (mxid, room_id, hashed_profile_content) VALUES INSERT INTO sim_member (mxid, room_id, hashed_profile_content) VALUES
('@_ooye_bojack_horseman:cadence.moe', '!hYnGGlPHlbujVVfktC:cadence.moe', NULL), ('@_ooye_bojack_horseman:cadence.moe', '!hYnGGlPHlbujVVfktC:cadence.moe', NULL),
('@_ooye_cadence:cadence.moe', '!BnKuBPCvyfOkhcUjEu:cadence.moe', NULL); ('@_ooye_cadence:cadence.moe', '!BnKuBPCvyfOkhcUjEu:cadence.moe', NULL),
('@_ooye_cadence:cadence.moe', '!kLRqKKUQXcibIMtOpl:cadence.moe', NULL),
('@_ooye_cadence:cadence.moe', '!fGgIymcYWOqjbSRUdV:cadence.moe', NULL),
('@_ooye_ampflower:cadence.moe', '!qzDBLKlildpzrrOnFZ:cadence.moe', NULL),
('@_ooye__pk_zoego:cadence.moe', '!qzDBLKlildpzrrOnFZ:cadence.moe', NULL),
('@_ooye_infinidoge1337:cadence.moe', '!BnKuBPCvyfOkhcUjEu:cadence.moe', NULL),
('@_ooye_evil_lillith_sheher:cadence.moe', '!BnKuBPCvyfOkhcUjEu:cadence.moe', NULL);
INSERT INTO sim_proxy (user_id, proxy_owner_id, displayname) VALUES INSERT INTO sim_proxy (user_id, proxy_owner_id, displayname) VALUES
('43d378d5-1183-47dc-ab3c-d14e21c3fe58', '196188877885538304', 'Azalea &flwr; 🌺'); ('43d378d5-1183-47dc-ab3c-d14e21c3fe58', '196188877885538304', 'Azalea &flwr; 🌺');
INSERT INTO app_user_install (guild_id, app_bot_id, user_id) VALUES
('66192955777486848', '1458668878107381800', '197126718400626689');
INSERT INTO message_room (message_id, historical_room_index) INSERT INTO message_room (message_id, historical_room_index)
WITH a (message_id, channel_id) AS (VALUES WITH a (message_id, channel_id) AS (VALUES
('1106366167788044450', '122155380120748034'), ('1106366167788044450', '122155380120748034'),

View file

@ -6,31 +6,29 @@ const sqlite = require("better-sqlite3")
const {Writable} = require("stream") const {Writable} = require("stream")
const migrate = require("../src/db/migrate") const migrate = require("../src/db/migrate")
const HeatSync = require("heatsync") const HeatSync = require("heatsync")
const {test, extend} = require("supertape") const {test} = require("supertape")
const data = require("./data") const data = require("./data")
const {green} = require("ansi-colors") const {green} = require("ansi-colors")
const mixin = require("@cloudrac3r/mixin-deep")
const passthrough = require("../src/passthrough") const passthrough = require("../src/passthrough")
const db = new sqlite(":memory:") const db = new sqlite(":memory:")
const {reg} = require("../src/matrix/read-registration") const registration = require("../src/matrix/read-registration")
reg.ooye.discord_token = "Njg0MjgwMTkyNTUzODQ0NzQ3.Xl3zlw.baby" registration.reg = mixin(registration.getTemplateRegistration("cadence.moe"), {
reg.ooye.server_origin = "https://matrix.cadence.moe" // so that tests will pass even when hard-coded id: "baby",
reg.ooye.server_name = "cadence.moe" url: "http://localhost:6693",
reg.ooye.namespace_prefix = "_ooye_" as_token: "don't actually take authenticated actions on the server",
reg.sender_localpart = "_ooye_bot" hs_token: "don't actually take authenticated actions on the server",
reg.id = "baby" ooye: {
reg.as_token = "don't actually take authenticated actions on the server" server_origin: "https://matrix.cadence.moe",
reg.hs_token = "don't actually take authenticated actions on the server" bridge_origin: "https://bridge.example.org",
reg.namespaces = { discord_token: "Njg0MjgwMTkyNTUzODQ0NzQ3.Xl3zlw.baby",
users: [{regex: "@_ooye_.*:cadence.moe", exclusive: true}], discord_client_secret: "baby",
aliases: [{regex: "#_ooye_.*:cadence.moe", exclusive: true}] web_password: "password123",
time_zone: "Pacific/Auckland",
} }
reg.ooye.bridge_origin = "https://bridge.example.org" })
reg.ooye.time_zone = "Pacific/Auckland"
reg.ooye.max_file_size = 5000000
reg.ooye.web_password = "password123"
reg.ooye.include_user_id_in_mxid = false
const sync = new HeatSync({watchFS: false}) const sync = new HeatSync({watchFS: false})
@ -154,6 +152,7 @@ file._actuallyUploadDiscordFileToMxc = function(url, res) { throw new Error(`Not
require("../src/d2m/converters/message-to-event.test.embeds") require("../src/d2m/converters/message-to-event.test.embeds")
require("../src/d2m/converters/message-to-event.test.pk") require("../src/d2m/converters/message-to-event.test.pk")
require("../src/d2m/converters/pins-to-list.test") require("../src/d2m/converters/pins-to-list.test")
require("../src/d2m/converters/remove-member-mxids.test")
require("../src/d2m/converters/remove-reaction.test") require("../src/d2m/converters/remove-reaction.test")
require("../src/d2m/converters/thread-to-announcement.test") require("../src/d2m/converters/thread-to-announcement.test")
require("../src/d2m/converters/user-to-mxid.test") require("../src/d2m/converters/user-to-mxid.test")
@ -176,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/log-in-with-matrix.test")
require("../src/web/routes/oauth.test") require("../src/web/routes/oauth.test")
require("../src/web/routes/password.test") require("../src/web/routes/password.test")
require("../src/agi/generator.test")
})() })()