Compare commits

..

32 commits

Author SHA1 Message Date
ab051f301f Fix polls in threads 2026-06-13 20:27:48 +12:00
51d57051f6 Fix not giving speedbump info when it's bypassed 2026-06-12 18:08:43 +12:00
f7609b2040 Only speedbump users that have used PK 2026-06-06 23:38:49 +12:00
b576869764 v3.6 2026-06-04 18:07:39 +12:00
47dc0504ff Consistent font colour 2026-06-03 00:36:51 +12:00
fbade33ff0 Update language to sound more warningcore 2026-06-03 00:34:37 +12:00
e2ab9fa9bf Improve PK ping message 2026-06-03 00:02:48 +12:00
18b6efdd18 Fix editing permissions interactions not working
Co-authored-by: Cadence Ember <cadence@disroot.org>
2026-06-01 16:55:11 +12:00
313efb29d8 Fix m->d reaction deletion counting (#85)
Fixes a bug where, if multiple Matrix users had used the same reaction on a message, and then one of those Matrix users removed their reactions, the bot would forcibly remove all of that reactions. Now, we check and make sure there are no remaining reactions from Matrix before removal.

This also rewrote the retrigger system to be more generic and to use promises instead of re-entry (would lose call stack).

Co-authored-by: Cadence Ember <cadence@disroot.org>
Reviewed-on: cadence/out-of-your-element#85
2026-06-01 04:54:38 +00:00
af6ea072f3 Add stats
Just adding this early version for now so I can iterate.
2026-05-30 15:28:26 +12:00
24c2dee7d3 Fix m->d custom emoji reactions on some clients 2026-05-30 15:16:54 +12:00
16867d57fb Rework how getMedia does thumbnails 2026-05-29 20:10:32 +12:00
aecfde54c8 Resize avatars before sending to Discord 2026-05-29 20:10:01 +12:00
ee406caf24 Update CloudStorm 2026-05-28 13:20:35 +12:00
9b37705a73 Indicate that errors may be retried 2026-05-28 13:18:18 +12:00
7f7a366cd5 Fix tests for command emoji change 2026-05-22 14:34:59 +12:00
99eacd8c47 Generate letter avatars if no avatar 2026-05-22 14:34:32 +12:00
e0eb7deb2f Change arrow to chevron for commands 2026-05-21 23:19:03 +12:00
e435b78e28 Do not revoke newer webhooks 2026-05-21 19:13:03 +12:00
d76936b157 Change emoji for forwards/crossposts 2026-05-21 19:09:02 +12:00
dec216c0c2 Update dependencies 2026-05-21 19:04:42 +12:00
7781d1e34d Increase d->m catch-up limit to 100 2026-05-21 18:44:48 +12:00
93bbc5ea0f Revoke webhooks that might have been compromised 2026-05-21 18:28:11 +12:00
43b8b02b40 Remove webhook tokens from error messages 2026-05-21 17:59:52 +12:00
eb676256e4 Fix Discord mentions with extra HTML attributes 2026-05-14 18:20:32 +12:00
4815d28aa4 Code blocks uploaded as attachments when too long 2026-05-13 14:38:14 +12:00
191a98e1dc Fix watching registration file before creation 2026-05-12 14:11:06 +12:00
678a1b77bb Cap length of channels report 2026-05-12 14:08:58 +12:00
2aff1fbd06 Code block attachments use Discord supported types 2026-05-12 14:07:14 +12:00
92d6ada71b Merge tag 'v3.5.1'
Remove AI joke
2026-05-10 20:41:21 +12:00
4698835549 v3.5.1 2026-03-29 15:43:43 +13:00
e7cbfb9fc9 Remove AI joke
This reverts commit 201814e9f4.
2026-03-29 15:43:23 +13:00
62 changed files with 1909 additions and 2040 deletions

5
.gitignore vendored
View file

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

View file

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

View file

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

102
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "out-of-your-element",
"version": "3.5.0",
"version": "3.6.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "out-of-your-element",
"version": "3.5.0",
"version": "3.6.0",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@chriscdn/promise-semaphore": "^3.0.1",
@ -24,7 +24,7 @@
"ansi-colors": "^4.1.3",
"better-sqlite3": "^12.2.0",
"chunk-text": "^2.0.1",
"cloudstorm": "^0.17.0",
"cloudstorm": "^0.17.1",
"discord-api-types": "^0.38.38",
"domino": "^2.1.6",
"enquirer": "^2.4.1",
@ -48,7 +48,7 @@
"@types/node": "^22.17.1",
"c8": "^11.0.0",
"cross-env": "^7.0.3",
"supertape": "^12.0.12"
"supertape": "^13.2.0"
},
"engines": {
"node": ">=22"
@ -1003,9 +1003,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.19.15",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
"integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==",
"version": "22.19.19",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz",
"integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -1129,9 +1129,9 @@
"license": "MIT"
},
"node_modules/better-sqlite3": {
"version": "12.8.0",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.8.0.tgz",
"integrity": "sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ==",
"version": "12.10.0",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.10.0.tgz",
"integrity": "sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@ -1139,7 +1139,7 @@
"prebuild-install": "^7.1.1"
},
"engines": {
"node": "20.x || 22.x || 23.x || 24.x || 25.x"
"node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x"
}
},
"node_modules/bindings": {
@ -1163,9 +1163,9 @@
}
},
"node_modules/brace-expansion": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -1316,12 +1316,12 @@
}
},
"node_modules/cloudstorm": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/cloudstorm/-/cloudstorm-0.17.0.tgz",
"integrity": "sha512-zsd9y5ljNnbxdvDid9TgWePDqo7il4so5spzx6NDwZ67qWQjR96UUhLxJ+BAOdBBSPF9UXFM61dAzC2g918q+A==",
"version": "0.17.1",
"resolved": "https://registry.npmjs.org/cloudstorm/-/cloudstorm-0.17.1.tgz",
"integrity": "sha512-LYUwzHagRYRd93XocOqi+HCHdzPYI9cW7Yf7pYqinxgG+Qka1OiqBKWTCcLiEuiqXaOV30kr8c6aZ/c1QcDP4Q==",
"license": "MIT",
"dependencies": {
"discord-api-types": "^0.38.40",
"discord-api-types": "^0.38.47",
"snowtransfer": "^0.17.5"
},
"engines": {
@ -1366,9 +1366,9 @@
"license": "MIT"
},
"node_modules/cookie-es": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-1.2.2.tgz",
"integrity": "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==",
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-1.2.3.tgz",
"integrity": "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==",
"license": "MIT"
},
"node_modules/cross-env": {
@ -1452,9 +1452,9 @@
}
},
"node_modules/defu": {
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz",
"integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==",
"version": "6.1.7",
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz",
"integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
"license": "MIT"
},
"node_modules/destr": {
@ -1473,9 +1473,9 @@
}
},
"node_modules/discord-api-types": {
"version": "0.38.42",
"resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.42.tgz",
"integrity": "sha512-qs1kya7S84r5RR8m9kgttywGrmmoHaRifU1askAoi+wkoSefLpZP6aGXusjNw5b0jD3zOg3LTwUa3Tf2iHIceQ==",
"version": "0.38.47",
"resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.47.tgz",
"integrity": "sha512-XgXQodHQBAE6kfD7kMvVo30863iHX1LHSqNq6MGUTDwIFCCvHva13+rwxyxVXDqudyApMNAd32PGjgVETi5rjA==",
"license": "MIT",
"workspaces": [
"scripts/actions/documentation"
@ -1688,14 +1688,14 @@
}
},
"node_modules/h3": {
"version": "1.15.10",
"resolved": "https://registry.npmjs.org/h3/-/h3-1.15.10.tgz",
"integrity": "sha512-YzJeWSkDZxAhvmp8dexjRK5hxziRO7I9m0N53WhvYL5NiWfkUkzssVzY9jvGu0HBoLFW6+duYmNSn6MaZBCCtg==",
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/h3/-/h3-1.15.11.tgz",
"integrity": "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==",
"license": "MIT",
"dependencies": {
"cookie-es": "^1.2.2",
"cookie-es": "^1.2.3",
"crossws": "^0.3.5",
"defu": "^6.1.4",
"defu": "^6.1.6",
"destr": "^2.0.5",
"iron-webcrypto": "^1.2.1",
"node-mock-http": "^1.0.4",
@ -1753,9 +1753,9 @@
"license": "MIT"
},
"node_modules/htmx.org": {
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.8.tgz",
"integrity": "sha512-fm297iru0iWsNJlBrjvtN7V9zjaxd+69Oqjh4F/Vq9Wwi2kFisLcrLCiv5oBX0KLfOX/zG8AUo9ROMU5XUB44Q==",
"version": "2.0.10",
"resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.10.tgz",
"integrity": "sha512-kdeJe7ZVwaS6QMz/ebBIVtZdpwen6L0OQ5GOhPV9MKBb196TCZeZu4yA7ZIQsaLKv7EpXz+So7KSXNuHXhj7Cw==",
"license": "0BSD"
},
"node_modules/ieee754": {
@ -1974,9 +1974,9 @@
}
},
"node_modules/lru-cache": {
"version": "11.2.7",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz",
"integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==",
"version": "11.5.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz",
"integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==",
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
@ -2579,12 +2579,12 @@
}
},
"node_modules/snowtransfer": {
"version": "0.17.5",
"resolved": "https://registry.npmjs.org/snowtransfer/-/snowtransfer-0.17.5.tgz",
"integrity": "sha512-nVI1UJNFoX1ndGFZxB3zb3X5SWtD9hIAcw7wCgVKWvCf42Wg2B4UFIrZWI83HxaSBY0CGbPZmZzZb3RSt/v2wQ==",
"version": "0.17.7",
"resolved": "https://registry.npmjs.org/snowtransfer/-/snowtransfer-0.17.7.tgz",
"integrity": "sha512-scbOjYezo1Ycfk21atCEkeXIISTT7R7JTHCdiZ/7m7k4XbSb6o5q8Mu2fev5IqFpNyqIVjA0d/MZQ+eP/gtwfg==",
"license": "MIT",
"dependencies": {
"discord-api-types": "^0.38.40"
"discord-api-types": "^0.38.47"
},
"engines": {
"node": ">=22.0.0"
@ -2667,9 +2667,9 @@
}
},
"node_modules/supertape": {
"version": "12.10.5",
"resolved": "https://registry.npmjs.org/supertape/-/supertape-12.10.5.tgz",
"integrity": "sha512-1Px+6mhFaqcht3p4tkf3o4G8lbBazvx4pgFngm4vGwWipYm3fykm6SJ4ThXobiaNsptz53CDWA2q4B/2KtmA4w==",
"version": "13.2.0",
"resolved": "https://registry.npmjs.org/supertape/-/supertape-13.2.0.tgz",
"integrity": "sha512-UoxZnyoMOdSJHvbcmD8i28MaGXsA7I0cJ0jr8anT4CkmfaE9M1y5mt9EoXyzfC8UdnQZwXOnJLUwqyKLAeUOug==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -2856,9 +2856,9 @@
"license": "MIT"
},
"node_modules/uqr": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/uqr/-/uqr-0.1.2.tgz",
"integrity": "sha512-MJu7ypHq6QasgF5YRTjqscSzQp/W11zoUk6kvmlH+fmWEs63Y0Eib13hYFwAzagRJcVY8WVnlV+eBDUGMJ5IbA==",
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/uqr/-/uqr-0.1.3.tgz",
"integrity": "sha512-0rjE8iEJe4YmT9TOhwsZtqCMRLc5DXZUI2UEYUUg63ikBkqqE5EYWaI0etFe/5KUcmcYwLih2RND1kq+hrUJXA==",
"license": "MIT"
},
"node_modules/util-deprecate": {
@ -3028,9 +3028,9 @@
}
},
"node_modules/zod": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"

View file

@ -1,6 +1,6 @@
{
"name": "out-of-your-element",
"version": "3.5.0",
"version": "3.6.0",
"description": "A bridge between Matrix and Discord",
"main": "index.js",
"repository": {
@ -33,7 +33,7 @@
"ansi-colors": "^4.1.3",
"better-sqlite3": "^12.2.0",
"chunk-text": "^2.0.1",
"cloudstorm": "^0.17.0",
"cloudstorm": "^0.17.1",
"discord-api-types": "^0.38.38",
"domino": "^2.1.6",
"enquirer": "^2.4.1",
@ -60,7 +60,7 @@
"@types/node": "^22.17.1",
"c8": "^11.0.0",
"cross-env": "^7.0.3",
"supertape": "^12.0.12"
"supertape": "^13.2.0"
},
"scripts": {
"start": "node --enable-source-maps start.js",

View file

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

View file

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

View file

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

View file

@ -1,161 +0,0 @@
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."
)
})

View file

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

View file

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

View file

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

View file

@ -23,8 +23,6 @@ const pollEnd = sync.require("../actions/poll-end")
const dUtils = sync.require("../../discord/utils")
/** @type {import("../../m2d/actions/channel-webhook")} */
const channelWebhook = sync.require("../../m2d/actions/channel-webhook")
/** @type {import("../../agi/listener")} */
const agiListener = sync.require("../../agi/listener")
/**
* @param {DiscordTypes.GatewayMessageCreateDispatchData} message
@ -62,8 +60,7 @@ async function sendMessage(message, channel, guild, row) {
const detailedResultsMessage = await pollEnd.endPoll(message)
if (detailedResultsMessage) {
const threadParent = select("channel_room", "thread_parent", {channel_id: message.channel_id}).pluck().get()
const channelID = threadParent ? threadParent : message.channel_id
const threadID = threadParent ? message.channel_id : undefined
const {channelID, threadID} = dUtils.swapThreadID(message.channel_id, threadParent)
sentResultsMessage = await channelWebhook.sendMessageWithWebhook(channelID, detailedResultsMessage, threadID)
}
}
@ -139,8 +136,6 @@ async function sendMessage(message, channel, guild, row) {
}
}
await agiListener.process(message, channel, guild, false)
return eventIDs
}

View file

@ -1,6 +1,5 @@
// @ts-check
const DiscordTypes = require("discord-api-types/v10")
const passthrough = require("../../passthrough")
const {discord, select, db} = passthrough
@ -70,12 +69,18 @@ async function doSpeedbump(messageID) {
* Check whether to slow down a message, and do it. After it passes the speedbump, return whether it's okay or if it's been deleted.
* @param {string} channelID
* @param {string} messageID
* @param {string} [userID] if provided, only slow down the message when the user has used PK before
* @returns whether it was deleted, and data about the channel's (not thread's) speedbump
*/
async function maybeDoSpeedbump(channelID, messageID) {
let row = select("channel_room", ["thread_parent", "speedbump_id", "speedbump_webhook_id"], {channel_id: channelID}).get()
if (row?.thread_parent) row = select("channel_room", ["thread_parent", "speedbump_id", "speedbump_webhook_id"], {channel_id: row.thread_parent}).get() // webhooks belong to the channel, not the thread
if (!row?.speedbump_webhook_id) return {affected: false, row: null} // not affected, no speedbump
async function maybeDoSpeedbump(channelID, messageID, userID) {
let row = select("channel_room", ["room_id", "thread_parent", "speedbump_id", "speedbump_webhook_id"], {channel_id: channelID}).get()
if (row?.thread_parent) row = select("channel_room", ["room_id", "thread_parent", "speedbump_id", "speedbump_webhook_id"], {channel_id: row.thread_parent}).get() // webhooks belong to the channel, not the thread
if (!row?.speedbump_webhook_id) return {affected: false, row: null} // channel not affected, no speedbump
if (userID) {
if (row.speedbump_webhook_id === userID) return {affected: false, row} // shortcut
const userHasProxy = select("sim_proxy", "user_id", {proxy_owner_id: userID}).pluck().get()
if (!userHasProxy) return {affected: false, row} // user has not used PK before, no speedbump
}
const affected = await doSpeedbump(messageID)
return {affected, row} // maybe affected, and there is a speedbump
}

View file

@ -109,7 +109,7 @@ const embedTitleParser = markdown.markdownEngine.parserFor({
/**
* @param {{room?: boolean, user_ids?: string[]}} mentions
* @param {Omit<DiscordTypes.APIAttachment, "id" | "proxy_url">} attachment
* @param {Omit<DiscordTypes.APIAttachment, "id" | "proxy_url" | "flags">} attachment
* @param {boolean} [alwaysLink]
*/
async function attachmentToEvent(mentions, attachment, alwaysLink) {
@ -256,8 +256,8 @@ function getFormattedInteraction(interaction, isThinkingInteraction) {
const username = interaction.member?.nick || interaction.user.global_name || interaction.user.username
const thinkingText = isThinkingInteraction ? " — interaction loading..." : ""
return {
body: `↪️ ${username} used \`/${interaction.name}\`${thinkingText}`,
html: `<blockquote><sub>↪️ ${mxid ? tag`<a href="https://matrix.to/#/${mxid}">${username}</a>` : username} used <code>/${interaction.name}</code>${thinkingText}</sub></blockquote>`
body: ` ${username} used \`/${interaction.name}\`${thinkingText}`,
html: `<blockquote><sub> ${mxid ? tag`<a href="https://matrix.to/#/${mxid}">${username}</a>` : username} used <code>/${interaction.name}</code>${thinkingText}</sub></blockquote>`
}
}
@ -265,8 +265,9 @@ 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
* @param {boolean} [forceMerge] if true, must merge event, will error if it had to append
*/
function mergeTextEvents(newEvents, events, forceSameMsgtype) {
function mergeTextEvents(newEvents, events, forceSameMsgtype, forceMerge = false) {
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)
@ -278,6 +279,8 @@ function mergeTextEvents(newEvents, events, forceSameMsgtype) {
rep.addLine(ne.body, ne.formatted_body)
prev.body = rep.body
prev.formatted_body = rep.formattedBody
} else if (forceMerge) {
throw new Error("Unable to merge events")
} else {
events.push(ne)
}
@ -640,8 +643,8 @@ async function messageToEvent(message, guild, options = {}, di) {
const flags = message.flags || 0
if (flags & DiscordTypes.MessageFlags.IsCrosspost) {
body = `[🔀 ${message.author.username}]\n` + body
html = `🔀 <strong>${message.author.username}</strong><br>` + html
body = `[ ${message.author.username}]\n` + body
html = ` <strong>${message.author.username}</strong><br>` + html
}
// Fallback body/formatted_body for replies
@ -768,20 +771,20 @@ async function messageToEvent(message, guild, options = {}, di) {
if (row && "event_id" in row) {
const via = await getViaServersMemo(row.room_id)
forwardedNotice.addLine(
`[🔀 Forwarded from #${roomName}]`,
tag`🔀 <em>Forwarded from ${roomName} <a href="https://matrix.to/#/${room.room_id}/${row.event_id}?${via}">[jump to event]</a></em>`
`[ Forwarded from #${roomName}]`,
tag` <em>Forwarded from ${roomName} <a href="https://matrix.to/#/${room.room_id}/${row.event_id}?${via}">[jump to event]</a></em>`
)
} else {
const via = await getViaServersMemo(room.room_id)
forwardedNotice.addLine(
`[🔀 Forwarded from #${roomName}]`,
tag`🔀 <em>Forwarded from ${roomName} <a href="https://matrix.to/#/${room.room_id}?${via}">[jump to room]</a></em>`
`[ Forwarded from #${roomName}]`,
tag` <em>Forwarded from ${roomName} <a href="https://matrix.to/#/${room.room_id}?${via}">[jump to room]</a></em>`
)
}
} else {
forwardedNotice.addLine(
`[🔀 Forwarded message]`,
tag`🔀 <em>Forwarded message</em>`
`[ Forwarded message]`,
tag` <em>Forwarded message</em>`
)
}
@ -967,7 +970,8 @@ async function messageToEvent(message, guild, options = {}, di) {
// May only be a section accessory or in an action row (up to 5)
if (component.style === DiscordTypes.ButtonStyle.Link) {
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> `)
const link = await transformContentMessageLinks(component.url)
stack.msb.add(`[${component.label} ${link}] `, tag`<a href="${link}">${component.label}</a> `)
}
}
@ -980,8 +984,20 @@ async function messageToEvent(message, guild, options = {}, di) {
const {body, formatted_body} = stack.msb.get()
if (body.trim().length) {
// Create new message if Components V2 (cannot have regular content)
if ((message.flags ?? 0) & DiscordTypes.MessageFlags.IsComponentsV2) {
await addTextEvent(body, formatted_body, "m.text")
}
// Add to existing message if legacy components https://docs.discord.com/developers/components/reference#legacy-message-component-behavior
else {
mergeTextEvents([{
msgtype: "m.text",
body,
format: "org.matrix.custom.html",
formatted_body
}], events, false, true)
}
}
}
// Then polls
@ -1127,7 +1143,7 @@ async function messageToEvent(message, guild, options = {}, di) {
}
} else {
let body = stickerItem.name
const sticker = guild.stickers.find(sticker => sticker.id === stickerItem.id)
const sticker = guild.stickers?.find(sticker => sticker.id === stickerItem.id)
if (sticker && sticker.description) body += ` - ${sticker.description}`
return {
$type: "m.sticker",

View file

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

View file

@ -8,9 +8,9 @@ test("message2event embeds: interaction loading", async t => {
const events = await messageToEvent(data.interaction_message.thinking_interaction, data.guild.general, {})
t.deepEqual(events, [{
$type: "m.room.message",
body: "↪️ Brad used `/stats` — interaction loading...",
body: " Brad used `/stats` — interaction loading...",
format: "org.matrix.custom.html",
formatted_body: "<blockquote><sub>↪️ <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">Brad</a> used <code>/stats</code> — interaction loading...</sub></blockquote>",
formatted_body: "<blockquote><sub> <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">Brad</a> used <code>/stats</code> — interaction loading...</sub></blockquote>",
"m.mentions": {},
msgtype: "m.notice",
}])
@ -22,12 +22,12 @@ test("message2event embeds: nothing but a field", async t => {
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.notice",
body: "↪️ PapiOphidian used `/stats`"
body: " PapiOphidian used `/stats`"
+ "\n| ### Amanda 🎵#2192 :online:"
+ "\n| willow tree, branch 0"
+ "\n| ** Uptime:**\n| 3m 55s\n| ** Memory:**\n| 64.45MB",
format: "org.matrix.custom.html",
formatted_body: '<blockquote><sub>↪️ <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">PapiOphidian</a> used <code>/stats</code></sub></blockquote>'
formatted_body: '<blockquote><sub> <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">PapiOphidian</a> used <code>/stats</code></sub></blockquote>'
+ '<blockquote><p><strong>Amanda 🎵#2192 <img data-mx-emoticon height=\"32\" src=\"mxc://cadence.moe/LCEqjStXCxvRQccEkuslXEyZ\" title=\":online:\" alt=\":online:\">'
+ '<br>willow tree, branch 0</strong>'
+ '<br><strong> Uptime:</strong><br>3m 55s'
@ -153,10 +153,10 @@ test("message2event embeds: title without url", async t => {
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.notice",
body: "↪️ PapiOphidian used `/stats`"
body: " PapiOphidian used `/stats`"
+ "\n| ## Hi, I'm Amanda!\n| \n| I condone pirating music!",
format: "org.matrix.custom.html",
formatted_body: '<blockquote><sub>↪️ <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">PapiOphidian</a> used <code>/stats</code></sub></blockquote>'
formatted_body: '<blockquote><sub> <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">PapiOphidian</a> used <code>/stats</code></sub></blockquote>'
+ `<blockquote><p><strong>Hi, I'm Amanda!</strong></p><p>I condone pirating music!</p></blockquote>`,
"m.mentions": {}
}])
@ -167,10 +167,10 @@ test("message2event embeds: url without title", async t => {
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.notice",
body: "↪️ PapiOphidian used `/stats`"
body: " PapiOphidian used `/stats`"
+ "\n| I condone pirating music!",
format: "org.matrix.custom.html",
formatted_body: '<blockquote><sub>↪️ <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">PapiOphidian</a> used <code>/stats</code></sub></blockquote>'
formatted_body: '<blockquote><sub> <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">PapiOphidian</a> used <code>/stats</code></sub></blockquote>'
+ `<blockquote><p>I condone pirating music!</p></blockquote>`,
"m.mentions": {}
}])
@ -181,10 +181,10 @@ test("message2event embeds: author without url", async t => {
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.notice",
body: "↪️ PapiOphidian used `/stats`"
body: " PapiOphidian used `/stats`"
+ "\n| ## Amanda\n| \n| I condone pirating music!",
format: "org.matrix.custom.html",
formatted_body: '<blockquote><sub>↪️ <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">PapiOphidian</a> used <code>/stats</code></sub></blockquote>'
formatted_body: '<blockquote><sub> <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">PapiOphidian</a> used <code>/stats</code></sub></blockquote>'
+ `<blockquote><p><strong>Amanda</strong></p><p>I condone pirating music!</p></blockquote>`,
"m.mentions": {}
}])
@ -195,10 +195,10 @@ test("message2event embeds: author url without name", async t => {
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.notice",
body: "↪️ PapiOphidian used `/stats`"
body: " PapiOphidian used `/stats`"
+ "\n| I condone pirating music!",
format: "org.matrix.custom.html",
formatted_body: '<blockquote><sub>↪️ <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">PapiOphidian</a> used <code>/stats</code></sub></blockquote>'
formatted_body: '<blockquote><sub> <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">PapiOphidian</a> used <code>/stats</code></sub></blockquote>'
+ `<blockquote><p>I condone pirating music!</p></blockquote>`,
"m.mentions": {}
}])
@ -209,9 +209,9 @@ test("message2event embeds: 4 images", async t => {
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.text",
body: "[🔀 Forwarded message]\n» https://fixupx.com/i/status/2032003668787020046",
body: "[ Forwarded message]\n» https://fixupx.com/i/status/2032003668787020046",
format: "org.matrix.custom.html",
formatted_body: "🔀 <em>Forwarded message</em><br><blockquote><a href=\"https://fixupx.com/i/status/2032003668787020046\">https://fixupx.com/i/status/2032003668787020046</a></blockquote>",
formatted_body: " <em>Forwarded message</em><br><blockquote><a href=\"https://fixupx.com/i/status/2032003668787020046\">https://fixupx.com/i/status/2032003668787020046</a></blockquote>",
"m.mentions": {}
}, {
$type: "m.room.message",

View file

@ -1273,9 +1273,9 @@ test("message2event: crossposted announcements say where they are crossposted fr
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.text",
body: "[🔀 Chewey Bot Official Server #announcements]\nAll text based commands are now inactive on Chewey Bot\nTo continue using commands you'll need to use them as slash commands",
body: "[ Chewey Bot Official Server #announcements]\nAll text based commands are now inactive on Chewey Bot\nTo continue using commands you'll need to use them as slash commands",
format: "org.matrix.custom.html",
formatted_body: "🔀 <strong>Chewey Bot Official Server #announcements</strong><br>All text based commands are now inactive on Chewey Bot<br>To continue using commands you'll need to use them as slash commands"
formatted_body: " <strong>Chewey Bot Official Server #announcements</strong><br>All text based commands are now inactive on Chewey Bot<br>To continue using commands you'll need to use them as slash commands"
}])
})
@ -1344,9 +1344,9 @@ test("message2event: forwarded image", async t => {
t.deepEqual(events, [
{
$type: "m.room.message",
body: "[🔀 Forwarded message]",
body: "[ Forwarded message]",
format: "org.matrix.custom.html",
formatted_body: "🔀 <em>Forwarded message</em>",
formatted_body: " <em>Forwarded message</em>",
"m.mentions": {},
msgtype: "m.notice",
},
@ -1385,10 +1385,10 @@ test("message2event: constructed forwarded message", async t => {
t.deepEqual(events, [
{
$type: "m.room.message",
body: "[🔀 Forwarded from #wonderland]"
body: "[ Forwarded from #wonderland]"
+ "\n» What's cooking, good looking? :hipposcope:",
format: "org.matrix.custom.html",
formatted_body: `🔀 <em>Forwarded from wonderland <a href="https://matrix.to/#/!qzDBLKlildpzrrOnFZ:cadence.moe/$tBIT8mO7XTTCgIINyiAIy6M2MSoPAdJenRl_RLyYuaE?via=cadence.moe&amp;via=matrix.org">[jump to event]</a></em>`
formatted_body: ` <em>Forwarded from wonderland <a href="https://matrix.to/#/!qzDBLKlildpzrrOnFZ:cadence.moe/$tBIT8mO7XTTCgIINyiAIy6M2MSoPAdJenRl_RLyYuaE?via=cadence.moe&amp;via=matrix.org">[jump to event]</a></em>`
+ `<br><blockquote>What's cooking, good looking? <img data-mx-emoticon height="32" src="mxc://cadence.moe/WbYqNlACRuicynBfdnPYtmvc" title=":hipposcope:" alt=":hipposcope:"></blockquote>`,
"m.mentions": {},
msgtype: "m.text",
@ -1444,10 +1444,10 @@ test("message2event: constructed forwarded text", async t => {
t.deepEqual(events, [
{
$type: "m.room.message",
body: "[🔀 Forwarded from #amanda-spam]"
body: "[ Forwarded from #amanda-spam]"
+ "\n» What's cooking, good looking?",
format: "org.matrix.custom.html",
formatted_body: `🔀 <em>Forwarded from amanda-spam <a href="https://matrix.to/#/!CzvdIdUQXgUjDVKxeU:cadence.moe?via=cadence.moe&amp;via=matrix.org">[jump to room]</a></em>`
formatted_body: ` <em>Forwarded from amanda-spam <a href="https://matrix.to/#/!CzvdIdUQXgUjDVKxeU:cadence.moe?via=cadence.moe&amp;via=matrix.org">[jump to room]</a></em>`
+ `<br><blockquote>What's cooking, good looking?</blockquote>`,
"m.mentions": {},
msgtype: "m.text",
@ -1467,10 +1467,10 @@ test("message2event: don't scan forwarded messages for mentions", async t => {
t.deepEqual(events, [
{
$type: "m.room.message",
body: "[🔀 Forwarded message]"
body: "[ Forwarded message]"
+ "\n» If some folks have spare bandwidth then helping out ArchiveTeam with archiving soon to be deleted research and government data might be worthwhile https://social.luca.run/@luca/113950834185678114",
format: "org.matrix.custom.html",
formatted_body: `🔀 <em>Forwarded message</em>`
formatted_body: ` <em>Forwarded message</em>`
+ `<br><blockquote>If some folks have spare bandwidth then helping out ArchiveTeam with archiving soon to be deleted research and government data might be worthwhile <a href="https://social.luca.run/@luca/113950834185678114">https://social.luca.run/@luca/113950834185678114</a></blockquote>`,
"m.mentions": {},
msgtype: "m.text"
@ -1820,9 +1820,9 @@ test("message2event: forwarded message with unreferenced mention", async t => {
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.text",
body: "[🔀 Forwarded message]\n» @unknown-user:\n» 🎞️ Uploaded file: https://bridge.example.org/download/discordcdn/893634327722721290/1463174815119704114/2022-10-18_16-49-46.mp4 (51 MB)",
body: "[ Forwarded message]\n» @unknown-user:\n» 🎞️ Uploaded file: https://bridge.example.org/download/discordcdn/893634327722721290/1463174815119704114/2022-10-18_16-49-46.mp4 (51 MB)",
format: "org.matrix.custom.html",
formatted_body: "🔀 <em>Forwarded message</em><br><blockquote>@unknown-user:<br>🎞️ Uploaded file: <a href=\"https://bridge.example.org/download/discordcdn/893634327722721290/1463174815119704114/2022-10-18_16-49-46.mp4\">2022-10-18_16-49-46.mp4</a> (51 MB)</blockquote>",
formatted_body: " <em>Forwarded message</em><br><blockquote>@unknown-user:<br>🎞️ Uploaded file: <a href=\"https://bridge.example.org/download/discordcdn/893634327722721290/1463174815119704114/2022-10-18_16-49-46.mp4\">2022-10-18_16-49-46.mp4</a> (51 MB)</blockquote>",
"m.mentions": {}
}])
})

View file

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

View file

@ -1,7 +1,9 @@
// @ts-check
const assert = require("assert").strict
const passthrough = require("../../passthrough")
const {sync, select} = passthrough
const {discord, sync, db, select} = passthrough
/** @type {import("../../matrix/utils")} */
const mxUtils = sync.require("../../matrix/utils")
const {reg} = require("../../matrix/read-registration.js")
@ -17,32 +19,24 @@ const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex))
*/
async function threadToAnnouncement(parentRoomID, threadRoomID, creatorMxid, thread, di) {
const branchedFromEventID = select("event_message", "event_id", {message_id: thread.id}).pluck().get()
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.in_reply_to"?: any}} */
const context = {}
let suffix = ""
if (branchedFromEventID) {
// Need to figure out who sent that event...
const event = await di.api.getEvent(parentRoomID, branchedFromEventID)
suffix = "\n[Note: You really should move the conversation to that room, rather than continuing to reply via a Matrix thread. Any messages sent in threads will be DELETED and instead moved to that room by the bot, which makes it the author and strips you from control (edits/deletions) over your own message!]"
context["m.relates_to"] = {"m.in_reply_to": {event_id: event.event_id}}
if (event.sender && !userRegex.some(rx => event.sender.match(rx))) context["m.mentions"] = {user_ids: [event.sender]}
if (!ellieMode) {
//...And actually branch from that event (if configured to do so)
suffix = "\n[Note: You really should continue the conversation in that room, rather than in this Matrix thread. Any messages sent here will be DELETED and instead moved there by the bot, which makes it the author and strips you from control (edits/deletions) over your own message!]"
context["m.relates_to"] = {"m.in_reply_to": {event_id: event.event_id}, is_falling_back:false, event_id: event.event_id, rel_type: "m.thread"}
}
}
const msgtype = creatorMxid ? "m.emote" : "m.text"
const template = creatorMxid ? "started a thread called" : "New thread started:"
const template = creatorMxid ? "started a thread:" : "Thread started:"
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} https://matrix.to/#/${threadRoomID}?${via.toString()}`
return {
msgtype,
body,
"m.mentions": {},
...context
}
}

View file

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

View file

@ -2,6 +2,7 @@
const assert = require("assert").strict
const DiscordTypes = require("discord-api-types/v10")
const {id: botID} = require("../../addbot")
const {sync, db, select, from} = require("../passthrough")
/** @type {import("./actions/send-message")}) */
@ -38,10 +39,10 @@ const removeMember = sync.require("./actions/remove-member")
const vote = sync.require("./actions/poll-vote")
/** @type {import("../m2d/event-dispatcher")} */
const matrixEventDispatcher = sync.require("../m2d/event-dispatcher")
/** @type {import("../m2d/actions/redact.js")} */
const redact = sync.require("../m2d/actions/redact.js")
/** @type {import("../discord/interactions/matrix-info")} */
const matrixInfoInteraction = sync.require("../discord/interactions/matrix-info")
/** @type {import("../agi/listener")} */
const agiListener = sync.require("../agi/listener")
const {Semaphore} = require("@chriscdn/promise-semaphore")
const checkMissedPinsSema = new Semaphore()
@ -104,7 +105,7 @@ module.exports = {
// console.log(`[check missed messages] in ${channel.id} (${guild.name} / ${channel.name}) because its last message ${channel.last_message_id} is not in the database`)
let messages
try {
messages = await client.snow.channel.getChannelMessages(channel.id, {limit: 50})
messages = await client.snow.channel.getChannelMessages(channel.id, {limit: 100})
} catch (e) {
if (e.message === `{"message": "Missing Access", "code": 50001}`) { // pathetic error handling from SnowTransfer
console.log(`[check missed messages] no permissions to look back in channel ${channel.name} (${channel.id})`)
@ -212,7 +213,7 @@ module.exports = {
const channelID = thread.parent_id || undefined
const parentRoomID = select("channel_room", "room_id", {channel_id: channelID}).pluck().get()
if (!parentRoomID) return // Not interested in a thread if we aren't interested in its wider channel (won't autocreate)
const threadRoomID = await createRoom.ensureRoom(thread.id)
const threadRoomID = await createRoom.syncRoom(thread.id) // Create room (will share the same inflight as the initial message to the thread)
await announceThread.announceThread(parentRoomID, threadRoomID, thread)
},
@ -305,23 +306,20 @@ module.exports = {
if (message.webhook_id) {
const row = select("webhook", "webhook_id", {webhook_id: message.webhook_id}).pluck().get()
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 (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 (dUtils.isEphemeralMessage(message)) return // Ephemeral messages are for the eyes of the receiver only!
if (!createRoom.existsOrAutocreatable(channel, guild.id)) return // Check that the sending-to room exists or is autocreatable
const {affected, row} = await speedbump.maybeDoSpeedbump(message.channel_id, message.id)
const {affected, row} = await speedbump.maybeDoSpeedbump(message.channel_id, message.id, message.author.id)
if (affected) return
// @ts-ignore
await sendMessage.sendMessage(message, channel, guild, row)
retrigger.messageFinishedBridging(message.id)
retrigger.finishedBridging(message.id)
},
/**
@ -337,12 +335,12 @@ module.exports = {
if (dUtils.isEphemeralMessage(data)) return // Ephemeral messages are for the eyes of the receiver only!
// Edits need to go through the speedbump as well. If the message is delayed but the edit isn't, we don't have anything to edit from.
const {affected, row} = await speedbump.maybeDoSpeedbump(data.channel_id, data.id)
const {affected, row} = await speedbump.maybeDoSpeedbump(data.channel_id, data.id, data.author.id)
if (affected) return
if (!row) {
// Check that the sending-to room exists, and deal with Eventual Consistency(TM)
if (retrigger.eventNotFoundThenRetrigger(data.id, module.exports.MESSAGE_UPDATE, client, data)) return
if (!await retrigger.waitForMessage(data.id)) return
}
/** @type {DiscordTypes.GatewayMessageCreateDispatchData} */
@ -380,6 +378,16 @@ module.exports = {
* @param {DiscordTypes.GatewayMessageReactionRemoveDispatchData | DiscordTypes.GatewayMessageReactionRemoveEmojiDispatchData | DiscordTypes.GatewayMessageReactionRemoveAllDispatchData} data
*/
async onSomeReactionsRemoved(client, data) {
// Don't attempt to double-bridge our own m2d deleted reactions back to Matrix
if ("user_id" in data && data.user_id === botID) {
const emojiIdOrName = data.emoji.id || data.emoji.name
const i = redact.m2dDeletedReactions.findIndex(x => data.message_id === x.messageID && emojiIdOrName === x.emojiIdOrName)
if (i !== -1) {
redact.m2dDeletedReactions.splice(i, 1)
return
}
}
await removeReaction.removeSomeReactions(data)
},
@ -389,7 +397,7 @@ module.exports = {
*/
async MESSAGE_DELETE(client, data) {
speedbump.onMessageDelete(data.id)
if (retrigger.eventNotFoundThenRetrigger(data.id, module.exports.MESSAGE_DELETE, client, data)) return
if (!await retrigger.waitForMessage(data.id)) return
await deleteMessage.deleteMessage(data)
},
@ -437,12 +445,12 @@ module.exports = {
* @param {DiscordTypes.GatewayMessagePollVoteDispatchData} data
*/
async MESSAGE_POLL_VOTE_ADD(client, data) {
if (retrigger.eventNotFoundThenRetrigger(data.message_id, module.exports.MESSAGE_POLL_VOTE_ADD, client, data)) return
if (!await retrigger.waitForMessage(data.message_id)) return
await vote.addVote(data)
},
async MESSAGE_POLL_VOTE_REMOVE(client, data) {
if (retrigger.eventNotFoundThenRetrigger(data.message_id, module.exports.MESSAGE_POLL_VOTE_REMOVE, client, data)) return
if (!await retrigger.waitForMessage(data.message_id)) return
await vote.removeVote(data)
},

View file

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

View file

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

View file

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

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

@ -1,23 +1,4 @@
export type Models = {
agi_prior_message: {
channel_id: string
username: string
avatar_url: string
use_caps: number
use_punct: number
use_apos: number
timestamp: number
}
agi_optout: {
guild_id: string
}
agi_cooldown: {
guild_id: string
timestamp: number
}
app_user_install: {
guild_id: string
app_bot_id: string

View file

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

View file

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

View file

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

View file

@ -91,15 +91,6 @@ function registerInteractions() {
async function dispatchInteraction(interaction) {
const interactionId = interaction.data?.["custom_id"] || interaction.data?.["name"]
try {
if (interaction.type === DiscordTypes.InteractionType.MessageComponent || interaction.type === DiscordTypes.InteractionType.ModalSubmit) {
// All we get is custom_id, don't know which context the button was clicked in.
// So we namespace these ourselves in the custom_id. Currently the only existing namespace is POLL_.
if (interaction.data.custom_id.startsWith("POLL_")) {
await poll.interact(interaction)
} else {
throw new Error(`Unknown message component ${interaction.data.custom_id}`)
}
} else {
if (interactionId === "Matrix info") {
await matrixInfo.interact(interaction)
} else if (interactionId === "invite") {
@ -122,10 +113,11 @@ async function dispatchInteraction(interaction) {
await ping.interact(interaction)
} else if (interactionId === "privacy") {
await privacy.interact(interaction)
} else if (interactionId.startsWith("POLL_")) {
await poll.interact(interaction)
} else {
throw new Error(`Unknown interaction ${interactionId}`)
}
}
} catch (e) {
let stackLines = null
if (e.stack) {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -29,6 +29,8 @@ const pollComponents = sync.require("./poll-components")
const setupEmojis = sync.require("../actions/setup-emojis")
/** @type {import("../../d2m/converters/user-to-mxid")} */
const userToMxid = sync.require("../../d2m/converters/user-to-mxid")
/** @type {import("../../web/routes/letter-avatar")} */
const letterAvatar = sync.require("../../web/routes/letter-avatar")
/** @type {[RegExp, string][]} */
const markdownEscapes = [
@ -471,7 +473,6 @@ async function checkWrittenMentions(content, senderMxid, roomID, guild, di) {
// @ts-ignore - typescript doesn't know about indices yet
content: content.slice(0, writtenMentionMatch.indices[1][0]-1) + `@everyone` + content.slice(writtenMentionMatch.indices[1][1]),
ensureJoined: [],
/**@type {DiscordTypes.AllowedMentionsTypes[]}*/ // @ts-ignore - TypeScript is for whatever reason conviced that "everyone" cannot be assigned to AllowedMentionsTypes, but if you „Go to Definition”, you'll see that "everyone" is a valid enum value.
allowedMentionsParse: ["everyone"],
allowedMentionsUsers: []
}
@ -546,7 +547,6 @@ async function getL1L2ReplyLine(called = false) {
async function eventToMessage(event, guild, channel, di) {
let displayName = event.sender
let avatarURL = undefined
/**@type {DiscordTypes.AllowedMentionsTypes[]}*/ // @ts-ignore - TypeScript is for whatever reason conviced that neither "users" no "roles" cannot be assigned to AllowedMentionsTypes, but if you „Go to Definition”, you'll see that both are valid enum values.
const allowedMentionsParse = ["users", "roles"]
const allowedMentionsUsers = []
/** @type {string[]} */
@ -584,6 +584,13 @@ async function eventToMessage(event, guild, channel, di) {
displayNameRunoff = ""
}
// Avatar post-processing. Use a thumbnail for media, or generate letter avatar if none present.
if (avatarURL) {
avatarURL = avatarURL + "?preset=avatar"
} else {
avatarURL = letterAvatar.getLetterAvatarURL(event.sender, displayNameShortened)
}
let content = event.content["body"] || "" // ultimate fallback
/** @type {{id: string, filename: string}[]} */
const attachments = []
@ -823,7 +830,7 @@ async function eventToMessage(event, guild, channel, di) {
}
// Handling mentions of Discord users
input = input.replace(/("https:\/\/matrix.to\/#\/((?:@|%40)[^"]+)")>/g, (whole, attributeValue, mxid) => {
input = input.replace(/("https:\/\/matrix.to\/#\/((?:@|%40)[^"]+)")/g, (whole, attributeValue, mxid) => {
mxid = decodeURIComponent(mxid)
if (mxUtils.eventSenderIsFromDiscord(mxid)) {
// Handle mention of an OOYE sim user by their mxid
@ -894,9 +901,12 @@ async function eventToMessage(event, guild, channel, di) {
}
// Check for incompatible backticks in code blocks
let preNode
if (node.nodeType === 3 && node.nodeValue.includes("```") && (preNode = nodeIsChildOf(node, ["PRE"]))) {
let isBackticksTextInPre = node.nodeType === 3 && node.nodeValue.includes("```") && (preNode = nodeIsChildOf(node, ["PRE"]))
let isLongPre = node.tagName === "PRE" && node.textContent.length > 1800 && (preNode = node)
if (isBackticksTextInPre || isLongPre) {
if (preNode.firstChild?.nodeName === "CODE") {
const ext = preNode.firstChild.className.match(/language-(\S+)/)?.[1] || "txt"
let ext = preNode.firstChild.className.match(/language-(\S+)/)?.[1]
if (!dUtils.supportedPlaintextPreviewExtensions.has(ext)) ext = "txt"
const filename = `inline_code.${ext}`
// Build the replacement <code> node
const replacementCode = doc.createElement("code")

File diff suppressed because it is too large Load diff

View file

@ -1,106 +0,0 @@
//@ts-check
/*
* Misc. utils for transforming various Matrix events (eg. those sent in Forum-bridged channels; those sent) so that they're usable as threads, and for creating said threads.
*/
const Ty = require("../../types")
const {discord, sync, select, from} = require("../../passthrough")
const DiscordTypes = require("discord-api-types/v10")
/** @type {import("../../matrix/api")}) */
const api = sync.require("../../matrix/api")
/** @type {import("../../d2m/actions/create-room")} */
const createRoom = sync.require("../../d2m/actions/create-room")
/**
* @param {Ty.Event.Outer_M_Room_Message | Ty.Event.Outer_M_Room_Message_File} event Used for determining the branching-point and the title; any relation data will be stripped and its room_id will mutate to the target thread-room, if one gets created.
* @returns {Promise<boolean>} whether a thread-room was created
*/
async function bridgeThread(event) {
/** @type {string} */ // @ts-ignore
const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get()
const channel = discord.channels.get(channelID)
const guildID = channel?.["guild_id"]
if (!guildID) return false; //Room not bridged? We don't care. It's a Matrix-native room, let Matrix users have standard Matrix-native threads there.
const threadEventID = event.content["m.relates_to"]?.event_id
if (!threadEventID) throw new Error("There was an event sent inside SOME Matrix thread, but it lacked any information as to what thread it actually was!"); //An „ugly error” is justified because if something like this DOES happen, then that means that it should be reported to us, as there is some broken client out there that we should account for.
const messageID = select("event_message", "message_id", {event_id: threadEventID}).pluck().get()
if (!messageID) return false; //Message not bridged? Too bad! Discord users will just see normal replies, and Matrix uses won't get a thread-room. We COULD technically create a "headless" thread on Discord side and bridge it to a new thread-room, but that comes with a whole host of complications on its own (notably: what do we do if the message gets bridged later (by reaction emoji), and then hypothetically gets its own thread; and: getThreadRoomFromThreadEvent will have to be much more complex than a simple DB call (probably a whole new DB table would have to be created, just to hold these Matrix-branched-but-headless-on-Discord threads) because the simple „MX event --(db)--> Discord Message --(Discord spec)--> Discord thread” relation would no longer hold true), which may not be worth it, as an unbridged message in a bridged channel is already an edge-case (so it seems pointless to introduce a whole bunch of edgier-cases that handling this edge-case "properly" would bring).
try {
event.room_id = await createRoom.ensureRoom((await discord.snow.channel.createThreadWithMessage(channelID, messageID, {name: computeName(event, await api.getEvent(event.room_id, threadEventID)).name})).id)
return true;
}
catch (e){
if (e.message?.includes("50024")){ //see: https://docs.discord.com/developers/topics/opcodes-and-status-codes
api.sendEvent(event.room_id, "m.room.message", {
body: "Hey, please don't do that! This room is already a thread on Discord (or it could also be a voice-chat or something adjacent) - trying to embed threads inside it, like you just did, will not work. DC users will just see a regular reply, which is distracting and also probably not what you want.",
"m.mentions": { "user_ids": [event.sender]},
"m.relates_to": {
event_id: threadEventID,
is_falling_back: false,
"m.in_reply_to": { event_id: event.event_id },
rel_type: "m.thread"
},
msgtype: "m.text"
})
return false;
}
else throw e
}
}
/**
* @param {Ty.Event.Outer_M_Room_Message | Ty.Event.Outer_M_Room_Message_File} event
* @returns {Promise<boolean>} whether a forum-thread-room was created
*/
async function handleForums(event) {
if (event.content.body === "/thread") return false; //Let the help be shown normally
const row = from("channel_room").where({room_id: event.room_id}).select("channel_id").get()
/** @type {string}*/ //@ts-ignore the possibility that it's undefined - get() will return back an undefined if it's fed one (so that's undefined-safe), and createThreadWithoutMessage() won't be reached because "undefined" is neither DiscordTypes.ChannelType.GuildMedia nor DiscordTypes.ChannelType.GuildForum, so the guard clause kicks in.
let channelID = row?.channel_id
const channel = discord.channels.get(channelID)
if (channel?.type != DiscordTypes.ChannelType.GuildForum && channel?.type != DiscordTypes.ChannelType.GuildMedia) return false
const name = computeName(event)
let resetNeeded = false
try {
if(channel.flags && channel.flags & DiscordTypes.ChannelFlags.RequireTag){
await discord.snow.channel.updateChannel(channelID, {flags:(channel.flags^DiscordTypes.ChannelFlags.RequireTag)}, "Temporary override of tagging requirements because Matrix threads that can't be tagged yet.")
resetNeeded = true
}
//@ts-ignore the presence of message:{} and the absence of type:11 - that's intended for threads in Forum channels (*and no, createThreadWithMessage wouldn't work here, either - that one takes an ALREADY EXISTING message ID, whereas this needs a new message)
await discord.snow.channel.createThreadWithoutMessage(channelID, {name: name.name, message:{content:"**Created by: `"+ event.sender +"`**"}})
if (!name.truncated) api.redactEvent(event.room_id, event.event_id) //Don't destroy people's texts - only remove if no truncation is guaranteed.
if (resetNeeded) discord.snow.channel.updateChannel(channelID, {flags:channel.flags}, "Restoring flags to their original state.")
}
catch (e){
if (e.message?.includes("50013")){
api.sendEvent(event.room_id, "m.room.message", {
body: "You can't create threads in this forum right now! This forum is configured to require tags on post (Matrix users can't yet use tags yet), and OOYE doesn't have the permission to edit this channel on Discord (needed to bypass the requirement of tags). Unless this is intentional (see room description - the admins may have left a note), please ask someone on the Discord side to either grant OOYE the necessary permissions, or to remove tagging requirements.",
msgtype: "m.text"
})
}
else throw e
}
return true
}
/**
* @param {Ty.Event.Outer_M_Room_Message | Ty.Event.Outer_M_Room_Message_File} from event
* @param {Ty.Event.Outer<any> | null | false | undefined} fallback Reuses the "from" param value if empty.
* @returns {{name: string, truncated: boolean}}
*/
function computeName(from, fallback=null){
let name = from.content.body
if (name.startsWith("/thread ") && name.length > 8) name = name.substring(8);
else name = (fallback ? fallback : from).content.body;
return name.length < 100 ? {name: name.replaceAll("\n", " "), truncated: false} : {name: name.slice(0, 96).replaceAll("\n", " ") + "...", truncated: true}
}
module.exports.handleForums = handleForums
module.exports.bridgeThread = bridgeThread

View file

@ -9,7 +9,6 @@ const Ty = require("../types")
const {discord, db, sync, as, select} = require("../passthrough")
const {tag} = require("@cloudrac3r/html-template-tag")
const {Semaphore} = require("@chriscdn/promise-semaphore")
const { bridgeThread, handleForums } = require("./converters/threads-and-forums")
/** @type {import("./actions/send-event")} */
const sendEvent = sync.require("./actions/send-event")
@ -95,6 +94,11 @@ function printError(type, source, e, payload) {
console.dir(payload, {depth: null})
}
/** @param {string} stack */
function cleanErrorStack(stack) {
return stack.replace(/(\/webhooks\/[0-9]+\/)[a-zA-Z0-9_-]+/g, "$1(redacted)")
}
/**
* @param {string} roomID
* @param {"Discord" | "Matrix"} source
@ -135,7 +139,7 @@ async function sendError(roomID, source, type, e, payload) {
builder.addLine(errorIntroLine)
// Where
const stack = stringifyErrorStack(e)
const stack = cleanErrorStack(stringifyErrorStack(e))
builder.addLine(`Error trace:\n${stack}`, tag`<details><summary>Error trace</summary><pre>${stack}</pre></details>`)
// How
@ -144,7 +148,7 @@ async function sendError(roomID, source, type, e, payload) {
// Send
try {
await api.sendEvent(roomID, "m.room.message", {
const errorEventID = await api.sendEvent(roomID, "m.room.message", {
...builder.get(),
"moe.cadence.ooye.error": {
source: source.toLowerCase(),
@ -154,18 +158,17 @@ async function sendError(roomID, source, type, e, payload) {
user_ids: ["@cadence:cadence.moe"]
}
})
// Add reaction indicating that errors may be retried
await api.sendEvent(roomID, "m.reaction", {
"m.relates_to": {
rel_type: "m.annotation",
event_id: errorEventID,
key: "🔁"
}
})
} catch (e) {}
}
/**
* Wraps the function with an automated error catching and reporting mechanism
* @template {Ty.Event.Outer<any>} EVENT The event that the wrapped function processes, its first argument.
* @template {[]} ARGS Other arguments of the wrapped function
* @template RETURNS The output of the wrapped function
* @param {string} type Type of the event, during the processing of which the error may occur.
* @param {(event: EVENT, ...args: ARGS)=>RETURNS|Promise<RETURNS>} fn Function to wrap
* @returns {(event: EVENT, ...args: ARGS)=>Promise<RETURNS|undefined>} Wrapped function
*/
function guard(type, fn) {
return async function(event, ...args) {
try {
@ -182,6 +185,7 @@ const errorRetrySema = new Semaphore()
* @param {Ty.Event.Outer<Ty.Event.M_Reaction>} reactionEvent
*/
async function onRetryReactionAdd(reactionEvent) {
if (reactionEvent.sender === `@${reg.sender_localpart}:${reg.ooye.server_name}`) return // Don't respond to the bot's own indicative reaction
const roomID = reactionEvent.room_id
await errorRetrySema.request(async () => {
const event = await api.getEvent(roomID, reactionEvent.content["m.relates_to"]?.event_id)
@ -215,36 +219,13 @@ sync.addTemporaryListener(as, "type:m.room.message", guard("m.room.message",
*/
async event => {
if (utils.eventSenderIsFromDiscord(event.sender)) return
if (await handleForums(event)) return
let processCommands = true
if (event.content["m.relates_to"]?.rel_type === "m.thread") {
/**@type {string|null} */
let toRedact = event.room_id
const bridgedTo = utils.getThreadRoomFromThreadEvent(event.content["m.relates_to"].event_id)
processCommands = false
if (bridgedTo) event.room_id = bridgedTo;
else if (!await bridgeThread(event)) toRedact = null; //Don't remove anything, if there is nowhere to relocate it to.
if (toRedact) {
api.redactEvent(toRedact, event.event_id)
event.content["m.relates_to"] = undefined
api.sendEvent(event.room_id, event.type, {...event.content, body: event.content.body+"\n ~ "+event.sender, formatted_body: event.content.formatted_body ? event.content.formatted_body+"<br> ~ "+event.sender :undefined })
}
}
const messageResponses = await sendEvent.sendEvent(event)
if (!messageResponses.length) return
if (event.type === "m.room.message" && event.content.msgtype === "m.text" && processCommands) {
await matrixCommandHandler.parseAndExecute(
// @ts-ignore - TypeScript doesn't know that the event.content.msgtype === "m.text" check ensures that event isn't of type Ty.Event.Outer_M_Room_Message_File (which, indeed, wouldn't fit here)
event
)
if (event.type === "m.room.message" && event.content.msgtype === "m.text") {
// @ts-ignore
await matrixCommandHandler.execute(event)
}
retrigger.messageFinishedBridging(event.event_id)
retrigger.finishedBridging(event.event_id)
await api.ackEvent(event)
}))
@ -255,7 +236,7 @@ sync.addTemporaryListener(as, "type:m.sticker", guard("m.sticker",
async event => {
if (utils.eventSenderIsFromDiscord(event.sender)) return
const messageResponses = await sendEvent.sendEvent(event)
retrigger.messageFinishedBridging(event.event_id)
retrigger.finishedBridging(event.event_id)
await api.ackEvent(event)
}))
@ -535,5 +516,6 @@ async event => {
}))
module.exports.stringifyErrorStack = stringifyErrorStack
module.exports.cleanErrorStack = cleanErrorStack
module.exports.sendError = sendError
module.exports.printError = printError

View file

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

View file

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

View file

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

View file

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

View file

@ -4,7 +4,7 @@ const assert = require("assert").strict
const Ty = require("../types")
const {tag} = require("@cloudrac3r/html-template-tag")
const passthrough = require("../passthrough")
const {db, select} = passthrough
const {db} = passthrough
const {reg} = require("./read-registration")
const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex))
@ -385,16 +385,6 @@ async function setUserPowerCascade(spaceID, mxid, power, api) {
}
}
/**
* @param {undefined|string?} eventID
*/ //^For some reason, ? doesn't include Undefined and it needs to be explicitly specified
function getThreadRoomFromThreadEvent(eventID){
if (!eventID) return eventID;
const threadID = select("event_message", "message_id", {event_id: eventID}).pluck().get() //Discord thread ID === its message ID
if (!threadID) return threadID;
return select("channel_room", "room_id", {channel_id: threadID}).pluck().get()
}
module.exports.bot = bot
module.exports.BLOCK_ELEMENTS = BLOCK_ELEMENTS
module.exports.eventSenderIsFromDiscord = eventSenderIsFromDiscord
@ -410,4 +400,3 @@ module.exports.removeCreatorsFromPowerLevels = removeCreatorsFromPowerLevels
module.exports.getEffectivePower = getEffectivePower
module.exports.setUserPower = setUserPower
module.exports.setUserPowerCascade = setUserPowerCascade
module.exports.getThreadRoomFromThreadEvent = getThreadRoomFromThreadEvent

View file

@ -2,7 +2,7 @@
const {select} = require("../passthrough")
const {test} = require("supertape")
const {eventSenderIsFromDiscord, getEventIDHash, MatrixStringBuilder, getViaServers, roomHasAtLeastVersion, removeCreatorsFromPowerLevels, setUserPower, getThreadRoomFromThreadEvent} = require("./utils")
const {eventSenderIsFromDiscord, getEventIDHash, MatrixStringBuilder, getViaServers, roomHasAtLeastVersion, removeCreatorsFromPowerLevels, setUserPower} = require("./utils")
const util = require("util")
/** @param {string[]} mxids */
@ -417,23 +417,4 @@ test("set user power: privileged users must demote themselves", async t => {
t.equal(called, 3)
})
test("getThreadRoomFromThreadEvent: real message with a thread", t => {
const room = getThreadRoomFromThreadEvent("$fdD9o7NxMA4VPexlAiIx2CB9JbsiGhJeyJgnZG7U5xg")
t.equal(room, "!FuDZhlOAtqswlyxzeR:cadence.moe")
})
test("getThreadRoomFromThreadEvent: real message, but without a thread", t => {
const room = getThreadRoomFromThreadEvent("$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4")
const msg = "Expected null/undefined, got: "+room
if(room) t.fail(msg);
else t.pass(msg)
})
test("getThreadRoomFromThreadEvent: fake message", t => {
const room = getThreadRoomFromThreadEvent("$ThisEvent-IdDoesNotExistInTheDatabase4Sure")
const msg = "Expected null/undefined, got: "+room
if(room) t.fail(msg);
else t.pass(msg)
})
module.exports.mockGetEffectivePower = mockGetEffectivePower

View file

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

23
src/types.d.ts vendored
View file

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

View file

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

View file

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

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

View file

@ -122,7 +122,7 @@ block body
#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.
p.fc-light.mb0.mt8 Matrix users will start with these roles. If your main channels are gated by a role, use this to let Matrix users skip the gate.
h3.mt32.fs-category Features
.s-card.d-grid.px0.g16
@ -191,14 +191,14 @@ block body
label.s-btn.s-btn__muted.ta-left.truncate(for=channel.id)
+discord(channel, true, "Announcement")
else
.s-empty-state.p8 All Discord channels are linked.
.s-empty-state.p8 No Discord channels available.
.fl-grow1.s-btn-group.fd-column.w30
each room in unlinkedRooms
input.s-btn--radio(type="radio" name="matrix" required id=room.room_id value=room.room_id)
label.s-btn.s-btn__muted.ta-left.truncate(for=room.room_id)
+matrix(room, true)
else
.s-empty-state.p8 All Matrix rooms are linked.
.s-empty-state.p8 No Matrix rooms available.
input(type="hidden" name="guild_id" value=guild_id)
div
button.s-btn.s-btn__icon.s-btn__filled#link-button
@ -237,10 +237,7 @@ block body
.s-card.p0
ul.my8.ml24
each row in removedWrongTypeChannels
li
a(href=`https://discord.com/channels/${guild_id}/${row.id}`) (#{row.type}) #{row.name}
span |
a(href=rel(`/explain?type=${row.type}`)) Why?
li: a(href=`https://discord.com/channels/${guild_id}/${row.id}`) (#{row.type}) #{row.name}
h3.mt24 Unavailable channels: Discord bot can't access
.s-card.p0
ul.my8.ml24
@ -257,13 +254,12 @@ block body
ul.my8.ml24
each row in removedEncryptedRooms
li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name
h3.mt24 Unavailable rooms: Root space
h3.mt24 Unavailable rooms: Wrong type
.s-card.p0
ul.my8.ml24
each row in removedRootSpaceRooms
each row in removedWrongTypeRooms
li: a(href=`https://matrix.to/#/${row.room_id}`) (#{row.room_type}) #{row.name}
h3.mt24 Unavailable rooms: Archived thread
p If you still want to link with any of these rooms (eg. you accidentally unlinked it and want to bring it back, or you're migrating from a different bridge that happens to use OOYE's prefixes), please remove the [⛓️] or [🔒⛓️] prefix in Matrix's room settings and refresh the page.
.s-card.p0
ul.my8.ml24
each row in removedArchivedThreadRooms

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

85
src/web/routes/stats.js Normal file
View file

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

View file

@ -125,13 +125,14 @@ as.router.get("/icon.png", defineEventHandler(async event => {
pugSync.createRoute(as.router, "/ok", "ok.pug")
sync.require("./routes/agi")
sync.require("./routes/download-matrix")
sync.require("./routes/download-discord")
sync.require("./routes/guild-settings")
sync.require("./routes/guild")
sync.require("./routes/info")
sync.require("./routes/letter-avatar")
sync.require("./routes/link")
sync.require("./routes/log-in-with-matrix")
sync.require("./routes/oauth")
sync.require("./routes/password")
sync.require("./routes/stats")

View file

@ -5473,6 +5473,189 @@ module.exports = {
content: '-# Original Message ID: 1466556003645657118 · <t:1769724599:f>'
}
]
},
pk_ping_components_v1: {
type: 23,
content: "Psst, **Red** (<@772659086046658620>), you have been pinged by <@772659086046658620>.",
mentions: [
{
id: "772659086046658620",
username: "cadence.worm",
avatar: "466df0c98b1af1e1388f595b4c1ad1b9",
discriminator: "0",
public_flags: 0,
flags: 0,
banner: null,
accent_color: null,
global_name: "cadence",
avatar_decoration_data: null,
collectibles: null,
display_name_styles: null,
banner_color: null,
clan: {
identity_guild_id: "532245108070809601",
identity_enabled: true,
tag: "doll",
badge: "dba08126b4e810a0e096cc7cd5bc37f0"
},
primary_guild: {
identity_guild_id: "532245108070809601",
identity_enabled: true,
tag: "doll",
badge: "dba08126b4e810a0e096cc7cd5bc37f0"
}
}
],
mention_roles: [],
attachments: [],
embeds: [],
timestamp: "2026-03-25T07:07:02.626000+00:00",
edited_timestamp: null,
flags: 0,
components: [
{
type: 1,
id: 1,
components: [
{
type: 2,
id: 2,
style: 5,
label: "Jump",
url: "https://discord.com/channels/1160893336324931584/1160894080998461480/1440549403667468320"
}
]
}
],
id: "1486260105908457653",
channel_id: "1160894080998461480",
author: {
id: "466378653216014359",
username: "PluralKit",
avatar: "b78ef67a081737a830b60aa47d9ebcd9",
discriminator: "4020",
public_flags: 65536,
flags: 65536,
bot: true,
banner: null,
accent_color: null,
global_name: null,
avatar_decoration_data: null,
collectibles: null,
display_name_styles: null,
banner_color: null,
clan: null,
primary_guild: null
},
pinned: false,
mention_everyone: false,
tts: false,
application_id: "466378653216014359",
interaction: {
id: "1486260103928614932",
type: 2,
name: "🔔 Ping author",
user: {
id: "772659086046658620",
username: "cadence.worm",
avatar: "466df0c98b1af1e1388f595b4c1ad1b9",
discriminator: "0",
public_flags: 0,
flags: 0,
banner: null,
accent_color: null,
global_name: "cadence",
avatar_decoration_data: null,
collectibles: null,
display_name_styles: null,
banner_color: null,
clan: {
identity_guild_id: "532245108070809601",
identity_enabled: true,
tag: "doll",
badge: "dba08126b4e810a0e096cc7cd5bc37f0"
},
primary_guild: {
identity_guild_id: "532245108070809601",
identity_enabled: true,
tag: "doll",
badge: "dba08126b4e810a0e096cc7cd5bc37f0"
}
}
},
webhook_id: "466378653216014359",
message_reference: {
type: 0,
channel_id: "1160894080998461480",
message_id: "1440549403667468320",
guild_id: "1160893336324931584"
},
interaction_metadata: {
id: "1486260103928614932",
type: 2,
user: {
id: "772659086046658620",
username: "cadence.worm",
avatar: "466df0c98b1af1e1388f595b4c1ad1b9",
discriminator: "0",
public_flags: 0,
flags: 0,
banner: null,
accent_color: null,
global_name: "cadence",
avatar_decoration_data: null,
collectibles: null,
display_name_styles: null,
banner_color: null,
clan: {
identity_guild_id: "532245108070809601",
identity_enabled: true,
tag: "doll",
badge: "dba08126b4e810a0e096cc7cd5bc37f0"
},
primary_guild: {
identity_guild_id: "532245108070809601",
identity_enabled: true,
tag: "doll",
badge: "dba08126b4e810a0e096cc7cd5bc37f0"
}
},
authorizing_integration_owners: { "0": "1160893336324931584" },
name: "🔔 Ping author",
command_type: 3,
target_message_id: "1440549403667468320"
},
referenced_message: {
type: 0,
content: "test",
mentions: [],
mention_roles: [],
attachments: [],
embeds: [],
timestamp: "2025-11-19T03:49:01.948000+00:00",
edited_timestamp: null,
flags: 0,
components: [],
id: "1440549403667468320",
channel_id: "1160894080998461480",
author: {
id: "1195662438662680720",
username: "special name",
avatar: "a82347890f2739e5880cd82b8c1a708e",
discriminator: "0000",
public_flags: 0,
flags: 0,
bot: true,
global_name: null,
clan: null,
primary_guild: null
},
pinned: false,
mention_everyone: false,
tts: false,
application_id: "466378653216014359",
webhook_id: "1195662438662680720"
}
}
},
message_update: {

View file

@ -96,13 +96,12 @@ WITH a (message_id, channel_id) AS (VALUES
('1401760355339862066', '112760669178241024'),
('1439351590262800565', '1438284564815548418'),
('1404133238414376971', '112760669178241024'),
('1162005314908999790', '1100319550446252084'))
('1440549403667468320', '1160894080998461480'))
SELECT message_id, max(historical_room_index) as historical_room_index FROM a INNER JOIN historical_channel_room ON historical_channel_room.reference_channel_id = a.channel_id GROUP BY message_id;
INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part, reaction_part, source) VALUES
('$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg', 'm.room.message', 'm.text', '1126786462646550579', 0, 0, 1),
('$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4', 'm.room.message', 'm.text', '1128118177155526666', 0, 0, 0),
('$fdD9o7NxMA4VPexlAiIx2CB9JbsiGhJeyJgnZG7U5xg', 'm.room.message', 'm.text', '1162005314908999790', 0, 0, 1),
('$zXSlyI78DQqQwwfPUSzZ1b-nXzbUrCDljJgnGDdoI10', 'm.room.message', 'm.text', '1141619794500649020', 0, 0, 1),
('$fdD9OZ55xg3EAsfvLZza5tMhtjUO91Wg3Otuo96TplY', 'm.room.message', 'm.text', '1141206225632112650', 0, 0, 1),
('$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA', 'm.room.message', 'm.text', '1141501302736695316', 0, 1, 1),
@ -145,7 +144,8 @@ INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part
('$7P2O_VTQNHvavX5zNJ35DV-dbJB1Ag80tGQP_JzGdhk', 'm.room.message', 'm.text', '1401760355339862066', 0, 0, 0),
('$ielAnR6geu0P1Tl5UXfrbxlIf-SV9jrNprxrGXP3v7M', 'm.room.message', 'm.image', '1439351590262800565', 0, 0, 0),
('$uUKLcTQvik5tgtTGDKuzn0Ci4zcCvSoUcYn2X7mXm9I', 'm.room.message', 'm.text', '1404133238414376971', 0, 1, 1),
('$LhmoWWvYyn5_AHkfb6FaXmLI6ZOC1kloql5P40YDmIk', 'm.room.message', 'm.notice', '1404133238414376971', 1, 0, 1);
('$LhmoWWvYyn5_AHkfb6FaXmLI6ZOC1kloql5P40YDmIk', 'm.room.message', 'm.notice', '1404133238414376971', 1, 0, 1),
('$l9FMmsEbh9K0NUReeEpWOMZYGRlUOE8yLcm6P-TYHSM', 'm.room.message', 'm.text', '1440549403667468320', 0, 0, 1);
INSERT INTO file (discord_url, mxc_url) VALUES
('https://cdn.discordapp.com/attachments/497161332244742154/1124628646431297546/image.png', 'mxc://cadence.moe/qXoZktDqNtEGuOCZEADAMvhM'),
@ -194,6 +194,7 @@ INSERT INTO member_cache (room_id, mxid, displayname, avatar_url, power_level) V
('!TqlyQmifxGUggEmdBN:cadence.moe', '@ampflower:matrix.org', 'Ampflower 🌺', 'mxc://cadence.moe/PRfhXYBTOalvgQYtmCLeUXko', 0),
('!TqlyQmifxGUggEmdBN:cadence.moe', '@aflower:syndicated.gay', 'Rose', 'mxc://syndicated.gay/ZkBUPXCiXTjdJvONpLJmcbKP', 0),
('!TqlyQmifxGUggEmdBN:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', NULL, 0),
('!qzDBLKlildpzrrOnFZ:cadence.moe', '@lavender.pet:queer.sh', 'lavender.pet', NULL, 0),
('!iSyXgNxQcEuXoXpsSn:pussthecat.org', '@austin:tchncs.de', 'Austin Huang', 'mxc://tchncs.de/090a2b5e07eed2f71e84edad5207221e6c8f8b8e', 0),
('!zq94fae5bVKUubZLp7:agiadn.org', '@underscore_x:agiadn.org', 'underscore_x', NULL, 100);

View file

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