Compare commits
9 commits
main
...
fix-remote
| Author | SHA1 | Date | |
|---|---|---|---|
| a55d7a1632 | |||
| ab69eab8a4 | |||
| 717dc185e5 | |||
| 7932f8af85 | |||
| 0776cc6ccd | |||
| e7b4dfea9c | |||
| ea08e16963 | |||
| 1efd301e1d | |||
| dc7b444086 |
137 changed files with 3453 additions and 10339 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -3,8 +3,6 @@ config.js
|
|||
registration.yaml
|
||||
ooye.db*
|
||||
events.db*
|
||||
backfill.db*
|
||||
custom-webroot
|
||||
|
||||
# Automatically generated
|
||||
node_modules
|
||||
|
|
|
|||
11
addbot.js
11
addbot.js
|
|
@ -1,20 +1,12 @@
|
|||
#!/usr/bin/env node
|
||||
// @ts-check
|
||||
|
||||
const DiscordTypes = require("discord-api-types/v10")
|
||||
|
||||
const {reg} = require("./src/matrix/read-registration")
|
||||
const token = reg.ooye.discord_token
|
||||
const id = Buffer.from(token.split(".")[0], "base64").toString()
|
||||
const permissions =
|
||||
( DiscordTypes.PermissionFlagsBits.ManageWebhooks
|
||||
| DiscordTypes.PermissionFlagsBits.ManageGuildExpressions
|
||||
| DiscordTypes.PermissionFlagsBits.ManageMessages
|
||||
| DiscordTypes.PermissionFlagsBits.PinMessages
|
||||
| DiscordTypes.PermissionFlagsBits.UseExternalEmojis)
|
||||
|
||||
function addbot() {
|
||||
return `Open this link to add the bot to a Discord server:\nhttps://discord.com/oauth2/authorize?client_id=${id}&scope=bot&permissions=${permissions} `
|
||||
return `Open this link to add the bot to a Discord server:\nhttps://discord.com/oauth2/authorize?client_id=${id}&scope=bot&permissions=1610883072 `
|
||||
}
|
||||
|
||||
/* c8 ignore next 3 */
|
||||
|
|
@ -24,4 +16,3 @@ if (process.argv.find(a => a.endsWith("addbot") || a.endsWith("addbot.js"))) {
|
|||
|
||||
module.exports.id = id
|
||||
module.exports.addbot = addbot
|
||||
module.exports.permissions = permissions
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
|
||||
Using WeatherStack as a thin layer between the bridge application and the Discord API lets us control exactly what data is cached in memory. Only necessary information is cached. For example, member data, user data, message content, and past edits are never stored in memory. This keeps the memory usage low and also prevents it ballooning in size over the bridge's runtime.
|
||||
|
||||
The bridge uses a small SQLite database to store relationships like which Discord messages correspond to which Matrix messages. This is so the bridge knows what to edit when some message is edited on Discord. Using `without rowid` on the database tables stores the index and the data in the same B-tree. Since Matrix and Discord's internal IDs are quite long, this vastly reduces storage space because those IDs do not have to be stored twice separately. Some event IDs and URLs are actually stored as xxhash integers to reduce storage requirements even more. On my personal instance of OOYE, every 300,000 messages (representing a year of conversations) requires 40.6 MB of storage space in the SQLite database.
|
||||
The bridge uses a small SQLite database to store relationships like which Discord messages correspond to which Matrix messages. This is so the bridge knows what to edit when some message is edited on Discord. Using `without rowid` on the database tables stores the index and the data in the same B-tree. Since Matrix and Discord's internal IDs are quite long, this vastly reduces storage space because those IDs do not have to be stored twice separately. Some event IDs and URLs are actually stored as xxhash integers to reduce storage requirements even more. On my personal instance of OOYE, every 300,000 messages (representing a year of conversations) requires 47.3 MB of storage space in the SQLite database.
|
||||
|
||||
Only necessary data and columns are queried from the database. We only contact the homeserver API if the database doesn't contain what we need.
|
||||
|
||||
|
|
@ -89,7 +89,7 @@ Whether you read those or not, I'm more than happy to help you 1-on-1 with codin
|
|||
|
||||
# Dependency justification
|
||||
|
||||
Total transitive production dependencies: 134
|
||||
Total transitive production dependencies: 137
|
||||
|
||||
### <font size="+2">🦕</font>
|
||||
|
||||
|
|
@ -119,8 +119,8 @@ Total transitive production dependencies: 134
|
|||
* (0) entities: Looks fine. No dependencies.
|
||||
* (0) get-relative-path: Looks fine. No dependencies.
|
||||
* (1) heatsync: Module hot-reloader that I trust.
|
||||
* (1) js-yaml: Will be removed in the future after registration.yaml is converted to JSON.
|
||||
* (0) lru-cache: For holding unused nonce in memory and letting them be overwritten later if never used.
|
||||
* (0) mime-type: File extension to mime type mapping that's already pulled in by stream-mime-type.
|
||||
* (0) prettier-bytes: It does what I want and has no dependencies.
|
||||
* (0) snowtransfer: Discord API library with bring-your-own-caching that I trust.
|
||||
* (0) try-to-catch: Not strictly necessary, but it's already pulled in by supertape, so I may as well.
|
||||
|
|
|
|||
|
|
@ -1,76 +0,0 @@
|
|||
# Docker policy
|
||||
|
||||
**Out Of Your Element has no official support for Docker. There are no official files or images. If you choose to run Out Of Your Element in Docker, you must disclose this when asking for support. I may refuse to provide support/advice at any time. I may refuse to acknowledge issue reports.**
|
||||
|
||||
This also goes for Podman, Nix, and other similar technology that upends a program's understanding of what it's running on.
|
||||
|
||||
## What I recommend
|
||||
|
||||
I recommend [following the official setup guide,](https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/get-started.md) which does not use Docker.
|
||||
|
||||
Ultimately, though, do what makes you happy. I can't stop you from doing what you want. As long as you read this page and understand my perspective, that's good enough for me.
|
||||
|
||||
## Why I advise against Docker
|
||||
|
||||
When misconfigured, Docker has terrible impacts. It can cause messages to go missing or even permanent data loss. These have happened to people.
|
||||
|
||||
Docker also makes it much harder for me to advise on debugging because it puts barriers between you and useful debugging tools, such as stdin, the database file, a shell, and the inspector. It's also not clear which version of the source code is running in the container, as there are many pieces of Docker (builder, container, image) that can cache old data, often making it so you didn't actually update when you thought you did. This has happened to people.
|
||||
|
||||
## Why I don't provide a good configuration myself
|
||||
|
||||
It is not possible for Docker to be correctly configured by default. The defaults are broken and will cause data loss.
|
||||
|
||||
It is also not possible for me to provide a correct configuration for everyone. Even if I provided a correct image, the YAMLs and command-line arguments must be written by individual end users. Incorrect YAMLs and command-line arguments may cause connection issues or permanent data loss.
|
||||
|
||||
## Why I don't provide assistance if you run OOYE in Docker
|
||||
|
||||
Problems you encounter, especially with the initial setup, are much more likely to be caused by nuances in your Docker setup than problems in my code. Therefore, my code is not responsible for the problem. The cause of the problem is different code that I can't advise on.
|
||||
|
||||
Also, if you reported an issue and I asked for additional information to help find the cause, you might be unable to provide it because of the debugging barriers discussed above.
|
||||
|
||||
## Why I don't provide Docker resources
|
||||
|
||||
I create OOYE unpaid in my spare time because I enjoy the process. I find great enjoyment in creating code and none at all in creating infrastructure.
|
||||
|
||||
## Why you're probably fine without Docker
|
||||
|
||||
### If you care about system footprint
|
||||
|
||||
OOYE was designed to be simple and courteous:
|
||||
|
||||
* It only creates files in its working directory
|
||||
* It does not require any other processes to be running (e.g., no dependency on a Postgres process)
|
||||
* It only requires node/npm executables in PATH, which you can store in any folder if you don't want to use your package manager
|
||||
|
||||
### If you care about ease of setup
|
||||
|
||||
In my opinion, the [official setup process](https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/get-started.md) is straightforward. After installing prerequisites (Node.js and the repo clone), the rest of the process interactively guides you through providing necessary information. Your input is checked for correctness so the bridge will definitely work when you run it.
|
||||
|
||||
I find this easier than the usual Docker workflow of pasting values into a YAML and rolling the dice on whether it will start up or not.
|
||||
|
||||
### If you care about security in the case of compromise/RCE
|
||||
|
||||
There are no known vulnerabilities in dependencies. I [carefully selected simple, light dependencies](https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/developer-orientation.md#dependency-justification) to reduce attack surface area.
|
||||
|
||||
For defense in depth, I suggest running OOYE as a different user.
|
||||
|
||||
### If you want to see all the processes when you run docker ps
|
||||
|
||||
Well, you got me there.
|
||||
|
||||
## Unofficial, independent, community-provided container setups
|
||||
|
||||
I acknowledge the demand for using OOYE in a container, so I will still point you in the right direction.
|
||||
|
||||
I had no hand in creating these and have not used or tested them whatsoever. I make no assurance that these will work reliably, or even at all. If you use these, you must do so with the understanding that if you run into any problems, **you must ask for support from the author of that setup, not from me, because you're running their code, not mine.**
|
||||
|
||||
***The following list is distributed for your information, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.***
|
||||
|
||||
- by melody: https://git.shork.ch/docker-images/out-of-your-element
|
||||
- by sim2kid: https://github.com/sim2kid/ooye-docker
|
||||
- by Katharos Technology: https://github.com/katharostech/docker_ooye
|
||||
- by Emma: https://cgit.rory.gay/nix/OOYE-module.git/tree
|
||||
|
||||
## Making your own Docker setup
|
||||
|
||||
If you decide to make your own, I may provide advice or indicate problems at my discretion. You acknowledge that I am not required to provide evidence of problems I indicate, nor solutions to them. You acknowledge that it is not possible for me to exhaustively indicate every problem, so I cannot indicate correctness. Even if I have provided advice to an unofficial, independent, community-provided setup, I do not endorse it.
|
||||
|
|
@ -1,7 +1,5 @@
|
|||
# Setup
|
||||
|
||||
If you want Docker, [please read this first.](https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/docker.md)
|
||||
|
||||
If you get stuck, you're welcome to message [#out-of-your-element:cadence.moe](https://matrix.to/#/#out-of-your-element:cadence.moe) or [@cadence:cadence.moe](https://matrix.to/#/@cadence:cadence.moe) to ask for help setting up OOYE!
|
||||
|
||||
You'll need:
|
||||
|
|
@ -108,9 +106,3 @@ bridge.cadence.moe {
|
|||
reverse_proxy 127.0.0.1:6693
|
||||
}
|
||||
```
|
||||
|
||||
## Example reverse proxy for traefik
|
||||
|
||||
Note: Out Of Your Element has no official Docker support. This guide is for using traefik when OOYE is ***not*** in a container.
|
||||
|
||||
See [third-party/reverse-proxy-traefik.md](https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/third-party/reverse-proxy-traefik.md)
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 3.6 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 6.4 KiB |
|
|
@ -1,80 +0,0 @@
|
|||
# Room upgrades
|
||||
|
||||
"Upgrading" a room is supposed to create a new room and then tries to set it up exactly like the old one. So it copies name, topic, power levels, space membership, etc. The old room is marked as old with an `m.room.tombstone` event, its power levels are adjusted to make it harder to send messages, and a hyperlink to the new room is added.
|
||||
|
||||
## What happens?
|
||||
|
||||
A room upgrade is triggered by a POST request to `/_matrix/client/v3/rooms/{roomId}/upgrade`. The upgrade process is done by the server, and involves multiple events across multiple rooms. Since this is server-specific, what will _actually_ happen depends on the server's implementation, but the spec says it does this:
|
||||
|
||||
1. Checks that the user has permission to send `m.room.tombstone` events in the room.
|
||||
2. Creates a replacement room with a `m.room.create` event containing a predecessor field, the applicable `room_version`, and a `type` field which is copied from the predecessor room. If no `type` is set on the previous room, no `type` is specified on the new room’s create event either.
|
||||
3. Replicates transferable state events to the new room. The exact details for what is transferred is left as an implementation detail, however the recommended state events to transfer are:
|
||||
* `m.room.server_acl`
|
||||
* `m.room.encryption`
|
||||
* `m.room.name`
|
||||
* `m.room.avatar`
|
||||
* `m.room.topic`
|
||||
* `m.room.guest_access`
|
||||
* `m.room.history_visibility`
|
||||
* `m.room.join_rules`
|
||||
* `m.room.power_levels`
|
||||
|
||||
(Membership can't be transferred by the server.)
|
||||
|
||||
4. Moves any local aliases to the new room.
|
||||
5. Sends a `m.room.tombstone` event to the old room to indicate that it is not intended to be used any further.
|
||||
6. If possible, the power levels in the old room should also be modified to prevent sending of events and inviting new users. For example, setting `events_default` and `invite` to the greater of `50` and `users_default + 1`.
|
||||
|
||||
### Synapse additionally:
|
||||
|
||||
1. Copies its `m.space.child` events (if it was a space).
|
||||
* This is good for OOYE, because it automatically tries to join new rooms when they're added to a registered space.
|
||||
2. Copies bans.
|
||||
3. Un/publishes to the public room directory as applicable.
|
||||
4. Copies user tags and push rules.
|
||||
|
||||
Conduwuit does not do those!
|
||||
|
||||
### Element additionally:
|
||||
|
||||
1. May invite all users from the old room to the new room, depending on if the checkbox is checked in the dialog.
|
||||
2. Update parent spaces to remove the old room and add the new room.
|
||||
|
||||
Cinny does not do those! The new room is totally detached! The hyperlink from the old room (and the moved alias by server) is the only way to find it!
|
||||
|
||||
* This is probably still okay for OOYE? Since the join rules are preserved, and if they were `restricted`, OOYE is able to join via the tombstone hyperlink. Then, after it joins, it's already PL 100 since the power levels are preserved. It's very bad if the join rules were `invite`, but OOYE never sets this join rule - it's either `restricted` or `public`.
|
||||
|
||||
### Other clients
|
||||
|
||||
Nheko doesn't support room upgrades at all. Cinyy, NeoChat and FluffyChat just call the API and don't do anything. FluffyChat invites all joined/invited users to the new room if the join rule is restricted.
|
||||
|
||||
### Notable things that don't happen at all:
|
||||
|
||||
* Add `m.space.parent` pointing to the space it was in (if it was a room in a space).
|
||||
|
||||
## What should OOYE do?
|
||||
|
||||
### Ideal case (Element, Synapse)
|
||||
|
||||
The new room is added to the space and OOYE autojoins it. It already has the correct power levels and join rules.
|
||||
|
||||
OOYE still needs to do this:
|
||||
|
||||
1. Un/set `m.room.parent` in the rooms.
|
||||
2. Update `channel_room` and `historical_channel_room` tables.
|
||||
|
||||
### Not ideal case (everyone else)
|
||||
|
||||
OOYE should:
|
||||
|
||||
1. Join the room by following the hyperlink from the tombstone, if able
|
||||
* If not able, somebody messed with the join rules. Send a PM to the user who upgraded - the new room's creator - asking for an invite.
|
||||
2. Wait for join.
|
||||
3. Un/set `m.space.child` events on the space.
|
||||
4. Un/set `m.room.parent` in the rooms.
|
||||
5. Update `channel_room` and `historical_channel_room` tables.
|
||||
6. Un/publish to the room directory.
|
||||
|
||||
### It's actually fine to do all the steps always
|
||||
|
||||
Even by blindly following the entire list, each step is a no-op or atomic, so it doesn't matter if Element is also trying to do them.
|
||||
113
docs/third-party/reverse-proxy-traefik.md
vendored
113
docs/third-party/reverse-proxy-traefik.md
vendored
|
|
@ -1,113 +0,0 @@
|
|||
> This guide was written by @bgtlover:stealthy.club, a community contributor. The author of Out Of Your Element hopes it will be useful, but cannot say whether the information is accurate or complete.
|
||||
|
||||
## Example reverse proxy configuration with traefik
|
||||
|
||||
Note: This guide describes setting up the reverse proxy configuration when OOYE is ***not*** in a Docker container.
|
||||
|
||||
Because traefik is generally used in Docker, this guide assumes the user already has it configured properly. However, given that Docker is very complex and the smallest mistakes can cascade in catastrophic, not immediately observable, and unpredictable ways, a fairly complete setup will be reproduced. Therefore, system administrators are advised to diff this sample setup against theirs rather than copy it wholesale.
|
||||
|
||||
### Note on variable substitution
|
||||
|
||||
Variables will be denoted as `{{var}}`. This syntax has been chosen because that's also how YAML substitution works. The values that fit each variable will be explained after the code block containing the placeholder.
|
||||
|
||||
### Base compose configuration for traefik
|
||||
|
||||
This file defines the traefik service stack. It's responsible for mounting volumes correctly, declaring ports that should be opened on the host side, and the external traefik network (created manually).
|
||||
|
||||
In compose.yml, put the following:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
traefik:
|
||||
image: "traefik:latest"
|
||||
restart: always
|
||||
command:
|
||||
- "--configFile=/etc/traefik/static_config.yml"
|
||||
ports:
|
||||
- "80:80" #http
|
||||
- "443:443" #https
|
||||
networks:
|
||||
- traefik
|
||||
volumes:
|
||||
- ./letsencrypt:/letsencrypt
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- ./static_config.yml:/etc/traefik/static_config.yml
|
||||
- ./config:/etc/traefik/config
|
||||
networks:
|
||||
traefik:
|
||||
external: true
|
||||
```
|
||||
|
||||
### Static traefik configuration
|
||||
|
||||
The static traefik configuration is used to define base traefik behavior, for example entry points, access and runtime logs, a file or directory for per-service configuration, etc.
|
||||
|
||||
In static_config.yml, put the following:
|
||||
|
||||
```yaml
|
||||
api:
|
||||
dashboard: true
|
||||
|
||||
providers:
|
||||
docker:
|
||||
endpoint: "unix:///var/run/docker.sock"
|
||||
exposedByDefault: false
|
||||
network: "traefik"
|
||||
file:
|
||||
directory: /etc/traefik/config/
|
||||
watch: true
|
||||
|
||||
entryPoints:
|
||||
web-secure:
|
||||
address: ":443"
|
||||
asDefault: true
|
||||
http3: {}
|
||||
http:
|
||||
tls:
|
||||
certResolver: default
|
||||
web:
|
||||
address: ":80"
|
||||
http:
|
||||
redirections:
|
||||
entryPoint:
|
||||
to: web-secure
|
||||
|
||||
certificatesResolvers:
|
||||
default:
|
||||
acme:
|
||||
email: {{email}}
|
||||
storage: "/letsencrypt/acme.json"
|
||||
tlsChallenge: {}
|
||||
|
||||
```
|
||||
|
||||
Replace `{{email}}` with a valid email address.
|
||||
|
||||
### Out of your element traefik dynamic configuration
|
||||
|
||||
Traefik's dynamic configuration files configure proxy behaviors on a per-application level.
|
||||
|
||||
In config/out-of-your-element.yml, put the following:
|
||||
|
||||
```yaml
|
||||
http:
|
||||
routers:
|
||||
out-of-your-element:
|
||||
rule: Host(`bridge.stealthy.club`)
|
||||
service: out-of-your-element-service
|
||||
services:
|
||||
out-of-your-element-service:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://{{ip}}:{{port}}"
|
||||
|
||||
```
|
||||
|
||||
The `{{port}}` is 6693 unless you changed it during Out Of Your Element's first time setup.
|
||||
|
||||
Replace `{{ip}}` with the ***external*** IP of your server.
|
||||
|
||||
Make sure the port is allowed through your firewall if applicable.
|
||||
|
||||
For context, the external IP is required because of Docker networking. Because Docker modifies the host-side iptables firewall and creates virtual interfaces for its networks, and because the networking inside containers is configured such that localhost points to the IP of the container instead of the actual host, placing localhost in the url field above would make the traefik container establish an HTTP connection to itself, which would cause a bad gateway error.
|
||||
|
|
@ -2,7 +2,6 @@
|
|||
"compilerOptions": {
|
||||
"target": "es2024",
|
||||
"module": "nodenext",
|
||||
"lib": ["ESNext"],
|
||||
"strict": true,
|
||||
"noImplicitAny": false,
|
||||
"useUnknownInCatchVariables": false
|
||||
|
|
|
|||
1296
package-lock.json
generated
1296
package-lock.json
generated
File diff suppressed because it is too large
Load diff
25
package.json
25
package.json
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "out-of-your-element",
|
||||
"version": "3.4.0",
|
||||
"version": "3.1.0",
|
||||
"description": "A bridge between Matrix and Discord",
|
||||
"main": "index.js",
|
||||
"repository": {
|
||||
|
|
@ -12,14 +12,14 @@
|
|||
"discord",
|
||||
"bridge"
|
||||
],
|
||||
"author": "Cadence",
|
||||
"author": "Cadence, PapiOphidian",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
"node": ">=20"
|
||||
},
|
||||
"dependencies": {
|
||||
"@chriscdn/promise-semaphore": "^3.0.1",
|
||||
"@cloudrac3r/discord-markdown": "^2.6.10",
|
||||
"@cloudrac3r/discord-markdown": "^2.6.7",
|
||||
"@cloudrac3r/giframe": "^0.4.3",
|
||||
"@cloudrac3r/html-template-tag": "^5.0.1",
|
||||
"@cloudrac3r/in-your-element": "^1.1.1",
|
||||
|
|
@ -32,8 +32,8 @@
|
|||
"ansi-colors": "^4.1.3",
|
||||
"better-sqlite3": "^12.2.0",
|
||||
"chunk-text": "^2.0.1",
|
||||
"cloudstorm": "^0.15.2",
|
||||
"discord-api-types": "^0.38.38",
|
||||
"cloudstorm": "^0.14.0",
|
||||
"discord-api-types": "^0.38.19",
|
||||
"domino": "^2.1.6",
|
||||
"enquirer": "^2.4.1",
|
||||
"entities": "^5.0.0",
|
||||
|
|
@ -42,31 +42,28 @@
|
|||
"heatsync": "^2.7.2",
|
||||
"htmx.org": "^2.0.4",
|
||||
"lru-cache": "^11.0.2",
|
||||
"mime-types": "^2.1.35",
|
||||
"prettier-bytes": "^1.0.4",
|
||||
"sharp": "^0.34.5",
|
||||
"snowtransfer": "^0.17.1",
|
||||
"sharp": "^0.33.4",
|
||||
"snowtransfer": "^0.14.2",
|
||||
"stream-mime-type": "^1.0.2",
|
||||
"try-to-catch": "^3.0.1",
|
||||
"uqr": "^0.1.2",
|
||||
"xxhash-wasm": "^1.0.2",
|
||||
"zod": "^4.0.17"
|
||||
},
|
||||
"overrides": {
|
||||
"glob@<11.1": "^12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudrac3r/tap-dot": "^2.0.3",
|
||||
"@types/node": "^22.17.1",
|
||||
"c8": "^10.1.2",
|
||||
"cross-env": "^7.0.3",
|
||||
"supertape": "^12.0.12"
|
||||
"supertape": "^11.3.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node --enable-source-maps start.js",
|
||||
"setup": "node --enable-source-maps scripts/setup.js",
|
||||
"addbot": "node addbot.js",
|
||||
"test": "cross-env FORCE_COLOR=true supertape --no-check-assertions-count --format tap --no-worker test/test.js | tap-dot",
|
||||
"cover": "c8 -o test/coverage --skip-full -x db/migrations -x src/m2d/event-dispatcher.js -x src/matrix/file.js -x src/matrix/api.js -x src/d2m/converters/rlottie-wasm.js -r html -r text supertape --no-check-assertions-count --format fail --no-worker test/test.js -- --slow"
|
||||
"test-slow": "cross-env FORCE_COLOR=true supertape --no-check-assertions-count --format tap --no-worker test/test.js -- --slow | tap-dot",
|
||||
"cover": "c8 -o test/coverage --skip-full -x db/migrations -x src/matrix/file.js -x src/matrix/api.js -x src/d2m/converters/rlottie-wasm.js -r html -r text supertape --no-check-assertions-count --format fail --no-worker test/test.js -- --slow"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,7 +38,6 @@ For more information about features, [see the user guide.](https://gitdab.com/ca
|
|||
|
||||
* This bridge is not designed for puppetting.
|
||||
* Direct Messaging is not supported until I figure out a good way of doing it.
|
||||
* Encrypted messages are not supported. Decryption is often unreliable on Matrix, and your messages end up in plaintext on Discord anyway, so there's not much advantage.
|
||||
|
||||
## Get started!
|
||||
|
||||
|
|
|
|||
|
|
@ -29,9 +29,6 @@ const DiscordClient = require("../src/d2m/discord-client")
|
|||
const discord = new DiscordClient(reg.ooye.discord_token, "half")
|
||||
passthrough.discord = discord
|
||||
|
||||
const {as} = require("../src/matrix/appservice")
|
||||
passthrough.as = as
|
||||
|
||||
const orm = sync.require("../src/db/orm")
|
||||
passthrough.from = orm.from
|
||||
passthrough.select = orm.select
|
||||
|
|
@ -72,7 +69,7 @@ async function event(event) {
|
|||
backfill: true,
|
||||
...message
|
||||
}
|
||||
await eventDispatcher.MESSAGE_CREATE(discord, simulatedGatewayDispatchData)
|
||||
await eventDispatcher.onMessageCreate(discord, simulatedGatewayDispatchData)
|
||||
preparedInsert.run(channelID, message.id)
|
||||
}
|
||||
last = messages.at(-1)?.id
|
||||
|
|
|
|||
|
|
@ -1,65 +0,0 @@
|
|||
// @ts-check
|
||||
|
||||
const pb = require("prettier-bytes")
|
||||
const sqlite = require("better-sqlite3")
|
||||
const HeatSync = require("heatsync")
|
||||
|
||||
const {reg} = require("../src/matrix/read-registration")
|
||||
const passthrough = require("../src/passthrough")
|
||||
|
||||
const sync = new HeatSync({watchFS: false})
|
||||
Object.assign(passthrough, {reg, sync})
|
||||
|
||||
const DiscordClient = require("../src/d2m/discord-client")
|
||||
|
||||
const discord = new DiscordClient(reg.ooye.discord_token, "no")
|
||||
passthrough.discord = discord
|
||||
|
||||
const db = new sqlite("ooye.db")
|
||||
passthrough.db = db
|
||||
|
||||
const api = require("../src/matrix/api")
|
||||
|
||||
const {room: roomID} = require("minimist")(process.argv.slice(2), {string: ["room"]})
|
||||
if (!roomID) {
|
||||
console.error("Usage: ./scripts/estimate-size.js --room=<!room id here>")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const {channel_id, guild_id} = db.prepare("SELECT channel_id, guild_id FROM channel_room WHERE room_id = ?").get(roomID)
|
||||
|
||||
const max = 1000
|
||||
|
||||
;(async () => {
|
||||
let total = 0
|
||||
let size = 0
|
||||
let from
|
||||
|
||||
while (total < max) {
|
||||
const events = await api.getEvents(roomID, "b", {limit: 1000, from})
|
||||
total += events.chunk.length
|
||||
from = events.end
|
||||
console.log(`Fetched ${total} events so far`)
|
||||
|
||||
for (const e of events.chunk) {
|
||||
if (e.content?.info?.size) {
|
||||
size += e.content.info.size
|
||||
}
|
||||
}
|
||||
|
||||
if (events.chunk.length === 0 || !events.end) break
|
||||
}
|
||||
|
||||
console.log(`Total size of uploads: ${pb(size)}`)
|
||||
|
||||
const searchResults = await discord.snow.requestHandler.request(`/guilds/${guild_id}/messages/search`, {
|
||||
channel_id,
|
||||
offset: "0",
|
||||
limit: "1"
|
||||
}, "get", "json")
|
||||
|
||||
const totalAllTime = searchResults.total_results
|
||||
const fractionCounted = total / totalAllTime
|
||||
console.log(`That counts for ${(fractionCounted*100).toFixed(2)}% of the history on Discord (${totalAllTime.toLocaleString()} messages)`)
|
||||
console.log(`The size of uploads for the whole history would be approx: ${pb(Math.floor(size/total*totalAllTime))}`)
|
||||
})()
|
||||
|
|
@ -37,9 +37,7 @@ const createRoom = sync.require("../d2m/actions/create-room")
|
|||
/** @type {import("../src/matrix/mreq")} */
|
||||
const mreq = sync.require("../matrix/mreq")
|
||||
/** @type {import("../src/matrix/api")} */
|
||||
const api = sync.require("../src/matrix/api")
|
||||
/** @type {import("../src/matrix/utils")} */
|
||||
const utils = sync.require("../src/matrix/utils")
|
||||
const api = sync.require("../matrix/api")
|
||||
|
||||
const sema = new Semaphore()
|
||||
|
||||
|
|
@ -82,8 +80,16 @@ async function migrateGuild(guild) {
|
|||
// Step 2: (Using old bridge access token) Join the new bridge to the old rooms and give it PL 100
|
||||
console.log(`-- Joining channel ${channel.name}...`)
|
||||
await mreq.withAccessToken(oldAT, async () => {
|
||||
await api.inviteToRoom(roomID, newBridgeMxid)
|
||||
await utils.setUserPower(roomID, newBridgeMxid, 100, api)
|
||||
try {
|
||||
await api.inviteToRoom(roomID, newBridgeMxid)
|
||||
} catch (e) {
|
||||
if (e.message.includes("is already in the room")) {
|
||||
// Great!
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
await api.setUserPower(roomID, newBridgeMxid, 100)
|
||||
})
|
||||
await api.joinRoom(roomID)
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ const passthrough = require("../src/passthrough")
|
|||
Object.assign(passthrough, {db, sync})
|
||||
|
||||
const api = require("../src/matrix/api")
|
||||
const utils = require("../src/matrix/utils")
|
||||
const mreq = require("../src/matrix/mreq")
|
||||
|
||||
const rooms = db.prepare("select room_id from channel_room").pluck().all()
|
||||
|
|
@ -26,7 +25,7 @@ const rooms = db.prepare("select room_id from channel_room").pluck().all()
|
|||
await api.leaveRoom(roomID, mxid)
|
||||
}
|
||||
}
|
||||
await utils.setUserPower(roomID, "@_discord_bot:cadence.moe", 0, api)
|
||||
await api.setUserPower(roomID, "@_discord_bot:cadence.moe", 0)
|
||||
await api.leaveRoom(roomID)
|
||||
} catch (e) {
|
||||
if (e.message.includes("Appservice not in room")) {
|
||||
|
|
|
|||
|
|
@ -1,17 +0,0 @@
|
|||
// @ts-check
|
||||
|
||||
const {reg, writeRegistration, registrationFilePath} = require("../src/matrix/read-registration")
|
||||
const {prompt} = require("enquirer")
|
||||
|
||||
;(async () => {
|
||||
/** @type {{web_password: string}} */
|
||||
const passwordResponse = await prompt({
|
||||
type: "text",
|
||||
name: "web_password",
|
||||
message: "Choose a simple password (optional)"
|
||||
})
|
||||
|
||||
reg.ooye.web_password = passwordResponse.web_password
|
||||
writeRegistration(reg)
|
||||
console.log("Saved. Restart Out Of Your Element to apply this change.")
|
||||
})()
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
#!/usr/bin/env node
|
||||
// @ts-check
|
||||
|
||||
const Ty = require("../src/types")
|
||||
const assert = require("assert").strict
|
||||
const fs = require("fs")
|
||||
const sqlite = require("better-sqlite3")
|
||||
|
|
@ -18,6 +17,22 @@ const {SnowTransfer} = require("snowtransfer")
|
|||
const DiscordTypes = require("discord-api-types/v10")
|
||||
const {createApp, defineEventHandler, toNodeListener} = require("h3")
|
||||
|
||||
// Move database file if it's still in the old location
|
||||
if (fs.existsSync("db")) {
|
||||
if (fs.existsSync("db/ooye.db")) {
|
||||
fs.renameSync("db/ooye.db", "ooye.db")
|
||||
}
|
||||
const files = fs.readdirSync("db")
|
||||
if (files.length) {
|
||||
console.error("The db folder is deprecated and must be removed. Your ooye.db database file has already been moved to the root of the repo. You must manually move or delete the remaining files:")
|
||||
for (const file of files) {
|
||||
console.error(file)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
fs.rmSync("db", {recursive: true})
|
||||
}
|
||||
|
||||
const passthrough = require("../src/passthrough")
|
||||
const db = new sqlite("ooye.db")
|
||||
const migrate = require("../src/db/migrate")
|
||||
|
|
@ -35,6 +50,11 @@ let {reg, getTemplateRegistration, writeRegistration, readRegistration, checkReg
|
|||
|
||||
const {setupEmojis} = require("../src/m2d/actions/setup-emojis")
|
||||
|
||||
function die(message) {
|
||||
console.error(message)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
async function suggestWellKnown(serverUrlPrompt, url, otherwise) {
|
||||
try {
|
||||
var json = await fetch(`${url}/.well-known/matrix/client`).then(res => res.json())
|
||||
|
|
@ -60,7 +80,6 @@ async function validateHomeserverOrigin(serverUrlPrompt, url) {
|
|||
return e.message
|
||||
}
|
||||
try {
|
||||
/** @type {any} */
|
||||
var json = await res.json()
|
||||
if (!Array.isArray(json?.versions) || !json.versions.includes("v1.11")) {
|
||||
return `OOYE needs Matrix version v1.11, but ${url} doesn't support this`
|
||||
|
|
@ -87,7 +106,7 @@ function defineEchoHandler() {
|
|||
type: "input",
|
||||
name: "server_name",
|
||||
message: "Homeserver name",
|
||||
validate: serverName => !!serverName.match(/[a-z0-9][.a-z0-9-]+[a-z]/)
|
||||
validate: serverName => !!serverName.match(/[a-z][a-z.]+[a-z]/)
|
||||
})
|
||||
|
||||
console.log("What is the URL of your homeserver?")
|
||||
|
|
@ -169,21 +188,17 @@ function defineEchoHandler() {
|
|||
}
|
||||
})
|
||||
|
||||
const intentFlagPossibilities = [
|
||||
DiscordTypes.ApplicationFlags.GatewayMessageContent | DiscordTypes.ApplicationFlags.GatewayPresence | DiscordTypes.ApplicationFlags.GatewayGuildMembers,
|
||||
DiscordTypes.ApplicationFlags.GatewayMessageContentLimited | DiscordTypes.ApplicationFlags.GatewayPresenceLimited | DiscordTypes.ApplicationFlags.GatewayGuildMembersLimited
|
||||
]
|
||||
const intentFlagMask = intentFlagPossibilities.reduce((a, c) => a | c, 0)
|
||||
if (!intentFlagPossibilities.includes(client.flags & intentFlagMask)) {
|
||||
const mandatoryIntentFlags = DiscordTypes.ApplicationFlags.GatewayMessageContent | DiscordTypes.ApplicationFlags.GatewayMessageContentLimited
|
||||
if (!(client.flags & mandatoryIntentFlags)) {
|
||||
console.log(`On that same page, scroll down to Privileged Gateway Intents and enable all switches.`)
|
||||
await prompt({
|
||||
type: "invisible",
|
||||
name: "intents",
|
||||
message: "Press Enter when you've enabled them",
|
||||
validate: async () => {
|
||||
validate: async token => {
|
||||
process.stdout.write(magenta("checking, please wait..."))
|
||||
client = await snow.requestHandler.request(`/applications/@me`, {}, "get", "json")
|
||||
if (intentFlagPossibilities.includes(client.flags & intentFlagMask)) {
|
||||
if (client.flags & mandatoryIntentFlags) {
|
||||
return true
|
||||
} else {
|
||||
return "Switches have not been enabled yet"
|
||||
|
|
@ -209,7 +224,7 @@ function defineEchoHandler() {
|
|||
type: "invisible",
|
||||
name: "description",
|
||||
message: "Press Enter to acknowledge",
|
||||
validate: async () => {
|
||||
validate: async token => {
|
||||
process.stdout.write(magenta("checking, please wait..."))
|
||||
client = await snow.requestHandler.request(`/applications/@me`, {}, "get", "json")
|
||||
if (client.description?.match(/out.of.your.element/i)) {
|
||||
|
|
@ -226,8 +241,7 @@ function defineEchoHandler() {
|
|||
const clientSecretResponse = await prompt({
|
||||
type: "input",
|
||||
name: "discord_client_secret",
|
||||
message: "Client secret",
|
||||
validate: secret => !!secret
|
||||
message: "Client secret"
|
||||
})
|
||||
|
||||
const expectedUri = `${bridgeOriginResponse.bridge_origin}/oauth`
|
||||
|
|
@ -237,7 +251,7 @@ function defineEchoHandler() {
|
|||
type: "invisible",
|
||||
name: "redirect_uri",
|
||||
message: "Press Enter when you've added it",
|
||||
validate: async () => {
|
||||
validate: async token => {
|
||||
process.stdout.write(magenta("checking, please wait..."))
|
||||
client = await snow.requestHandler.request(`/applications/@me`, {}, "get", "json")
|
||||
if (client.redirect_uris.includes(expectedUri)) {
|
||||
|
|
@ -286,15 +300,14 @@ function defineEchoHandler() {
|
|||
console.log()
|
||||
|
||||
// Done with user prompts, reg is now guaranteed to be valid
|
||||
const mreq = require("../src/matrix/mreq")
|
||||
const api = require("../src/matrix/api")
|
||||
const file = require("../src/matrix/file")
|
||||
const DiscordClient = require("../src/d2m/discord-client")
|
||||
const discord = new DiscordClient(reg.ooye.discord_token, "no")
|
||||
passthrough.discord = discord
|
||||
|
||||
const {as} = require("../src/matrix/appservice")
|
||||
as.router.use("/**", defineEchoHandler())
|
||||
await as.listen()
|
||||
|
||||
console.log("⏳ Waiting for you to register the file with your homeserver... (Ctrl+C to cancel)")
|
||||
process.once("SIGINT", () => {
|
||||
|
|
@ -344,13 +357,7 @@ function defineEchoHandler() {
|
|||
await api.register(reg.sender_localpart)
|
||||
|
||||
// upload initial images...
|
||||
const avatarBuffer = await fs.promises.readFile(join(__dirname, "..", "docs", "img", "icon.png"), null)
|
||||
/** @type {Ty.R.FileUploaded} */
|
||||
const root = await mreq.mreq("POST", "/media/v3/upload", avatarBuffer, {
|
||||
headers: {"Content-Type": "image/png"}
|
||||
})
|
||||
const avatarUrl = root.content_uri
|
||||
assert(avatarUrl)
|
||||
const avatarUrl = await file.uploadDiscordFileToMxc("https://cadence.moe/friends/out_of_your_element.png")
|
||||
|
||||
console.log("✅ Matrix appservice login works...")
|
||||
|
||||
|
|
@ -359,7 +366,8 @@ function defineEchoHandler() {
|
|||
console.log("✅ Emojis are ready...")
|
||||
|
||||
// set profile data on discord...
|
||||
await discord.snow.user.updateSelf({avatar: "data:image/png;base64," + avatarBuffer.toString("base64")})
|
||||
const avatarImageBuffer = await fetch("https://cadence.moe/friends/out_of_your_element.png").then(res => res.arrayBuffer())
|
||||
await discord.snow.user.updateSelf({avatar: "data:image/png;base64," + Buffer.from(avatarImageBuffer).toString("base64")})
|
||||
console.log("✅ Discord profile updated...")
|
||||
|
||||
// set profile data on homeserver...
|
||||
|
|
|
|||
|
|
@ -34,9 +34,5 @@ passthrough.select = orm.select
|
|||
console.log("Discord gateway started")
|
||||
sync.require("../src/web/server")
|
||||
|
||||
discord.cloud.once("ready", () => {
|
||||
as.listen()
|
||||
})
|
||||
|
||||
require("../src/stdin")
|
||||
})()
|
||||
|
|
|
|||
|
|
@ -1,65 +0,0 @@
|
|||
// @ts-check
|
||||
|
||||
const Ty = require("../src/types")
|
||||
const fs = require("fs")
|
||||
const domino = require("domino")
|
||||
const repl = require("repl")
|
||||
|
||||
const pres = (() => {
|
||||
const pres = []
|
||||
for (const file of process.argv.slice(2)) {
|
||||
const data = JSON.parse(fs.readFileSync(file, "utf8"))
|
||||
/** @type {Ty.Event.Outer<{msgtype?: string}>[]} */
|
||||
const events = data.messages
|
||||
for (const event of events) {
|
||||
if (event.type !== "m.room.message" || event.content.msgtype !== "m.text") continue
|
||||
/** @type {Ty.Event.M_Room_Message} */ // @ts-ignore
|
||||
const content = event.content
|
||||
if (content.format !== "org.matrix.custom.html") continue
|
||||
if (!content.formatted_body) continue
|
||||
|
||||
const document = domino.createDocument(content.formatted_body)
|
||||
// @ts-ignore
|
||||
for (const pre of document.querySelectorAll("pre").cache) {
|
||||
const content = pre.textContent
|
||||
if (content.length < 100) continue
|
||||
pres.push(content)
|
||||
}
|
||||
}
|
||||
}
|
||||
return pres
|
||||
})()
|
||||
|
||||
// @ts-ignore
|
||||
global.gc()
|
||||
|
||||
/** @param {string} text */
|
||||
function probablyFixedWidthIntended(text) {
|
||||
// if internal spaces are used, seems like they want a fixed-width font
|
||||
if (text.match(/[^ ] {3,}[^ ]/)) return true
|
||||
// if characters from Unicode General_Category "Symbol, other" are used, seems like they're doing ascii art and they want a fixed-width font
|
||||
if (text.match(/\p{So}/v)) return true
|
||||
// check start of line indentation
|
||||
let indents = new Set()
|
||||
for (const line of text.trimEnd().split("\n")) {
|
||||
indents.add(line.match(/^ */)?.[0].length || 0)
|
||||
// if there are more than 3 different indents (counting 0) then it's code
|
||||
if (indents.size >= 3) return true
|
||||
}
|
||||
// if everything is indented then it's code
|
||||
if (!indents.has(0)) return true
|
||||
// if there is a high proportion of symbols then it's code (this filter works remarkably well on its own)
|
||||
if ([...text.matchAll(/[\\`~;+|<>%$@*&"'=(){}[\]_^]|\.[a-zA-Z]|[a-z][A-Z]/g)].length / text.length >= 0.04) return true
|
||||
return false
|
||||
}
|
||||
|
||||
Object.assign(repl.start().context, {pres, probablyFixedWidthIntended})
|
||||
|
||||
/*
|
||||
if it has a lot of symbols then it's code
|
||||
if it has >=3 levels of indentation then it's code
|
||||
if it is all indented then it's code
|
||||
if it has many spaces in a row in the middle then it's ascii art
|
||||
if it has many non-latin characters then it's language
|
||||
-> except if they are ascii art characters e.g. ⣿⣿⡇⢸⣿⠃ then it's ascii art
|
||||
*/
|
||||
|
|
@ -21,7 +21,7 @@ async function addReaction(data) {
|
|||
const user = data.member?.user
|
||||
assert.ok(user && user.username)
|
||||
|
||||
const parentID = select("event_message", "event_id", {message_id: data.message_id}, "ORDER BY reaction_part").pluck().get()
|
||||
const parentID = select("event_message", "event_id", {message_id: data.message_id, reaction_part: 0}).pluck().get()
|
||||
if (!parentID) return // Nothing can be done if the parent message was never bridged.
|
||||
assert.equal(typeof parentID, "string")
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// @ts-check
|
||||
|
||||
const assert = require("assert").strict
|
||||
const assert = require("assert")
|
||||
|
||||
const passthrough = require("../../passthrough")
|
||||
const {discord, sync, db, select} = passthrough
|
||||
|
|
|
|||
|
|
@ -17,8 +17,8 @@ const mreq = sync.require("../../matrix/mreq")
|
|||
const ks = sync.require("../../matrix/kstate")
|
||||
/** @type {import("../../discord/utils")} */
|
||||
const dUtils = sync.require("../../discord/utils")
|
||||
/** @type {import("../../matrix/utils")} */
|
||||
const mUtils = sync.require("../../matrix/utils")
|
||||
/** @type {import("../../m2d/converters/utils")} */
|
||||
const mUtils = sync.require("../../m2d/converters/utils")
|
||||
/** @type {import("./create-space")} */
|
||||
const createSpace = sync.require("./create-space")
|
||||
|
||||
|
|
@ -77,7 +77,7 @@ function convertNameAndTopic(channel, guild, customName) {
|
|||
* Async because it may create the guild and/or upload the guild icon to mxc.
|
||||
* @param {DiscordTypes.APIGuildTextChannel | DiscordTypes.APIThreadChannel} channel
|
||||
* @param {DiscordTypes.APIGuild} guild
|
||||
* @param {{api: {getStateEvent: typeof api.getStateEvent, getStateEventOuter: typeof api.getStateEventOuter}}} di simple-as-nails dependency injection for the matrix API
|
||||
* @param {{api: {getStateEvent: typeof api.getStateEvent}}} di simple-as-nails dependency injection for the matrix API
|
||||
*/
|
||||
async function channelToKState(channel, guild, di) {
|
||||
// @ts-ignore
|
||||
|
|
@ -122,31 +122,19 @@ async function channelToKState(channel, guild, di) {
|
|||
join_rules = {join_rule: PRIVACY_ENUMS.ROOM_JOIN_RULES[privacyLevel]}
|
||||
}
|
||||
|
||||
const everyonePermissions = dUtils.getPermissions(guild.id, [], guild.roles, undefined, channel.permission_overwrites)
|
||||
const everyonePermissions = dUtils.getPermissions([], guild.roles, undefined, channel.permission_overwrites)
|
||||
const everyoneCanSend = dUtils.hasPermission(everyonePermissions, DiscordTypes.PermissionFlagsBits.SendMessages)
|
||||
const everyoneCanMentionEveryone = dUtils.hasPermission(everyonePermissions, DiscordTypes.PermissionFlagsBits.MentionEveryone)
|
||||
|
||||
const pollStartPowerLevel = {}
|
||||
const everyoneCanCreatePolls = dUtils.hasPermission(everyonePermissions, DiscordTypes.PermissionFlagsBits.SendPolls)
|
||||
if (everyoneCanSend && !everyoneCanCreatePolls) pollStartPowerLevel["org.matrix.msc3381.poll.start"] = 10
|
||||
|
||||
const spacePowerDetails = await mUtils.getEffectivePower(guildSpaceID, [], di.api)
|
||||
spacePowerDetails.powerLevels.users ??= {}
|
||||
const spaceCreatorsAndFounders = spacePowerDetails.allCreators
|
||||
.concat(Object.entries(spacePowerDetails.powerLevels.users).filter(([, power]) => power >= spacePowerDetails.tombstone).map(([mxid]) => mxid))
|
||||
const everyoneCanMentionEveryone = dUtils.hasAllPermissions(everyonePermissions, ["MentionEveryone"])
|
||||
|
||||
const globalAdmins = select("member_power", ["mxid", "power_level"], {room_id: "*"}).all()
|
||||
const globalAdminPower = globalAdmins.reduce((a, c) => (a[c.mxid] = c.power_level, a), {})
|
||||
|
||||
const additionalCreators = select("member_power", "mxid", {room_id: "*"}, "AND power_level > 100").pluck().all().concat(spaceCreatorsAndFounders)
|
||||
const creationContent = {}
|
||||
creationContent.additional_creators = additionalCreators
|
||||
|
||||
if (channel.type === DiscordTypes.ChannelType.GuildForum) creationContent.type = "m.space"
|
||||
/** @type {Ty.Event.M_Power_Levels} */
|
||||
const spacePowerEvent = await di.api.getStateEvent(guildSpaceID, "m.room.power_levels", "")
|
||||
const spacePower = spacePowerEvent.users
|
||||
|
||||
/** @type {any} */
|
||||
const channelKState = {
|
||||
"m.room.create/": creationContent,
|
||||
"m.room.name/": {name: convertedName},
|
||||
"m.room.topic/": {topic: convertedTopic},
|
||||
"m.room.avatar/": avatarEventContent,
|
||||
|
|
@ -163,16 +151,17 @@ async function channelToKState(channel, guild, di) {
|
|||
events_default: everyoneCanSend ? 0 : READ_ONLY_ROOM_EVENTS_DEFAULT_POWER,
|
||||
events: {
|
||||
"m.reaction": 0,
|
||||
"m.room.redaction": 0, // only affects redactions of own events, required to be able to un-react
|
||||
...pollStartPowerLevel
|
||||
"m.room.redaction": 0 // only affects redactions of own events, required to be able to un-react
|
||||
},
|
||||
notifications: {
|
||||
room: everyoneCanMentionEveryone ? 0 : 20
|
||||
},
|
||||
users: {...spacePowerDetails.powerLevels.users, ...globalAdminPower}
|
||||
users: {...spacePower, ...globalAdminPower}
|
||||
},
|
||||
"chat.schildi.hide_ui/read_receipts": {
|
||||
},
|
||||
[`uk.half-shot.bridge/moe.cadence.ooye://discord/${guild.id}/${channel.id}`]: {
|
||||
bridgebot: mUtils.bot,
|
||||
bridgebot: `@${reg.sender_localpart}:${reg.ooye.server_name}`,
|
||||
protocol: {
|
||||
id: "discord",
|
||||
displayname: "Discord"
|
||||
|
|
@ -206,7 +195,7 @@ async function channelToKState(channel, guild, di) {
|
|||
/**
|
||||
* Create a bridge room, store the relationship in the database, and add it to the guild's space.
|
||||
* @param {DiscordTypes.APIGuildTextChannel} channel
|
||||
* @param {DiscordTypes.APIGuild} guild
|
||||
* @param guild
|
||||
* @param {string} spaceID
|
||||
* @param {any} kstate
|
||||
* @param {number} privacyLevel
|
||||
|
|
@ -216,6 +205,9 @@ async function createRoom(channel, guild, spaceID, kstate, privacyLevel) {
|
|||
let threadParent = null
|
||||
if (channel.type === DiscordTypes.ChannelType.PublicThread) threadParent = channel.parent_id
|
||||
|
||||
let spaceCreationContent = {}
|
||||
if (channel.type === DiscordTypes.ChannelType.GuildForum) spaceCreationContent = {creation_content: {type: "m.space"}}
|
||||
|
||||
// Name and topic can be done earlier in room creation rather than in initial_state
|
||||
// https://spec.matrix.org/latest/client-server-api/#creation
|
||||
const name = kstate["m.room.name/"].name
|
||||
|
|
@ -225,7 +217,7 @@ async function createRoom(channel, guild, spaceID, kstate, privacyLevel) {
|
|||
delete kstate["m.room.topic/"]
|
||||
assert(topic)
|
||||
|
||||
const roomCreate = await postApplyPowerLevels(kstate, async kstate => {
|
||||
const roomID = await postApplyPowerLevels(kstate, async kstate => {
|
||||
const roomID = await api.createRoom({
|
||||
name,
|
||||
topic,
|
||||
|
|
@ -233,20 +225,13 @@ async function createRoom(channel, guild, spaceID, kstate, privacyLevel) {
|
|||
visibility: PRIVACY_ENUMS.VISIBILITY[privacyLevel],
|
||||
invite: [],
|
||||
initial_state: await ks.kstateToState(kstate),
|
||||
creation_content: ks.kstateToCreationContent(kstate)
|
||||
...spaceCreationContent
|
||||
})
|
||||
|
||||
/** @type {Ty.Event.StateOuter<Ty.Event.M_Room_Create>} */
|
||||
const roomCreate = await api.getStateEventOuter(roomID, "m.room.create", "")
|
||||
db.prepare("INSERT INTO channel_room (channel_id, room_id, name, nick, thread_parent) VALUES (?, ?, ?, NULL, ?)").run(channel.id, roomID, channel.name, threadParent)
|
||||
|
||||
db.transaction(() => {
|
||||
db.prepare("INSERT INTO channel_room (channel_id, room_id, name, nick, thread_parent, guild_id) VALUES (?, ?, ?, NULL, ?, ?)").run(channel.id, roomID, channel.name, threadParent, guild.id)
|
||||
db.prepare("INSERT INTO historical_channel_room (reference_channel_id, room_id, upgraded_timestamp) VALUES (?, ?, 0)").run(channel.id, roomID)
|
||||
})()
|
||||
|
||||
return roomCreate
|
||||
return roomID
|
||||
})
|
||||
const roomID = roomCreate.room_id
|
||||
|
||||
// Put the newly created child into the space
|
||||
await _syncSpaceMember(channel, spaceID, roomID, guild.id)
|
||||
|
|
@ -261,30 +246,26 @@ async function createRoom(channel, guild, spaceID, kstate, privacyLevel) {
|
|||
* https://github.com/matrix-org/synapse/blob/develop/synapse/handlers/room.py#L1170-L1210
|
||||
* https://github.com/matrix-org/matrix-spec/issues/492
|
||||
* @param {any} kstate
|
||||
* @param {(_: any) => Promise<Ty.Event.StateOuter<Ty.Event.M_Room_Create>>} callback must return room ID and room version
|
||||
* @returns {Promise<Ty.Event.StateOuter<Ty.Event.M_Room_Create>>} room ID
|
||||
* @param {(_: any) => Promise<string>} callback must return room ID
|
||||
* @returns {Promise<string>} room ID
|
||||
*/
|
||||
async function postApplyPowerLevels(kstate, callback) {
|
||||
const powerLevelContent = kstate["m.room.power_levels/"]
|
||||
const kstateWithoutPowerLevels = {...kstate}
|
||||
delete kstateWithoutPowerLevels["m.room.power_levels/"]
|
||||
delete kstateWithoutPowerLevels["chat.schildi.hide_ui/read_receipts"]
|
||||
|
||||
const roomCreate = await callback(kstateWithoutPowerLevels)
|
||||
const roomID = roomCreate.room_id
|
||||
/** @type {string} */
|
||||
const roomID = await callback(kstateWithoutPowerLevels)
|
||||
|
||||
// Now *really* apply the power level overrides on top of what Synapse *really* set
|
||||
if (powerLevelContent) {
|
||||
mUtils.removeCreatorsFromPowerLevels(roomCreate, powerLevelContent)
|
||||
|
||||
const originalPowerLevels = await api.getStateEvent(roomID, "m.room.power_levels", "")
|
||||
const powerLevelsDiff = ks.diffKState(
|
||||
{"m.room.power_levels/": originalPowerLevels, "m.room.create/": roomCreate.content, "m.room.create/outer": roomCreate},
|
||||
{"m.room.power_levels/": powerLevelContent}
|
||||
)
|
||||
await ks.applyKStateDiffToRoom(roomID, powerLevelsDiff)
|
||||
const newRoomKState = await ks.roomToKState(roomID)
|
||||
const newRoomPowerLevelsDiff = ks.diffKState(newRoomKState, {"m.room.power_levels/": powerLevelContent})
|
||||
await ks.applyKStateDiffToRoom(roomID, newRoomPowerLevelsDiff)
|
||||
}
|
||||
|
||||
return roomCreate
|
||||
return roomID
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -377,7 +358,7 @@ function assertExistsOrAutocreatable(channel, guildID) {
|
|||
* @returns {Promise<string>} room ID
|
||||
*/
|
||||
async function _syncRoom(channelID, shouldActuallySync) {
|
||||
/** @ts-ignore @type {DiscordTypes.APIGuildTextChannel} */
|
||||
/** @ts-ignore @type {DiscordTypes.APIGuildChannel} */
|
||||
const channel = discord.channels.get(channelID)
|
||||
assert.ok(channel)
|
||||
const guild = channelToGuild(channel)
|
||||
|
|
@ -408,19 +389,18 @@ async function _syncRoom(channelID, shouldActuallySync) {
|
|||
console.log(`[room sync] to matrix: ${channel.name}`)
|
||||
|
||||
const {spaceID, channelKState} = await channelToKState(channel, guild, {api}) // calling this in both branches because we don't want to calculate this if not syncing
|
||||
await ks.kstateUploadMxc(channelKState) // pre-upload icons before diffing
|
||||
|
||||
// sync channel state to room
|
||||
const roomKState = await ks.roomToKState(roomID)
|
||||
if (!mUtils.roomHasAtLeastVersion(roomKState["m.room.create/"].room_version, 9)) {
|
||||
// join_rule `restricted` is not available in room version < 8 and not working properly in version == 8, so require version 9
|
||||
if (+roomKState["m.room.create/"].room_version <= 8) {
|
||||
// join_rule `restricted` is not available in room version < 8 and not working properly in version == 8
|
||||
// read more: https://spec.matrix.org/v1.8/rooms/v9/
|
||||
// we have to use `public` instead, otherwise the room will be unjoinable.
|
||||
channelKState["m.room.join_rules/"] = {join_rule: "public"}
|
||||
}
|
||||
const roomDiff = ks.diffKState(roomKState, channelKState)
|
||||
const roomApply = ks.applyKStateDiffToRoom(roomID, roomDiff)
|
||||
db.prepare("UPDATE channel_room SET name = ? WHERE channel_id = ?").run(channel.name, channel.id)
|
||||
db.prepare("UPDATE channel_room SET name = ? WHERE room_id = ?").run(channel.name, roomID)
|
||||
|
||||
// sync room as space member
|
||||
const spaceApply = _syncSpaceMember(channel, spaceID, roomID, guild.id)
|
||||
|
|
@ -439,14 +419,22 @@ function syncRoom(channelID) {
|
|||
return _syncRoom(channelID, true)
|
||||
}
|
||||
|
||||
async function unbridgeChannel(channelID) {
|
||||
/** @ts-ignore @type {DiscordTypes.APIGuildChannel} */
|
||||
const channel = discord.channels.get(channelID)
|
||||
assert.ok(channel)
|
||||
assert.ok(channel.guild_id)
|
||||
return unbridgeDeletedChannel(channel, channel.guild_id)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{id: string, topic?: string?}} channel channel-ish (just needs an id, topic is optional)
|
||||
* @param {string} guildID
|
||||
*/
|
||||
async function unbridgeChannel(channel, guildID) {
|
||||
async function unbridgeDeletedChannel(channel, guildID) {
|
||||
const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get()
|
||||
assert.ok(roomID)
|
||||
const row = from("guild_space").join("guild_active", "guild_id").select("space_id", "autocreate").where({guild_id: guildID}).get()
|
||||
const row = from("guild_space").join("guild_active", "guild_id").select("space_id", "autocreate").get()
|
||||
assert.ok(row)
|
||||
|
||||
let botInRoom = true
|
||||
|
|
@ -470,23 +458,25 @@ async function unbridgeChannel(channel, guildID) {
|
|||
// delete webhook on discord
|
||||
const webhook = select("webhook", ["webhook_id", "webhook_token"], {channel_id: channel.id}).get()
|
||||
if (webhook) {
|
||||
await discord.snow.webhook.deleteWebhook(webhook.webhook_id, webhook.webhook_token).catch(() => {})
|
||||
await discord.snow.webhook.deleteWebhook(webhook.webhook_id, webhook.webhook_token)
|
||||
db.prepare("DELETE FROM webhook WHERE channel_id = ?").run(channel.id)
|
||||
}
|
||||
|
||||
// delete room from database
|
||||
db.prepare("DELETE FROM member_cache WHERE room_id = ?").run(roomID)
|
||||
db.prepare("DELETE FROM channel_room WHERE room_id = ? AND channel_id = ?").run(roomID, channel.id) // cascades to most other tables, like messages and historical rooms
|
||||
db.prepare("DELETE FROM channel_room WHERE room_id = ? AND channel_id = ?").run(roomID, channel.id) // cascades to most other tables, like messages
|
||||
|
||||
if (!botInRoom) return
|
||||
|
||||
// demote discord sim admins in room
|
||||
const {powerLevels, allCreators} = await mUtils.getEffectivePower(roomID, [], api)
|
||||
const powerLevelsUsers = (powerLevels.users ||= {})
|
||||
for (const mxid of Object.keys(powerLevelsUsers)) {
|
||||
if (powerLevelsUsers[mxid] >= (powerLevels.state_default ?? 50) && !allCreators.includes(mxid) && mUtils.eventSenderIsFromDiscord(mxid) && mxid !== mUtils.bot) {
|
||||
delete powerLevelsUsers[mxid]
|
||||
await api.sendState(roomID, "m.room.power_levels", "", powerLevels, mxid) // done individually because each user must demote themselves
|
||||
// demote admins in room
|
||||
/** @type {Ty.Event.M_Power_Levels} */
|
||||
const powerLevelContent = await api.getStateEvent(roomID, "m.room.power_levels", "")
|
||||
powerLevelContent.users ??= {}
|
||||
const bot = `@${reg.sender_localpart}:${reg.ooye.server_name}`
|
||||
for (const mxid of Object.keys(powerLevelContent.users)) {
|
||||
if (powerLevelContent.users[mxid] >= 100 && mUtils.eventSenderIsFromDiscord(mxid) && mxid !== bot) {
|
||||
delete powerLevelContent.users[mxid]
|
||||
await api.sendState(roomID, "m.room.power_levels", "", powerLevelContent, mxid)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -508,7 +498,7 @@ async function unbridgeChannel(channel, guildID) {
|
|||
// (the room can be used with less clutter and the member list makes sense if it's bridged somewhere else)
|
||||
if (row.autocreate === 0) {
|
||||
// remove sim members
|
||||
const members = db.prepare("SELECT mxid FROM sim_member WHERE room_id = ? AND mxid <> ?").pluck().all(roomID, mUtils.bot)
|
||||
const members = db.prepare("SELECT mxid FROM sim_member WHERE room_id = ? AND mxid <> ?").pluck().all(roomID, bot)
|
||||
const preparedDelete = db.prepare("DELETE FROM sim_member WHERE room_id = ? AND mxid = ?")
|
||||
for (const mxid of members) {
|
||||
await api.leaveRoom(roomID, mxid)
|
||||
|
|
@ -517,7 +507,6 @@ async function unbridgeChannel(channel, guildID) {
|
|||
}
|
||||
|
||||
// leave room
|
||||
await mUtils.setUserPower(roomID, mUtils.bot, 0, api)
|
||||
await api.leaveRoom(roomID)
|
||||
}
|
||||
|
||||
|
|
@ -579,7 +568,7 @@ module.exports.createAllForGuild = createAllForGuild
|
|||
module.exports.channelToKState = channelToKState
|
||||
module.exports.postApplyPowerLevels = postApplyPowerLevels
|
||||
module.exports._convertNameAndTopic = convertNameAndTopic
|
||||
module.exports._syncSpaceMember = _syncSpaceMember
|
||||
module.exports.unbridgeChannel = unbridgeChannel
|
||||
module.exports.unbridgeDeletedChannel = unbridgeDeletedChannel
|
||||
module.exports.existsOrAutocreatable = existsOrAutocreatable
|
||||
module.exports.assertExistsOrAutocreatable = assertExistsOrAutocreatable
|
||||
|
|
|
|||
|
|
@ -9,44 +9,19 @@ const testData = require("../../../test/data")
|
|||
const passthrough = require("../../passthrough")
|
||||
const {db} = passthrough
|
||||
|
||||
function mockAPI(t) {
|
||||
let called = 0
|
||||
return {
|
||||
getCalled() {
|
||||
return called
|
||||
},
|
||||
async getStateEvent(roomID, type, key) { // getting power levels from space to apply to room
|
||||
called++
|
||||
t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe")
|
||||
t.equal(type, "m.room.power_levels")
|
||||
t.equal(key, "")
|
||||
return {users: {"@example:matrix.org": 50}, events: {"m.room.tombstone": 100}}
|
||||
},
|
||||
async getStateEventOuter(roomID, type, key) {
|
||||
called++
|
||||
t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe")
|
||||
t.equal(type, "m.room.create")
|
||||
t.equal(key, "")
|
||||
return {
|
||||
type: "m.room.create",
|
||||
state_key: "",
|
||||
content: {
|
||||
room_version: "11"
|
||||
},
|
||||
event_id: "$create",
|
||||
origin_server_ts: 0,
|
||||
room_id: "!jjmvBegULiLucuWEHU:cadence.moe",
|
||||
sender: "@_ooye_bot:cadence.moe"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("channel2room: discoverable privacy room", async t => {
|
||||
const api = mockAPI(t)
|
||||
let called = 0
|
||||
async function getStateEvent(roomID, type, key) { // getting power levels from space to apply to room
|
||||
called++
|
||||
t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe")
|
||||
t.equal(type, "m.room.power_levels")
|
||||
t.equal(key, "")
|
||||
return {users: {"@example:matrix.org": 50}}
|
||||
}
|
||||
db.prepare("UPDATE guild_space SET privacy_level = 2").run()
|
||||
t.deepEqual(
|
||||
kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general, {api}).then(x => x.channelKState)),
|
||||
kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general, {api: {getStateEvent}}).then(x => x.channelKState)),
|
||||
Object.assign({}, testData.room.general, {
|
||||
"m.room.guest_access/": {guest_access: "forbidden"},
|
||||
"m.room.join_rules/": {join_rule: "public"},
|
||||
|
|
@ -54,37 +29,58 @@ test("channel2room: discoverable privacy room", async t => {
|
|||
"m.room.power_levels/": mixin({users: {"@example:matrix.org": 50}}, testData.room.general["m.room.power_levels/"])
|
||||
})
|
||||
)
|
||||
t.equal(api.getCalled(), 2)
|
||||
t.equal(called, 1)
|
||||
})
|
||||
|
||||
test("channel2room: linkable privacy room", async t => {
|
||||
const api = mockAPI(t)
|
||||
let called = 0
|
||||
async function getStateEvent(roomID, type, key) { // getting power levels from space to apply to room
|
||||
called++
|
||||
t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe")
|
||||
t.equal(type, "m.room.power_levels")
|
||||
t.equal(key, "")
|
||||
return {users: {"@example:matrix.org": 50}}
|
||||
}
|
||||
db.prepare("UPDATE guild_space SET privacy_level = 1").run()
|
||||
t.deepEqual(
|
||||
kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general, {api}).then(x => x.channelKState)),
|
||||
kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general, {api: {getStateEvent}}).then(x => x.channelKState)),
|
||||
Object.assign({}, testData.room.general, {
|
||||
"m.room.guest_access/": {guest_access: "forbidden"},
|
||||
"m.room.join_rules/": {join_rule: "public"},
|
||||
"m.room.power_levels/": mixin({users: {"@example:matrix.org": 50}}, testData.room.general["m.room.power_levels/"])
|
||||
})
|
||||
)
|
||||
t.equal(api.getCalled(), 2)
|
||||
t.equal(called, 1)
|
||||
})
|
||||
|
||||
test("channel2room: invite-only privacy room", async t => {
|
||||
const api = mockAPI(t)
|
||||
let called = 0
|
||||
async function getStateEvent(roomID, type, key) { // getting power levels from space to apply to room
|
||||
called++
|
||||
t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe")
|
||||
t.equal(type, "m.room.power_levels")
|
||||
t.equal(key, "")
|
||||
return {users: {"@example:matrix.org": 50}}
|
||||
}
|
||||
db.prepare("UPDATE guild_space SET privacy_level = 0").run()
|
||||
t.deepEqual(
|
||||
kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general, {api}).then(x => x.channelKState)),
|
||||
kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general, {api: {getStateEvent}}).then(x => x.channelKState)),
|
||||
Object.assign({}, testData.room.general, {
|
||||
"m.room.power_levels/": mixin({users: {"@example:matrix.org": 50}}, testData.room.general["m.room.power_levels/"])
|
||||
})
|
||||
)
|
||||
t.equal(api.getCalled(), 2)
|
||||
t.equal(called, 1)
|
||||
})
|
||||
|
||||
test("channel2room: room where limited people can mention everyone", async t => {
|
||||
const api = mockAPI(t)
|
||||
let called = 0
|
||||
async function getStateEvent(roomID, type, key) { // getting power levels from space to apply to room
|
||||
called++
|
||||
t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe")
|
||||
t.equal(type, "m.room.power_levels")
|
||||
t.equal(key, "")
|
||||
return {users: {"@example:matrix.org": 50}}
|
||||
}
|
||||
const limitedGuild = mixin({}, testData.guild.general)
|
||||
limitedGuild.roles[0].permissions = (BigInt(limitedGuild.roles[0].permissions) - 131072n).toString()
|
||||
const limitedRoom = mixin({}, testData.room.general, {"m.room.power_levels/": {
|
||||
|
|
@ -92,31 +88,43 @@ test("channel2room: room where limited people can mention everyone", async t =>
|
|||
users: {"@example:matrix.org": 50}
|
||||
}})
|
||||
t.deepEqual(
|
||||
kstateStripConditionals(await channelToKState(testData.channel.general, limitedGuild, {api}).then(x => x.channelKState)),
|
||||
kstateStripConditionals(await channelToKState(testData.channel.general, limitedGuild, {api: {getStateEvent}}).then(x => x.channelKState)),
|
||||
limitedRoom
|
||||
)
|
||||
t.equal(api.getCalled(), 2)
|
||||
t.equal(called, 1)
|
||||
})
|
||||
|
||||
test("channel2room: matrix room that already has a custom topic set", async t => {
|
||||
const api = mockAPI(t)
|
||||
let called = 0
|
||||
async function getStateEvent(roomID, type, key) { // getting power levels from space to apply to room
|
||||
called++
|
||||
t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe")
|
||||
t.equal(type, "m.room.power_levels")
|
||||
t.equal(key, "")
|
||||
return {}
|
||||
}
|
||||
db.prepare("UPDATE channel_room SET custom_topic = 1 WHERE channel_id = ?").run(testData.channel.general.id)
|
||||
const expected = mixin({}, testData.room.general, {"m.room.power_levels/": {notifications: {room: 20}, users: {"@example:matrix.org": 50}}})
|
||||
const expected = mixin({}, testData.room.general, {"m.room.power_levels/": {notifications: {room: 20}}})
|
||||
// @ts-ignore
|
||||
delete expected["m.room.topic/"]
|
||||
t.deepEqual(
|
||||
kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general, {api}).then(x => x.channelKState)),
|
||||
kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general, {api: {getStateEvent}}).then(x => x.channelKState)),
|
||||
expected
|
||||
)
|
||||
t.equal(api.getCalled(), 2)
|
||||
t.equal(called, 1)
|
||||
})
|
||||
|
||||
test("channel2room: read-only discord channel", async t => {
|
||||
const api = mockAPI(t)
|
||||
let called = 0
|
||||
async function getStateEvent(roomID, type, key) { // getting power levels from space to apply to room
|
||||
called++
|
||||
t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe")
|
||||
t.equal(type, "m.room.power_levels")
|
||||
t.equal(key, "")
|
||||
return {}
|
||||
}
|
||||
const expected = {
|
||||
"m.room.create/": {
|
||||
additional_creators: ["@test_auto_invite:example.org"],
|
||||
},
|
||||
"chat.schildi.hide_ui/read_receipts": {},
|
||||
"m.room.avatar/": {
|
||||
url: {
|
||||
$url: "/icons/112760669178241024/a_f83622e09ead74f0c5c527fe241f8f8c.png?size=1024",
|
||||
|
|
@ -153,8 +161,7 @@ test("channel2room: read-only discord channel", async t => {
|
|||
room: 20,
|
||||
},
|
||||
users: {
|
||||
"@test_auto_invite:example.org": 150,
|
||||
"@example:matrix.org": 50
|
||||
"@test_auto_invite:example.org": 100,
|
||||
},
|
||||
},
|
||||
"m.space.parent/!jjmvBegULiLucuWEHU:cadence.moe": {
|
||||
|
|
@ -184,10 +191,10 @@ test("channel2room: read-only discord channel", async t => {
|
|||
}
|
||||
}
|
||||
t.deepEqual(
|
||||
kstateStripConditionals(await channelToKState(testData.channel.updates, testData.guild.general, {api}).then(x => x.channelKState)),
|
||||
kstateStripConditionals(await channelToKState(testData.channel.updates, testData.guild.general, {api: {getStateEvent}}).then(x => x.channelKState)),
|
||||
expected
|
||||
)
|
||||
t.equal(api.getCalled(), 2)
|
||||
t.equal(called, 1)
|
||||
})
|
||||
|
||||
test("convertNameAndTopic: custom name and topic", t => {
|
||||
|
|
|
|||
|
|
@ -35,8 +35,8 @@ async function createSpace(guild, kstate) {
|
|||
const enablePresenceByDefault = +(memberCount < 50) // scary! all active users in a presence-enabled guild will be pinging the server every <30 seconds to stay online
|
||||
const globalAdmins = select("member_power", "mxid", {room_id: "*"}).pluck().all()
|
||||
|
||||
const roomCreate = await createRoom.postApplyPowerLevels(kstate, async kstate => {
|
||||
const roomID = await api.createRoom({
|
||||
const roomID = await createRoom.postApplyPowerLevels(kstate, async kstate => {
|
||||
return api.createRoom({
|
||||
name,
|
||||
preset: createRoom.PRIVACY_ENUMS.PRESET[createRoom.DEFAULT_PRIVACY_LEVEL], // New spaces will have to use the default privacy level; we obviously can't look up the existing entry
|
||||
visibility: createRoom.PRIVACY_ENUMS.VISIBILITY[createRoom.DEFAULT_PRIVACY_LEVEL],
|
||||
|
|
@ -46,14 +46,12 @@ async function createSpace(guild, kstate) {
|
|||
},
|
||||
invite: globalAdmins,
|
||||
topic,
|
||||
initial_state: await ks.kstateToState(kstate),
|
||||
creation_content: ks.kstateToCreationContent(kstate)
|
||||
creation_content: {
|
||||
type: "m.space"
|
||||
},
|
||||
initial_state: await ks.kstateToState(kstate)
|
||||
})
|
||||
const roomCreate = await api.getStateEventOuter(roomID, "m.room.create", "")
|
||||
return roomCreate
|
||||
})
|
||||
const roomID = roomCreate.room_id
|
||||
|
||||
db.prepare("INSERT INTO guild_space (guild_id, space_id, presence) VALUES (?, ?, ?)").run(guild.id, roomID, enablePresenceByDefault)
|
||||
return roomID
|
||||
}
|
||||
|
|
@ -65,13 +63,7 @@ async function createSpace(guild, kstate) {
|
|||
async function guildToKState(guild, privacyLevel) {
|
||||
assert.equal(typeof privacyLevel, "number")
|
||||
const globalAdmins = select("member_power", ["mxid", "power_level"], {room_id: "*"}).all()
|
||||
const additionalCreators = select("member_power", "mxid", {room_id: "*"}, "AND power_level > 100").pluck().all()
|
||||
|
||||
const guildKState = {
|
||||
"m.room.create/": {
|
||||
type: "m.space",
|
||||
additional_creators: additionalCreators
|
||||
},
|
||||
"m.room.name/": {name: guild.name},
|
||||
"m.room.avatar/": {
|
||||
$if: guild.icon,
|
||||
|
|
@ -124,8 +116,6 @@ async function _syncSpace(guild, shouldActuallySync) {
|
|||
console.log(`[space sync] to matrix: ${guild.name}`)
|
||||
|
||||
const guildKState = await guildToKState(guild, privacy_level) // calling this in both branches because we don't want to calculate this if not syncing
|
||||
ks.kstateStripConditionals(guildKState) // pre-upload icons before diffing
|
||||
await ks.kstateUploadMxc(guildKState)
|
||||
|
||||
// sync guild state to space
|
||||
const spaceKState = await ks.roomToKState(spaceID)
|
||||
|
|
@ -187,8 +177,6 @@ async function syncSpaceFully(guildID) {
|
|||
console.log(`[space sync] to matrix: ${guild.name}`)
|
||||
|
||||
const guildKState = await guildToKState(guild, privacy_level)
|
||||
ks.kstateStripConditionals(guildKState) // pre-upload icons before diffing
|
||||
await ks.kstateUploadMxc(guildKState)
|
||||
|
||||
// sync guild state to space
|
||||
const spaceKState = await ks.roomToKState(spaceID)
|
||||
|
|
@ -203,7 +191,7 @@ async function syncSpaceFully(guildID) {
|
|||
if (discord.channels.has(channelID)) {
|
||||
await createRoom.syncRoom(channelID)
|
||||
} else {
|
||||
await createRoom.unbridgeChannel({id: channelID}, guildID)
|
||||
await createRoom.unbridgeDeletedChannel({id: channelID}, guildID)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -229,16 +217,14 @@ async function syncSpaceExpressions(data, checkBeforeSync) {
|
|||
*/
|
||||
async function update(spaceID, key, eventKey, fn) {
|
||||
if (!(key in data) || !data[key].length) return
|
||||
const guild = discord.guilds.get(data.guild_id)
|
||||
assert(guild)
|
||||
const content = await fn(data[key], guild)
|
||||
const content = await fn(data[key])
|
||||
if (checkBeforeSync) {
|
||||
let existing
|
||||
try {
|
||||
existing = await api.getStateEvent(spaceID, "im.ponies.room_emotes", eventKey)
|
||||
} catch (e) {
|
||||
// State event not found. This space doesn't have any existing emojis. We create a dummy empty event for comparison's sake.
|
||||
existing = fn([], guild)
|
||||
existing = fn([])
|
||||
}
|
||||
if (isDeepStrictEqual(existing, content)) return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,10 +13,6 @@ test("guild2space: can generate kstate for a guild, passing privacy level 0", as
|
|||
t.deepEqual(
|
||||
await kstateUploadMxc(kstateStripConditionals(await guildToKState(testData.guild.general, 0))),
|
||||
{
|
||||
"m.room.create/": {
|
||||
additional_creators: ["@test_auto_invite:example.org"],
|
||||
type: "m.space"
|
||||
},
|
||||
"m.room.avatar/": {
|
||||
url: "mxc://cadence.moe/zKXGZhmImMHuGQZWJEFKJbsF"
|
||||
},
|
||||
|
|
@ -34,7 +30,7 @@ test("guild2space: can generate kstate for a guild, passing privacy level 0", as
|
|||
},
|
||||
"m.room.power_levels/": {
|
||||
users: {
|
||||
"@test_auto_invite:example.org": 150
|
||||
"@test_auto_invite:example.org": 100
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,13 +14,11 @@ async function deleteMessage(data) {
|
|||
const row = select("channel_room", ["room_id", "speedbump_checked", "thread_parent"], {channel_id: data.channel_id}).get()
|
||||
if (!row) return
|
||||
|
||||
// Assume we can redact from tombstoned rooms.
|
||||
const eventsToRedact = from("event_message").join("message_room", "message_id").join("historical_channel_room", "historical_room_index")
|
||||
.select("event_id", "room_id").where({message_id: data.id}).all()
|
||||
db.prepare("DELETE FROM message_room WHERE message_id = ?").run(data.id)
|
||||
for (const {event_id, room_id} of eventsToRedact) {
|
||||
const eventsToRedact = select("event_message", "event_id", {message_id: data.id}).pluck().all()
|
||||
db.prepare("DELETE FROM message_channel WHERE message_id = ?").run(data.id)
|
||||
for (const eventID of eventsToRedact) {
|
||||
// Unfortunately, we can't specify a sender to do the redaction as, unless we find out that info via the audit logs
|
||||
await api.redactEvent(room_id, event_id)
|
||||
await api.redactEvent(row.room_id, eventID)
|
||||
}
|
||||
|
||||
await speedbump.updateCache(row.thread_parent || data.channel_id, row.speedbump_checked)
|
||||
|
|
@ -30,17 +28,15 @@ async function deleteMessage(data) {
|
|||
* @param {import("discord-api-types/v10").GatewayMessageDeleteBulkDispatchData} data
|
||||
*/
|
||||
async function deleteMessageBulk(data) {
|
||||
const row = select("channel_room", "room_id", {channel_id: data.channel_id}).get()
|
||||
if (!row) return
|
||||
const roomID = select("channel_room", "room_id", {channel_id: data.channel_id}).pluck().get()
|
||||
if (!roomID) return
|
||||
|
||||
const sids = JSON.stringify(data.ids)
|
||||
// Assume we can redact from tombstoned rooms.
|
||||
const eventsToRedact = from("event_message").join("message_room", "message_id").join("historical_channel_room", "historical_room_index")
|
||||
.select("event_id", "room_id").and("WHERE message_id IN (SELECT value FROM json_each(?))").all(sids)
|
||||
db.prepare("DELETE FROM message_room WHERE message_id IN (SELECT value FROM json_each(?))").run(sids)
|
||||
for (const {event_id, room_id} of eventsToRedact) {
|
||||
const eventsToRedact = from("event_message").pluck("event_id").and("WHERE message_id IN (SELECT value FROM json_each(?))").all(sids)
|
||||
db.prepare("DELETE FROM message_channel WHERE message_id IN (SELECT value FROM json_each(?))").run(sids)
|
||||
for (const eventID of eventsToRedact) {
|
||||
// Awaiting will make it go slower, but since this could be a long-running operation either way, we want to leave rate limit capacity for other operations
|
||||
await api.redactEvent(room_id, event_id)
|
||||
await api.redactEvent(roomID, eventID)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,15 +3,13 @@
|
|||
const assert = require("assert").strict
|
||||
|
||||
const passthrough = require("../../passthrough")
|
||||
const {sync, db, select, from} = passthrough
|
||||
const {sync, db, select} = passthrough
|
||||
/** @type {import("../converters/edit-to-changes")} */
|
||||
const editToChanges = sync.require("../converters/edit-to-changes")
|
||||
/** @type {import("./register-pk-user")} */
|
||||
const registerPkUser = sync.require("./register-pk-user")
|
||||
/** @type {import("../../matrix/api")} */
|
||||
const api = sync.require("../../matrix/api")
|
||||
/** @type {import("../../matrix/mreq")} */
|
||||
const mreq = sync.require("../../matrix/mreq")
|
||||
|
||||
/**
|
||||
* @param {import("discord-api-types/v10").GatewayMessageCreateDispatchData} message
|
||||
|
|
@ -19,18 +17,12 @@ const mreq = sync.require("../../matrix/mreq")
|
|||
* @param {{speedbump_id: string, speedbump_webhook_id: string} | null} row data about the webhook which is proxying messages in this channel
|
||||
*/
|
||||
async function editMessage(message, guild, row) {
|
||||
const historicalRoomOfMessage = from("message_room").join("historical_channel_room", "historical_room_index").where({message_id: message.id}).select("room_id").get()
|
||||
const currentRoom = from("channel_room").join("historical_channel_room", "room_id").where({channel_id: message.channel_id}).select("room_id", "historical_room_index").get()
|
||||
if (!currentRoom) return
|
||||
|
||||
if (historicalRoomOfMessage && historicalRoomOfMessage.room_id !== currentRoom.room_id) return // tombstoned rooms should not have new events (including edits) sent to them
|
||||
|
||||
let {roomID, eventsToRedact, eventsToReplace, eventsToSend, senderMxid, promotions} = await editToChanges.editToChanges(message, guild, api)
|
||||
|
||||
if (row && row.speedbump_webhook_id === message.webhook_id) {
|
||||
// Handle the PluralKit public instance
|
||||
if (row.speedbump_id === "466378653216014359") {
|
||||
senderMxid = await registerPkUser.syncUser(message.id, message.author, roomID, true)
|
||||
senderMxid = await registerPkUser.syncUser(message.id, message.author, roomID, false)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -67,7 +59,7 @@ async function editMessage(message, guild, row) {
|
|||
|
||||
// 4. Send all the things.
|
||||
if (eventsToSend.length) {
|
||||
db.prepare("INSERT OR IGNORE INTO message_room (message_id, historical_room_index) VALUES (?, ?)").run(message.id, currentRoom.historical_room_index)
|
||||
db.prepare("INSERT OR IGNORE INTO message_channel (message_id, channel_id) VALUES (?, ?)").run(message.id, message.channel_id)
|
||||
}
|
||||
for (const content of eventsToSend) {
|
||||
const eventType = content.$type
|
||||
|
|
@ -78,17 +70,8 @@ async function editMessage(message, guild, row) {
|
|||
|
||||
const part = sendNewEventParts.has("part") && eventsToSend[0] === content ? 0 : 1
|
||||
const reactionPart = sendNewEventParts.has("reaction_part") && eventsToSend[eventsToSend.length - 1] === content ? 0 : 1
|
||||
|
||||
try {
|
||||
const eventID = await api.sendEvent(roomID, eventType, contentWithoutType, senderMxid)
|
||||
db.prepare("INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part, reaction_part, source) VALUES (?, ?, ?, ?, ?, ?, 1)").run(eventID, eventType, content.msgtype || null, message.id, part, reactionPart) // source 1 = discord
|
||||
} catch (e) {
|
||||
if (e instanceof mreq.MatrixServerError && e.errcode === "M_FORBIDDEN") {
|
||||
// sending user doesn't have permission to update message, e.g. because Discord generated an embed in a read-only room
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
const eventID = await api.sendEvent(roomID, eventType, contentWithoutType, senderMxid)
|
||||
db.prepare("INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part, reaction_part, source) VALUES (?, ?, ?, ?, ?, ?, 1)").run(eventID, eventType, content.msgtype || null, message.id, part, reactionPart) // source 1 = discord
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,12 +9,11 @@ const file = sync.require("../../matrix/file")
|
|||
|
||||
/**
|
||||
* @param {DiscordTypes.APIEmoji[]} emojis
|
||||
* @param {DiscordTypes.APIGuild} guild
|
||||
*/
|
||||
async function emojisToState(emojis, guild) {
|
||||
async function emojisToState(emojis) {
|
||||
const result = {
|
||||
pack: {
|
||||
display_name: `${guild.name} (Discord Emojis)`,
|
||||
display_name: "Discord Emojis",
|
||||
usage: ["emoticon"] // we'll see...
|
||||
},
|
||||
images: {
|
||||
|
|
@ -25,7 +24,7 @@ async function emojisToState(emojis, guild) {
|
|||
file.uploadDiscordFileToMxc(file.emoji(emoji.id, emoji.animated)).then(url => {
|
||||
result.images[emoji.name] = {
|
||||
info: {
|
||||
mimetype: "image/webp"
|
||||
mimetype: emoji.animated ? "image/gif" : "image/png"
|
||||
},
|
||||
url
|
||||
}
|
||||
|
|
@ -43,12 +42,11 @@ async function emojisToState(emojis, guild) {
|
|||
|
||||
/**
|
||||
* @param {DiscordTypes.APISticker[]} stickers
|
||||
* @param {DiscordTypes.APIGuild} guild
|
||||
*/
|
||||
async function stickersToState(stickers, guild) {
|
||||
async function stickersToState(stickers) {
|
||||
const result = {
|
||||
pack: {
|
||||
display_name: `${guild.name} (Discord Stickers)`,
|
||||
display_name: "Discord Stickers",
|
||||
usage: ["sticker"] // we'll see...
|
||||
},
|
||||
images: {
|
||||
|
|
|
|||
|
|
@ -1,122 +0,0 @@
|
|||
// @ts-check
|
||||
|
||||
const assert = require("assert").strict
|
||||
const DiscordTypes = require("discord-api-types/v10")
|
||||
const {isDeepStrictEqual} = require("util")
|
||||
|
||||
const passthrough = require("../../passthrough")
|
||||
const {discord, sync, db, select, from} = passthrough
|
||||
const {reg} = require("../../matrix/read-registration")
|
||||
/** @type {import("./poll-vote")} */
|
||||
const vote = sync.require("../actions/poll-vote")
|
||||
/** @type {import("../../discord/interactions/poll-responses")} */
|
||||
const pollResponses = sync.require("../../discord/interactions/poll-responses")
|
||||
|
||||
/**
|
||||
* @file This handles, in the following order:
|
||||
* * verifying Matrix-side votes are accurate for a poll originating on Discord, sending missed votes to Matrix if necessary
|
||||
* * sending a message to Discord if a vote in that poll has been cast on Matrix
|
||||
* This does *not* handle bridging of poll closures on Discord to Matrix; that takes place in converters/message-to-event.js.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {string} channelID
|
||||
* @param {string} messageID
|
||||
* @param {string} answerID
|
||||
* @returns {Promise<DiscordTypes.RESTGetAPIPollAnswerVotersResult["users"]>}
|
||||
*/
|
||||
async function getAllVotesOnAnswer(channelID, messageID, answerID) {
|
||||
const limit = 100
|
||||
/** @type {DiscordTypes.RESTGetAPIPollAnswerVotersResult["users"]} */
|
||||
let voteUsers = []
|
||||
let after = undefined
|
||||
while (true) {
|
||||
const curVotes = await discord.snow.channel.getPollAnswerVoters(channelID, messageID, answerID, {after: after, limit})
|
||||
voteUsers = voteUsers.concat(curVotes.users)
|
||||
if (curVotes.users.length >= limit) { // Loop again for the next page.
|
||||
// @ts-ignore - stupid
|
||||
after = curVotes.users.at(-1).id
|
||||
} else { // Reached the end.
|
||||
return voteUsers
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {typeof import("../../../test/data.js")["poll_close"]} closeMessage
|
||||
*/
|
||||
async function endPoll(closeMessage) {
|
||||
const pollCloseObject = closeMessage.embeds[0]
|
||||
|
||||
const pollMessageID = closeMessage.message_reference.message_id
|
||||
const pollEventID = select("event_message", "event_id", {message_id: pollMessageID, event_type: "org.matrix.msc3381.poll.start"}).pluck().get()
|
||||
if (!pollEventID) return // Nothing we can send Discord-side if we don't have the original poll. We will still send a results message Matrix-side.
|
||||
|
||||
const discordPollOptions = select("poll_option", "discord_option", {message_id: pollMessageID}).pluck().all()
|
||||
assert(discordPollOptions.every(x => typeof x === "string")) // This poll originated on Discord so it will have Discord option IDs
|
||||
|
||||
// If the closure came from Discord, we want to fetch all the votes there again and bridge over any that got lost to Matrix before posting the results.
|
||||
// Database reads are cheap, and API calls are expensive, so we will only query Discord when the totals don't match.
|
||||
|
||||
const totalVotes = +pollCloseObject.fields.find(element => element.name === "total_votes").value // We could do [2], but best not to rely on the ordering staying consistent.
|
||||
|
||||
const databaseVotes = select("poll_vote", ["discord_or_matrix_user_id", "matrix_option"], {message_id: pollMessageID}, " AND discord_or_matrix_user_id NOT LIKE '@%'").all()
|
||||
|
||||
if (databaseVotes.length !== totalVotes) { // Matching length should be sufficient for most cases.
|
||||
let voteUsers = [...new Set(databaseVotes.map(vote => vote.discord_or_matrix_user_id))] // Unique array of all users we have votes for in the database.
|
||||
|
||||
// Main design challenge here: we get the data by *answer*, but we need to send it to Matrix by *user*.
|
||||
|
||||
/** @type {{user: DiscordTypes.APIUser, matrixOptionVotes: string[]}[]} This will be our new array of answers */
|
||||
const updatedAnswers = []
|
||||
|
||||
for (const discordPollOption of discordPollOptions) {
|
||||
const optionUsers = await getAllVotesOnAnswer(closeMessage.channel_id, pollMessageID, discordPollOption) // Array of user IDs who voted for the option we're testing.
|
||||
for (const user of optionUsers) {
|
||||
const userLocation = updatedAnswers.findIndex(answer => answer.user.id === user.id)
|
||||
const matrixOption = select("poll_option", "matrix_option", {message_id: pollMessageID, discord_option: discordPollOption}).pluck().get()
|
||||
assert(matrixOption)
|
||||
if (userLocation === -1) { // We haven't seen this user yet, so we need to add them.
|
||||
updatedAnswers.push({user, matrixOptionVotes: [matrixOption]}) // toString as this is what we store and get from the database and send to Matrix.
|
||||
} else { // This user already voted for another option on the poll.
|
||||
updatedAnswers[userLocation].matrixOptionVotes.push(matrixOption)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for inconsistencies in what was cached in database vs final confirmed poll answers
|
||||
// If different, sync the final confirmed answers to Matrix-side to make it accurate there too
|
||||
|
||||
await Promise.all(updatedAnswers.map(async answer => {
|
||||
voteUsers = voteUsers.filter(item => item !== answer.user.id) // Remove any users we have updated answers for from voteUsers. The only remaining entries in this array will be users who voted, but then removed their votes before the poll ended.
|
||||
const cachedAnswers = select("poll_vote", "matrix_option", {discord_or_matrix_user_id: answer.user.id, message_id: pollMessageID}).pluck().all()
|
||||
if (!isDeepStrictEqual(new Set(cachedAnswers), new Set(answer.matrixOptionVotes))) {
|
||||
db.transaction(() => {
|
||||
db.prepare("DELETE FROM poll_vote WHERE discord_or_matrix_user_id = ? AND message_id = ?").run(answer.user.id, pollMessageID) // Delete existing stored votes.
|
||||
for (const matrixOption of answer.matrixOptionVotes) {
|
||||
db.prepare("INSERT INTO poll_vote (discord_or_matrix_user_id, message_id, matrix_option) VALUES (?, ?, ?)").run(answer.user.id, pollMessageID, matrixOption)
|
||||
}
|
||||
})()
|
||||
await vote.sendVotes(answer.user, closeMessage.channel_id, pollMessageID, pollEventID)
|
||||
}
|
||||
}))
|
||||
|
||||
await Promise.all(voteUsers.map(async user_id => { // Remove these votes.
|
||||
db.prepare("DELETE FROM poll_vote WHERE discord_or_matrix_user_id = ? AND message_id = ?").run(user_id, pollMessageID)
|
||||
await vote.sendVotes(user_id, closeMessage.channel_id, pollMessageID, pollEventID)
|
||||
}))
|
||||
}
|
||||
|
||||
const {combinedVotes, messageString} = pollResponses.getCombinedResults(pollMessageID, true)
|
||||
|
||||
if (combinedVotes !== totalVotes) { // This means some votes were cast on Matrix. Now that we've corrected the vote totals, we can get the results again and post them to Discord.
|
||||
return {
|
||||
username: "Total results including Matrix votes",
|
||||
avatar_url: `${reg.ooye.bridge_origin}/download/file/poll-star-avatar.png`,
|
||||
content: messageString,
|
||||
flags: DiscordTypes.MessageFlags.SuppressEmbeds
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports.endPoll = endPoll
|
||||
|
|
@ -1,100 +0,0 @@
|
|||
// @ts-check
|
||||
|
||||
const assert = require("assert").strict
|
||||
const DiscordTypes = require("discord-api-types/v10")
|
||||
const {Semaphore} = require("@chriscdn/promise-semaphore")
|
||||
const {scheduler} = require("timers/promises")
|
||||
|
||||
const passthrough = require("../../passthrough")
|
||||
const {discord, sync, db, select, from} = passthrough
|
||||
/** @type {import("../../matrix/api")} */
|
||||
const api = sync.require("../../matrix/api")
|
||||
/** @type {import("./register-user")} */
|
||||
const registerUser = sync.require("./register-user")
|
||||
|
||||
const inFlightPollSema = new Semaphore()
|
||||
|
||||
/**
|
||||
* @param {import("discord-api-types/v10").GatewayMessagePollVoteAddDispatch["d"]} data
|
||||
*/
|
||||
async function addVote(data) {
|
||||
const pollEventID = from("event_message").join("poll_option", "message_id").pluck("event_id").where({message_id: data.message_id, event_type: "org.matrix.msc3381.poll.start"}).get() // Currently Discord doesn't allow sending a poll with anything else, but we bridge it after all other content so reaction_part: 0 is the part that will have the poll.
|
||||
if (!pollEventID) return // Nothing can be done if the parent message was never bridged.
|
||||
|
||||
let realAnswer = select("poll_option", "matrix_option", {message_id: data.message_id, discord_option: data.answer_id.toString()}).pluck().get() // Discord answer IDs don't match those on Matrix-created polls.
|
||||
assert(realAnswer)
|
||||
db.prepare("INSERT OR IGNORE INTO poll_vote (discord_or_matrix_user_id, message_id, matrix_option) VALUES (?, ?, ?)").run(data.user_id, data.message_id, realAnswer)
|
||||
return debounceSendVotes(data, pollEventID)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("discord-api-types/v10").GatewayMessagePollVoteRemoveDispatch["d"]} data
|
||||
*/
|
||||
async function removeVote(data) {
|
||||
const pollEventID = from("event_message").join("poll_option", "message_id").pluck("event_id").where({message_id: data.message_id, event_type: "org.matrix.msc3381.poll.start"}).get()
|
||||
if (!pollEventID) return
|
||||
|
||||
let realAnswer = select("poll_option", "matrix_option", {message_id: data.message_id, discord_option: data.answer_id.toString()}).pluck().get() // Discord answer IDs don't match those on Matrix-created polls.
|
||||
assert(realAnswer)
|
||||
db.prepare("DELETE FROM poll_vote WHERE discord_or_matrix_user_id = ? AND message_id = ? AND matrix_option = ?").run(data.user_id, data.message_id, realAnswer)
|
||||
return debounceSendVotes(data, pollEventID)
|
||||
}
|
||||
|
||||
/**
|
||||
* Multiple-choice polls send all the votes at the same time. This debounces and sends the combined votes.
|
||||
* In the meantime, the combined votes are assembled in the `poll_vote` database table by the above functions.
|
||||
* @param {import("discord-api-types/v10").GatewayMessagePollVoteAddDispatch["d"]} data
|
||||
* @param {string} pollEventID
|
||||
* @return {Promise<string>} event ID of Matrix vote
|
||||
*/
|
||||
async function debounceSendVotes(data, pollEventID) {
|
||||
return await inFlightPollSema.request(async () => {
|
||||
await scheduler.wait(1000) // Wait for votes to be collected
|
||||
|
||||
const user = await discord.snow.user.getUser(data.user_id) // Gateway event doesn't give us the object, only the ID.
|
||||
return await sendVotes(user, data.channel_id, data.message_id, pollEventID)
|
||||
}, `${data.user_id}/${data.message_id}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DiscordTypes.APIUser | string} userOrID
|
||||
* @param {string} channelID
|
||||
* @param {string} pollMessageID
|
||||
* @param {string} pollEventID
|
||||
*/
|
||||
async function sendVotes(userOrID, channelID, pollMessageID, pollEventID) {
|
||||
const latestRoomID = select("channel_room", "room_id", {channel_id: channelID}).pluck().get()
|
||||
const matchingRoomID = from("message_room").join("historical_channel_room", "historical_room_index").where({message_id: pollMessageID}).pluck("room_id").get()
|
||||
if (!latestRoomID || latestRoomID !== matchingRoomID) { // room upgrade mid-poll??
|
||||
db.prepare("UPDATE poll SET is_closed = 1 WHERE message_id = ?").run(pollMessageID)
|
||||
return
|
||||
}
|
||||
|
||||
let userID, senderMxid
|
||||
if (typeof userOrID === "string") { // just a string when double-checking a vote removal - good thing the unvoter is already here from having voted
|
||||
userID = userOrID
|
||||
senderMxid = from("sim").join("sim_member", "mxid").where({user_id: userOrID, room_id: matchingRoomID}).pluck("mxid").get()
|
||||
if (!senderMxid) return
|
||||
} else { // sent in full when double-checking adding a vote, so we can properly ensure joined
|
||||
userID = userOrID.id
|
||||
senderMxid = await registerUser.ensureSimJoined(userOrID, matchingRoomID)
|
||||
}
|
||||
|
||||
const answersArray = select("poll_vote", "matrix_option", {discord_or_matrix_user_id: userID, message_id: pollMessageID}).pluck().all()
|
||||
const eventID = await api.sendEvent(matchingRoomID, "org.matrix.msc3381.poll.response", {
|
||||
"m.relates_to": {
|
||||
rel_type: "m.reference",
|
||||
event_id: pollEventID,
|
||||
},
|
||||
"org.matrix.msc3381.poll.response": {
|
||||
answers: answersArray
|
||||
}
|
||||
}, senderMxid)
|
||||
|
||||
return eventID
|
||||
}
|
||||
|
||||
module.exports.addVote = addVote
|
||||
module.exports.removeVote = removeVote
|
||||
module.exports.debounceSendVotes = debounceSendVotes
|
||||
module.exports.sendVotes = sendVotes
|
||||
|
|
@ -13,6 +13,13 @@ const file = sync.require("../../matrix/file")
|
|||
/** @type {import("./register-user")} */
|
||||
const registerUser = sync.require("./register-user")
|
||||
|
||||
/**
|
||||
* @typedef WebhookAuthor Discord API message->author. A webhook as an author.
|
||||
* @prop {string} username
|
||||
* @prop {string?} avatar
|
||||
* @prop {string} id
|
||||
*/
|
||||
|
||||
/** @returns {Promise<Ty.PkMessage>} */
|
||||
async function fetchMessage(messageID) {
|
||||
try {
|
||||
|
|
@ -22,7 +29,6 @@ async function fetchMessage(messageID) {
|
|||
throw new Error(`Failed to connect to PK API: ${networkError.toString()}`)
|
||||
}
|
||||
if (!res.ok) throw new Error(`PK API returned an error: ${await res.text()}`)
|
||||
/** @type {any} */
|
||||
const root = await res.json()
|
||||
if (!root.member) throw new Error(`PK API didn't return member data: ${JSON.stringify(root)}`)
|
||||
return root
|
||||
|
|
@ -87,8 +93,16 @@ async function ensureSimJoined(pkMessage, roomID) {
|
|||
// Ensure joined
|
||||
const existing = select("sim_member", "mxid", {room_id: roomID, mxid}).pluck().get()
|
||||
if (!existing) {
|
||||
await api.inviteToRoom(roomID, mxid)
|
||||
await api.joinRoom(roomID, mxid)
|
||||
try {
|
||||
await api.inviteToRoom(roomID, mxid)
|
||||
await api.joinRoom(roomID, mxid)
|
||||
} catch (e) {
|
||||
if (e.message.includes("is already in the room.")) {
|
||||
// Sweet!
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
db.prepare("INSERT OR IGNORE INTO sim_member (room_id, mxid) VALUES (?, ?)").run(roomID, mxid)
|
||||
}
|
||||
return mxid
|
||||
|
|
@ -97,7 +111,7 @@ async function ensureSimJoined(pkMessage, roomID) {
|
|||
/**
|
||||
* Generate profile data based on webhook displayname and configured avatar.
|
||||
* @param {Ty.PkMessage} pkMessage
|
||||
* @param {Ty.WebhookAuthor} author
|
||||
* @param {WebhookAuthor} author
|
||||
*/
|
||||
async function memberToStateContent(pkMessage, author) {
|
||||
// We prefer to use the member's avatar URL data since the image upload can be cached across channels,
|
||||
|
|
@ -123,7 +137,7 @@ async function memberToStateContent(pkMessage, author) {
|
|||
* 5. Compare against the previously known state content, which is helpfully stored in the database
|
||||
* 6. If the state content has changed, send it to Matrix and update it in the database for next time
|
||||
* @param {string} messageID to call API with
|
||||
* @param {Ty.WebhookAuthor} author for profile data
|
||||
* @param {WebhookAuthor} author for profile data
|
||||
* @param {string} roomID room to join member to
|
||||
* @param {boolean} shouldActuallySync whether to actually sync updated user data or just ensure it's joined
|
||||
* @returns {Promise<string>} mxid of the updated sim
|
||||
|
|
@ -144,9 +158,16 @@ async function syncUser(messageID, author, roomID, shouldActuallySync) {
|
|||
const mxid = await ensureSimJoined(pkMessage, roomID)
|
||||
|
||||
if (shouldActuallySync) {
|
||||
// Build current profile data and sync if the hash has changed
|
||||
// Build current profile data
|
||||
const content = await memberToStateContent(pkMessage, author)
|
||||
await registerUser._sendSyncUser(roomID, mxid, content, null)
|
||||
const currentHash = registerUser._hashProfileContent(content, 0)
|
||||
const existingHash = select("sim_member", "hashed_profile_content", {room_id: roomID, mxid}).safeIntegers().pluck().get()
|
||||
|
||||
// Only do the actual sync if the hash has changed since we last looked
|
||||
if (existingHash !== currentHash) {
|
||||
await api.sendState(roomID, "m.room.member", mxid, content, mxid)
|
||||
db.prepare("UPDATE sim_member SET hashed_profile_content = ? WHERE room_id = ? AND mxid = ?").run(currentHash, roomID, mxid)
|
||||
}
|
||||
}
|
||||
|
||||
return mxid
|
||||
|
|
|
|||
|
|
@ -4,17 +4,16 @@ const assert = require("assert").strict
|
|||
const {reg} = require("../../matrix/read-registration")
|
||||
const DiscordTypes = require("discord-api-types/v10")
|
||||
const Ty = require("../../types")
|
||||
const mixin = require("@cloudrac3r/mixin-deep")
|
||||
|
||||
const passthrough = require("../../passthrough")
|
||||
const {discord, sync, db, from, select} = passthrough
|
||||
const {discord, sync, db, select} = passthrough
|
||||
/** @type {import("../../matrix/api")} */
|
||||
const api = sync.require("../../matrix/api")
|
||||
/** @type {import("../../matrix/file")} */
|
||||
const file = sync.require("../../matrix/file")
|
||||
/** @type {import("../../discord/utils")} */
|
||||
const dUtils = sync.require("../../discord/utils")
|
||||
/** @type {import("../../matrix/utils")} */
|
||||
const mxUtils = sync.require("../../matrix/utils")
|
||||
const utils = sync.require("../../discord/utils")
|
||||
/** @type {import("../converters/user-to-mxid")} */
|
||||
const userToMxid = sync.require("../converters/user-to-mxid")
|
||||
/** @type {import("./create-room")} */
|
||||
|
|
@ -24,8 +23,6 @@ let hasher = null
|
|||
// @ts-ignore
|
||||
require("xxhash-wasm")().then(h => hasher = h)
|
||||
|
||||
const supportsMsc4069 = api.versions().then(v => !!v?.unstable_features?.["org.matrix.msc4069"]).catch(() => false)
|
||||
|
||||
/**
|
||||
* A sim is an account that is being simulated by the bridge to copy events from the other side.
|
||||
* @param {DiscordTypes.APIUser} user
|
||||
|
|
@ -86,30 +83,21 @@ async function ensureSimJoined(user, roomID) {
|
|||
// Ensure joined
|
||||
const existing = select("sim_member", "mxid", {room_id: roomID, mxid}).pluck().get()
|
||||
if (!existing) {
|
||||
await api.inviteToRoom(roomID, mxid)
|
||||
await api.joinRoom(roomID, mxid)
|
||||
try {
|
||||
await api.inviteToRoom(roomID, mxid)
|
||||
await api.joinRoom(roomID, mxid)
|
||||
} catch (e) {
|
||||
if (e.message.includes("is already in the room.")) {
|
||||
// Sweet!
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
db.prepare("INSERT OR IGNORE INTO sim_member (room_id, mxid) VALUES (?, ?)").run(roomID, mxid)
|
||||
}
|
||||
return mxid
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DiscordTypes.APIUser} user
|
||||
*/
|
||||
async function userToGlobalProfile(user) {
|
||||
const globalProfile = {}
|
||||
|
||||
globalProfile.displayname = user.username
|
||||
if (user.global_name) globalProfile.displayname = user.global_name
|
||||
|
||||
if (user.avatar) {
|
||||
const avatarPath = file.userAvatar(user) // the user avatar only
|
||||
globalProfile.avatar_url = await file.uploadDiscordFileToMxc(avatarPath)
|
||||
}
|
||||
|
||||
return globalProfile
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DiscordTypes.APIUser} user
|
||||
* @param {Omit<DiscordTypes.APIGuildMember, "user"> | undefined} member
|
||||
|
|
@ -153,8 +141,8 @@ async function memberToStateContent(user, member, guildID) {
|
|||
function memberToPowerLevel(user, member, guild, channel) {
|
||||
if (!member) return 0
|
||||
|
||||
const permissions = dUtils.getPermissions(guild.id, member.roles, guild.roles, user.id, channel.permission_overwrites)
|
||||
const everyonePermissions = dUtils.getPermissions(guild.id, [], guild.roles, undefined, channel.permission_overwrites)
|
||||
const permissions = utils.getPermissions(member.roles, guild.roles, user.id, channel.permission_overwrites)
|
||||
const everyonePermissions = utils.getPermissions([], guild.roles, undefined, channel.permission_overwrites)
|
||||
/*
|
||||
* PL 100 = Administrator = People who can brick the room. RATIONALE:
|
||||
* - Administrator.
|
||||
|
|
@ -163,7 +151,7 @@ function memberToPowerLevel(user, member, guild, channel) {
|
|||
* - Manage Channels: People who can manage the channel can delete it.
|
||||
* (Setting sim users to PL 100 is safe because even though we can't demote the sims we can use code to make the sims demote themselves.)
|
||||
*/
|
||||
if (guild.owner_id === user.id || dUtils.hasSomePermissions(permissions, ["Administrator", "ManageWebhooks", "ManageGuild", "ManageChannels"])) return 100
|
||||
if (guild.owner_id === user.id || utils.hasSomePermissions(permissions, ["Administrator", "ManageWebhooks", "ManageGuild", "ManageChannels"])) return 100
|
||||
/*
|
||||
* PL 50 = Moderator = People who can manage people and messages in many ways. RATIONALE:
|
||||
* - Manage Messages: Can moderate by pinning or deleting the conversation.
|
||||
|
|
@ -173,19 +161,15 @@ function memberToPowerLevel(user, member, guild, channel) {
|
|||
* - Mute Members & Deafen Members: Can moderate by silencing disruptive people in ways they can't undo.
|
||||
* - Moderate Members.
|
||||
*/
|
||||
if (dUtils.hasSomePermissions(permissions, ["ManageMessages", "ManageNicknames", "ManageThreads", "KickMembers", "BanMembers", "MuteMembers", "DeafenMembers", "ModerateMembers"])) return 50
|
||||
if (utils.hasSomePermissions(permissions, ["ManageMessages", "ManageNicknames", "ManageThreads", "KickMembers", "BanMembers", "MuteMembers", "DeafenMembers", "ModerateMembers"])) return 50
|
||||
/* PL 50 = if room is read-only but the user has been specially allowed to send messages */
|
||||
const everyoneCanSend = dUtils.hasPermission(everyonePermissions, DiscordTypes.PermissionFlagsBits.SendMessages)
|
||||
const userCanSend = dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.SendMessages)
|
||||
const everyoneCanSend = utils.hasPermission(everyonePermissions, DiscordTypes.PermissionFlagsBits.SendMessages)
|
||||
const userCanSend = utils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.SendMessages)
|
||||
if (!everyoneCanSend && userCanSend) return createRoom.READ_ONLY_ROOM_EVENTS_DEFAULT_POWER
|
||||
/* PL 20 = Mention Everyone for technical reasons. */
|
||||
const everyoneCanMentionEveryone = dUtils.hasPermission(everyonePermissions, DiscordTypes.PermissionFlagsBits.MentionEveryone)
|
||||
const userCanMentionEveryone = dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.MentionEveryone)
|
||||
const everyoneCanMentionEveryone = utils.hasPermission(everyonePermissions, DiscordTypes.PermissionFlagsBits.MentionEveryone)
|
||||
const userCanMentionEveryone = utils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.MentionEveryone)
|
||||
if (!everyoneCanMentionEveryone && userCanMentionEveryone) return 20
|
||||
/* PL 10 = Create Polls for technical reasons. */
|
||||
const everyoneCanCreatePolls = dUtils.hasPermission(everyonePermissions, DiscordTypes.PermissionFlagsBits.SendPolls)
|
||||
const userCanCreatePolls = dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.SendPolls)
|
||||
if (!everyoneCanCreatePolls && userCanCreatePolls) return 10
|
||||
return 0
|
||||
}
|
||||
|
||||
|
|
@ -217,53 +201,28 @@ async function syncUser(user, member, channel, guild, roomID) {
|
|||
const mxid = await ensureSimJoined(user, roomID)
|
||||
const content = await memberToStateContent(user, member, guild.id)
|
||||
const powerLevel = memberToPowerLevel(user, member, guild, channel)
|
||||
await _sendSyncUser(roomID, mxid, content, powerLevel, {
|
||||
// do not overwrite pre-existing data if we already have data and `member` is not accessible, because this would replace good data with bad data
|
||||
allowOverwrite: !!member,
|
||||
globalProfile: await userToGlobalProfile(user)
|
||||
})
|
||||
return mxid
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} roomID
|
||||
* @param {string} mxid
|
||||
* @param {{displayname: string, avatar_url?: string}} content
|
||||
* @param {number | null} powerLevel
|
||||
* @param {{allowOverwrite?: boolean, globalProfile?: {displayname: string, avatar_url?: string}}} [options]
|
||||
*/
|
||||
async function _sendSyncUser(roomID, mxid, content, powerLevel, options) {
|
||||
const currentHash = _hashProfileContent(content, powerLevel ?? 0)
|
||||
const currentHash = _hashProfileContent(content, powerLevel)
|
||||
const existingHash = select("sim_member", "hashed_profile_content", {room_id: roomID, mxid}).safeIntegers().pluck().get()
|
||||
// only do the actual sync if the hash has changed since we last looked
|
||||
const hashHasChanged = existingHash !== currentHash
|
||||
// always okay to add new data. for overwriting, restrict based on options.allowOverwrite, if present
|
||||
const overwriteOkay = !existingHash || (options?.allowOverwrite ?? true)
|
||||
if (hashHasChanged && overwriteOkay) {
|
||||
const actions = []
|
||||
// however, do not overwrite pre-existing data if we already have data and `member` is not accessible, because this would replace good data with bad data
|
||||
const wouldOverwritePreExisting = existingHash && !member
|
||||
if (hashHasChanged && !wouldOverwritePreExisting) {
|
||||
// Update room member state
|
||||
actions.push(api.sendState(roomID, "m.room.member", mxid, content, mxid))
|
||||
await api.sendState(roomID, "m.room.member", mxid, content, mxid)
|
||||
// Update power levels
|
||||
if (powerLevel != null) {
|
||||
actions.push(mxUtils.setUserPower(roomID, mxid, powerLevel, api))
|
||||
}
|
||||
// Update global profile (if supported by server)
|
||||
if (await supportsMsc4069) {
|
||||
actions.push(api.profileSetDisplayname(mxid, options?.globalProfile?.displayname || content.displayname, true))
|
||||
actions.push(api.profileSetAvatarUrl(mxid, options?.globalProfile?.avatar_url || content.avatar_url, true))
|
||||
}
|
||||
await Promise.all(actions)
|
||||
await api.setUserPower(roomID, mxid, powerLevel)
|
||||
// Update cached hash
|
||||
db.prepare("UPDATE sim_member SET hashed_profile_content = ? WHERE room_id = ? AND mxid = ?").run(currentHash, roomID, mxid)
|
||||
}
|
||||
return mxid
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} roomID
|
||||
*/
|
||||
async function syncAllUsersInRoom(roomID) {
|
||||
const users = from("sim_member").join("sim", "mxid")
|
||||
.where({room_id: roomID}).and("and user_id not like '%-%' and user_id not like '%\\_%' escape '\\'").pluck("user_id").all()
|
||||
const mxids = select("sim_member", "mxid", {room_id: roomID}).pluck().all()
|
||||
|
||||
const channelID = select("channel_room", "channel_id", {room_id: roomID}).pluck().get()
|
||||
assert.ok(typeof channelID === "string")
|
||||
|
|
@ -275,7 +234,10 @@ async function syncAllUsersInRoom(roomID) {
|
|||
/** @ts-ignore @type {DiscordTypes.APIGuild} */
|
||||
const guild = discord.guilds.get(guildID)
|
||||
|
||||
for (const userID of users) {
|
||||
for (const mxid of mxids) {
|
||||
const userID = select("sim", "user_id", {mxid}).pluck().get()
|
||||
assert.ok(typeof userID === "string")
|
||||
|
||||
/** @ts-ignore @type {Required<DiscordTypes.APIGuildMember>} */
|
||||
const member = await discord.snow.guild.getGuildMember(guildID, userID)
|
||||
/** @ts-ignore @type {Required<DiscordTypes.APIUser>} user */
|
||||
|
|
@ -292,7 +254,5 @@ module.exports._hashProfileContent = _hashProfileContent
|
|||
module.exports.ensureSim = ensureSim
|
||||
module.exports.ensureSimJoined = ensureSimJoined
|
||||
module.exports.syncUser = syncUser
|
||||
module.exports._sendSyncUser = _sendSyncUser
|
||||
module.exports.syncAllUsersInRoom = syncAllUsersInRoom
|
||||
module.exports._memberToPowerLevel = memberToPowerLevel
|
||||
module.exports.supportsMsc4069 = supportsMsc4069
|
||||
|
|
|
|||
|
|
@ -1,131 +0,0 @@
|
|||
// @ts-check
|
||||
|
||||
const assert = require("assert")
|
||||
const {reg} = require("../../matrix/read-registration")
|
||||
const Ty = require("../../types")
|
||||
|
||||
const passthrough = require("../../passthrough")
|
||||
const {sync, db, select, from} = passthrough
|
||||
/** @type {import("../../matrix/api")} */
|
||||
const api = sync.require("../../matrix/api")
|
||||
/** @type {import("../../matrix/file")} */
|
||||
const file = sync.require("../../matrix/file")
|
||||
/** @type {import("./register-user")} */
|
||||
const registerUser = sync.require("./register-user")
|
||||
/** @type {import("../converters/user-to-mxid")} */
|
||||
const userToMxid = sync.require("../converters/user-to-mxid")
|
||||
|
||||
/**
|
||||
* A sim is an account that is being simulated by the bridge to copy events from the other side.
|
||||
* @param {string} fakeUserID
|
||||
* @param {Ty.WebhookAuthor} author
|
||||
* @returns mxid
|
||||
*/
|
||||
async function createSim(fakeUserID, author) {
|
||||
// Choose sim name
|
||||
const simName = userToMxid.webhookAuthorToSimName(author)
|
||||
const localpart = reg.ooye.namespace_prefix + simName
|
||||
const mxid = `@${localpart}:${reg.ooye.server_name}`
|
||||
|
||||
// Save chosen name in the database forever
|
||||
db.prepare("INSERT INTO sim (user_id, username, sim_name, mxid) VALUES (?, ?, ?, ?)").run(fakeUserID, author.username, simName, mxid)
|
||||
|
||||
// Register matrix user with that name
|
||||
try {
|
||||
await api.register(localpart)
|
||||
} catch (e) {
|
||||
// If user creation fails, manually undo the database change. Still isn't perfect, but should help.
|
||||
// (I would prefer a transaction, but it's not safe to leave transactions open across event loop ticks.)
|
||||
db.prepare("DELETE FROM sim WHERE user_id = ?").run(fakeUserID)
|
||||
throw e
|
||||
}
|
||||
return mxid
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a sim is registered for the user.
|
||||
* If there is already a sim, use that one. If there isn't one yet, register a new sim.
|
||||
* @param {string} fakeUserID
|
||||
* @param {Ty.WebhookAuthor} author
|
||||
* @returns {Promise<string>} mxid
|
||||
*/
|
||||
async function ensureSim(fakeUserID, author) {
|
||||
let mxid = null
|
||||
const existing = select("sim", "mxid", {user_id: fakeUserID}).pluck().get()
|
||||
if (existing) {
|
||||
mxid = existing
|
||||
} else {
|
||||
mxid = await createSim(fakeUserID, author)
|
||||
}
|
||||
return mxid
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a sim is registered for the user and is joined to the room.
|
||||
* @param {string} fakeUserID
|
||||
* @param {Ty.WebhookAuthor} author
|
||||
* @param {string} roomID
|
||||
* @returns {Promise<string>} mxid
|
||||
*/
|
||||
async function ensureSimJoined(fakeUserID, author, roomID) {
|
||||
// Ensure room ID is really an ID, not an alias
|
||||
assert.ok(roomID[0] === "!")
|
||||
|
||||
// Ensure user
|
||||
const mxid = await ensureSim(fakeUserID, author)
|
||||
|
||||
// Ensure joined
|
||||
const existing = select("sim_member", "mxid", {room_id: roomID, mxid}).pluck().get()
|
||||
if (!existing) {
|
||||
await api.inviteToRoom(roomID, mxid)
|
||||
await api.joinRoom(roomID, mxid)
|
||||
db.prepare("INSERT OR IGNORE INTO sim_member (room_id, mxid) VALUES (?, ?)").run(roomID, mxid)
|
||||
}
|
||||
return mxid
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate profile data based on webhook displayname and configured avatar.
|
||||
* @param {Ty.WebhookAuthor} author
|
||||
*/
|
||||
async function authorToStateContent(author) {
|
||||
// We prefer to use the member's avatar URL data since the image upload can be cached across channels,
|
||||
// unlike the userAvatar URL which is unique per channel, due to the webhook ID being in the URL.
|
||||
const avatar = file.userAvatar(author)
|
||||
|
||||
const content = {
|
||||
displayname: author.username,
|
||||
membership: "join",
|
||||
}
|
||||
if (avatar) content.avatar_url = await file.uploadDiscordFileToMxc(avatar)
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync profile data for a sim webhook user. This function follows the following process:
|
||||
* 1. Create and join the sim to the room if needed
|
||||
* 2. Make an object of what the new room member state content would be, including uploading the profile picture if it hasn't been done before
|
||||
* 3. Compare against the previously known state content, which is helpfully stored in the database
|
||||
* 4. If the state content has changed, send it to Matrix and update it in the database for next time
|
||||
* @param {Ty.WebhookAuthor} author for profile data
|
||||
* @param {string} roomID room to join member to
|
||||
* @param {boolean} shouldActuallySync whether to actually sync updated user data or just ensure it's joined
|
||||
* @returns {Promise<string>} mxid of the updated sim
|
||||
*/
|
||||
async function syncUser(author, roomID, shouldActuallySync) {
|
||||
const fakeUserID = userToMxid.webhookAuthorToFakeUserID(author)
|
||||
|
||||
// Create and join the sim to the room if needed
|
||||
const mxid = await ensureSimJoined(fakeUserID, author, roomID)
|
||||
|
||||
if (shouldActuallySync) {
|
||||
// Build current profile data and sync if the hash has changed
|
||||
const content = await authorToStateContent(author)
|
||||
await registerUser._sendSyncUser(roomID, mxid, content, null)
|
||||
}
|
||||
|
||||
return mxid
|
||||
}
|
||||
|
||||
module.exports.syncUser = syncUser
|
||||
|
|
@ -4,7 +4,7 @@ const Ty = require("../../types")
|
|||
const DiscordTypes = require("discord-api-types/v10")
|
||||
|
||||
const passthrough = require("../../passthrough")
|
||||
const {discord, sync, db, from, select} = passthrough
|
||||
const {discord, sync, db, select} = passthrough
|
||||
/** @type {import("../../matrix/api")} */
|
||||
const api = sync.require("../../matrix/api")
|
||||
/** @type {import("../converters/emoji-to-key")} */
|
||||
|
|
@ -18,15 +18,12 @@ const converter = sync.require("../converters/remove-reaction")
|
|||
* @param {DiscordTypes.GatewayMessageReactionRemoveDispatchData | DiscordTypes.GatewayMessageReactionRemoveEmojiDispatchData | DiscordTypes.GatewayMessageReactionRemoveAllDispatchData} data
|
||||
*/
|
||||
async function removeSomeReactions(data) {
|
||||
const row = select("channel_room", "room_id", {channel_id: data.channel_id}).get()
|
||||
if (!row) return
|
||||
const roomID = select("channel_room", "room_id", {channel_id: data.channel_id}).pluck().get()
|
||||
if (!roomID) return
|
||||
const eventIDForMessage = select("event_message", "event_id", {message_id: data.message_id, reaction_part: 0}).pluck().get()
|
||||
if (!eventIDForMessage) return
|
||||
|
||||
const eventReactedTo = from("event_message").join("message_room", "message_id").join("historical_channel_room", "historical_room_index")
|
||||
.where({message_id: data.message_id}).and("ORDER BY reaction_part").select("event_id", "room_id").get()
|
||||
if (!eventReactedTo) return
|
||||
|
||||
// Due to server restrictions, all relations (i.e. reactions) have to be in the same room as the original event.
|
||||
const reactions = await api.getFullRelations(eventReactedTo.room_id, eventReactedTo.event_id, "m.annotation")
|
||||
const reactions = await api.getFullRelations(roomID, eventIDForMessage, "m.annotation")
|
||||
|
||||
// Run the proper strategy and any strategy-specific database changes
|
||||
const removals = await
|
||||
|
|
@ -36,7 +33,7 @@ async function removeSomeReactions(data) {
|
|||
|
||||
// Redact the events and delete individual stored events in the database
|
||||
for (const removal of removals) {
|
||||
await api.redactEvent(eventReactedTo.room_id, removal.eventID, removal.mxid)
|
||||
await api.redactEvent(roomID, removal.eventID, removal.mxid)
|
||||
if (removal.hash) db.prepare("DELETE FROM reaction WHERE hashed_event_id = ?").run(removal.hash)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,40 +19,32 @@ const emitter = new EventEmitter()
|
|||
* 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
|
||||
* @template {(...args: any[]) => Promise<any>} T
|
||||
* @param {string} messageID
|
||||
* @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
|
||||
*/
|
||||
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 eventNotFoundThenRetrigger(messageID, fn, ...rest) {
|
||||
if (!paused.has(messageID)) {
|
||||
const eventID = select("event_message", "event_id", {message_id: messageID}).pluck().get()
|
||||
if (eventID) {
|
||||
debugRetrigger(`[retrigger] OK mid <-> eid = ${messageID} <-> ${eventID}`)
|
||||
return false // event was found so don't retrigger
|
||||
}
|
||||
}
|
||||
|
||||
debugRetrigger(`[retrigger] WAIT id = ${inputID}`)
|
||||
emitter.once(inputID, () => {
|
||||
debugRetrigger(`[retrigger] TRIGGER id = ${inputID}`)
|
||||
debugRetrigger(`[retrigger] WAIT mid = ${messageID}`)
|
||||
emitter.once(messageID, () => {
|
||||
debugRetrigger(`[retrigger] TRIGGER mid = ${messageID}`)
|
||||
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}`)
|
||||
if (emitter.listeners(messageID).length) {
|
||||
debugRetrigger(`[retrigger] EXPIRE mid = ${messageID}`)
|
||||
}
|
||||
emitter.removeAllListeners(inputID)
|
||||
emitter.removeAllListeners(messageID)
|
||||
}, 60 * 1000) // 1 minute
|
||||
return true // event was not found, then retrigger
|
||||
}
|
||||
|
|
@ -66,11 +58,11 @@ function eventNotFoundThenRetrigger(inputID, fn, ...rest) {
|
|||
*/
|
||||
async function pauseChanges(messageID, promise) {
|
||||
try {
|
||||
debugRetrigger(`[retrigger] PAUSE id = ${messageID}`)
|
||||
debugRetrigger(`[retrigger] PAUSE mid = ${messageID}`)
|
||||
paused.add(messageID)
|
||||
return await promise
|
||||
} finally {
|
||||
debugRetrigger(`[retrigger] RESUME id = ${messageID}`)
|
||||
debugRetrigger(`[retrigger] RESUME mid = ${messageID}`)
|
||||
paused.delete(messageID)
|
||||
messageFinishedBridging(messageID)
|
||||
}
|
||||
|
|
@ -82,7 +74,7 @@ async function pauseChanges(messageID, promise) {
|
|||
*/
|
||||
function messageFinishedBridging(messageID) {
|
||||
if (emitter.listeners(messageID).length) {
|
||||
debugRetrigger(`[retrigger] EMIT id = ${messageID}`)
|
||||
debugRetrigger(`[retrigger] EMIT mid = ${messageID}`)
|
||||
}
|
||||
emitter.emit(messageID)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ const assert = require("assert").strict
|
|||
const DiscordTypes = require("discord-api-types/v10")
|
||||
|
||||
const passthrough = require("../../passthrough")
|
||||
const { discord, sync, db, select, from} = passthrough
|
||||
const { discord, sync, db } = passthrough
|
||||
/** @type {import("../converters/message-to-event")} */
|
||||
const messageToEvent = sync.require("../converters/message-to-event")
|
||||
/** @type {import("../../matrix/api")} */
|
||||
|
|
@ -13,16 +13,10 @@ const api = sync.require("../../matrix/api")
|
|||
const registerUser = sync.require("./register-user")
|
||||
/** @type {import("./register-pk-user")} */
|
||||
const registerPkUser = sync.require("./register-pk-user")
|
||||
/** @type {import("./register-webhook-user")} */
|
||||
const registerWebhookUser = sync.require("./register-webhook-user")
|
||||
/** @type {import("../actions/create-room")} */
|
||||
const createRoom = sync.require("../actions/create-room")
|
||||
/** @type {import("../actions/poll-end")} */
|
||||
const pollEnd = sync.require("../actions/poll-end")
|
||||
/** @type {import("../../discord/utils")} */
|
||||
const dUtils = sync.require("../../discord/utils")
|
||||
/** @type {import("../../m2d/actions/channel-webhook")} */
|
||||
const channelWebhook = sync.require("../../m2d/actions/channel-webhook")
|
||||
|
||||
/**
|
||||
* @param {DiscordTypes.GatewayMessageCreateDispatchData} message
|
||||
|
|
@ -32,46 +26,26 @@ const channelWebhook = sync.require("../../m2d/actions/channel-webhook")
|
|||
*/
|
||||
async function sendMessage(message, channel, guild, row) {
|
||||
const roomID = await createRoom.ensureRoom(message.channel_id)
|
||||
const historicalRoomIndex = select("historical_channel_room", "historical_room_index", {room_id: roomID}).pluck().get()
|
||||
assert(historicalRoomIndex)
|
||||
|
||||
let senderMxid = null
|
||||
if (dUtils.isWebhookMessage(message)) {
|
||||
const useWebhookProfile = select("guild_space", "webhook_profile", {guild_id: guild.id}).pluck().get() ?? 0
|
||||
if (row && row.speedbump_webhook_id === message.webhook_id) {
|
||||
// Handle the PluralKit public instance
|
||||
if (row.speedbump_id === "466378653216014359") {
|
||||
senderMxid = await registerPkUser.syncUser(message.id, message.author, roomID, true)
|
||||
}
|
||||
} else if (useWebhookProfile) {
|
||||
senderMxid = await registerWebhookUser.syncUser(message.author, roomID, true)
|
||||
}
|
||||
} else {
|
||||
// not a webhook
|
||||
if (!dUtils.isWebhookMessage(message)) {
|
||||
if (message.author.id === discord.application.id) {
|
||||
// no need to sync the bot's own user
|
||||
} else {
|
||||
senderMxid = await registerUser.syncUser(message.author, message.member, channel, guild, roomID)
|
||||
}
|
||||
}
|
||||
|
||||
let sentResultsMessage
|
||||
if (message.type === DiscordTypes.MessageType.PollResult) { // ensure all Discord-side votes were pushed to Matrix before a poll is closed
|
||||
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
|
||||
sentResultsMessage = await channelWebhook.sendMessageWithWebhook(channelID, detailedResultsMessage, threadID)
|
||||
} else if (row && row.speedbump_webhook_id === message.webhook_id) {
|
||||
// Handle the PluralKit public instance
|
||||
if (row.speedbump_id === "466378653216014359") {
|
||||
senderMxid = await registerPkUser.syncUser(message.id, message.author, roomID, true)
|
||||
}
|
||||
}
|
||||
|
||||
const events = await messageToEvent.messageToEvent(message, guild, {}, {api, snow: discord.snow})
|
||||
const eventIDs = []
|
||||
if (events.length) {
|
||||
db.prepare("INSERT OR IGNORE INTO message_room (message_id, historical_room_index) VALUES (?, ?)").run(message.id, historicalRoomIndex)
|
||||
const typingMxid = from("sim").join("sim_member", "mxid").where({user_id: message.author.id, room_id: roomID}).pluck("mxid").get()
|
||||
if (typingMxid) api.sendTyping(roomID, false, typingMxid).catch(() => {})
|
||||
db.prepare("INSERT OR IGNORE INTO message_channel (message_id, channel_id) VALUES (?, ?)").run(message.id, message.channel_id)
|
||||
if (senderMxid) api.sendTyping(roomID, false, senderMxid).catch(() => {})
|
||||
}
|
||||
for (const event of events) {
|
||||
const part = event === events[0] ? 0 : 1
|
||||
|
|
@ -86,19 +60,7 @@ async function sendMessage(message, channel, guild, row) {
|
|||
|
||||
const useTimestamp = message["backfill"] ? new Date(message.timestamp).getTime() : undefined
|
||||
const eventID = await api.sendEvent(roomID, eventType, eventWithoutType, senderMxid, useTimestamp)
|
||||
eventIDs.push(eventID)
|
||||
|
||||
try {
|
||||
db.prepare("INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part, reaction_part, source) VALUES (?, ?, ?, ?, ?, ?, 1)").run(eventID, eventType, event.msgtype || null, message.id, part, reactionPart) // source 1 = discord
|
||||
} catch (e) {
|
||||
// check if we got rugpulled
|
||||
if (!select("message_room", "message_id", {message_id: message.id}).get()) {
|
||||
for (const eventID of eventIDs) {
|
||||
await api.redactEvent(roomID, eventID)
|
||||
}
|
||||
return []
|
||||
}
|
||||
}
|
||||
db.prepare("INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part, reaction_part, source) VALUES (?, ?, ?, ?, ?, ?, 1)").run(eventID, eventType, event.msgtype || null, message.id, part, reactionPart) // source 1 = discord
|
||||
|
||||
// The primary event is part = 0 and has the most important and distinct information. It is used to provide reply previews, be pinned, and possibly future uses.
|
||||
// The first event is chosen to be the primary part because it is usually the message text content and is more likely to be distinct.
|
||||
|
|
@ -106,35 +68,7 @@ async function sendMessage(message, channel, guild, row) {
|
|||
|
||||
// The last event gets reaction_part = 0. Reactions are managed there because reactions are supposed to appear at the bottom.
|
||||
|
||||
|
||||
if (eventType === "org.matrix.msc3381.poll.start") {
|
||||
db.transaction(() => {
|
||||
db.prepare("INSERT INTO poll (message_id, max_selections, question_text, is_closed) VALUES (?, ?, ?, 0)").run(
|
||||
message.id,
|
||||
event["org.matrix.msc3381.poll.start"].max_selections,
|
||||
event["org.matrix.msc3381.poll.start"].question["org.matrix.msc1767.text"]
|
||||
)
|
||||
for (const [index, option] of Object.entries(event["org.matrix.msc3381.poll.start"].answers)) {
|
||||
db.prepare("INSERT INTO poll_option (message_id, matrix_option, discord_option, option_text, seq) VALUES (?, ?, ?, ?, ?)").run(
|
||||
message.id,
|
||||
option.id,
|
||||
option.id,
|
||||
option["org.matrix.msc1767.text"],
|
||||
index
|
||||
)
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
// part/reaction_part consistency for polls
|
||||
if (sentResultsMessage) {
|
||||
db.transaction(() => {
|
||||
db.prepare("INSERT OR IGNORE INTO message_room (message_id, historical_room_index) VALUES (?, ?)").run(sentResultsMessage.id, historicalRoomIndex)
|
||||
db.prepare("UPDATE event_message SET reaction_part = 1 WHERE event_id = ?").run(eventID)
|
||||
// part = 1, reaction_part = 0, source = 0 as the results are "from Matrix" and doing otherwise breaks things when that message gets updated by Discord (it just does that sometimes)
|
||||
db.prepare("INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part, reaction_part, source) VALUES (?, ?, ?, ?, ?, ?, 0)").run(eventID, eventType, event.msgtype || null, sentResultsMessage.id, 1, 0)
|
||||
})()
|
||||
}
|
||||
eventIDs.push(eventID)
|
||||
}
|
||||
|
||||
return eventIDs
|
||||
|
|
|
|||
|
|
@ -4,14 +4,6 @@ const DiscordTypes = require("discord-api-types/v10")
|
|||
const passthrough = require("../../passthrough")
|
||||
const {discord, select, db} = passthrough
|
||||
|
||||
const DEBUG_SPEEDBUMP = false
|
||||
|
||||
function debugSpeedbump(message) {
|
||||
if (DEBUG_SPEEDBUMP) {
|
||||
console.log(message)
|
||||
}
|
||||
}
|
||||
|
||||
const SPEEDBUMP_SPEED = 4000 // 4 seconds delay
|
||||
const SPEEDBUMP_UPDATE_FREQUENCY = 2 * 60 * 60 // 2 hours
|
||||
|
||||
|
|
@ -35,8 +27,8 @@ async function updateCache(channelID, lastChecked) {
|
|||
db.prepare("UPDATE channel_room SET speedbump_id = ?, speedbump_webhook_id = ?, speedbump_checked = ? WHERE channel_id = ?").run(foundApplication, foundWebhook, now, channelID)
|
||||
}
|
||||
|
||||
/** @type {Map<string, number>} messageID -> number of gateway events currently bumping */
|
||||
const bumping = new Map()
|
||||
/** @type {Set<string>} set of messageID */
|
||||
const bumping = new Set()
|
||||
|
||||
/**
|
||||
* Slow down a message. After it passes the speedbump, return whether it's okay or if it's been deleted.
|
||||
|
|
@ -44,26 +36,9 @@ const bumping = new Map()
|
|||
* @returns whether it was deleted
|
||||
*/
|
||||
async function doSpeedbump(messageID) {
|
||||
let value = (bumping.get(messageID) ?? 0) + 1
|
||||
bumping.set(messageID, value)
|
||||
debugSpeedbump(`[speedbump] WAIT ${messageID}++ = ${value}`)
|
||||
|
||||
bumping.add(messageID)
|
||||
await new Promise(resolve => setTimeout(resolve, SPEEDBUMP_SPEED))
|
||||
|
||||
if (!bumping.has(messageID)) {
|
||||
debugSpeedbump(`[speedbump] DELETED ${messageID}`)
|
||||
return true
|
||||
}
|
||||
value = (bumping.get(messageID) ?? 0) - 1
|
||||
if (value <= 0) {
|
||||
debugSpeedbump(`[speedbump] OK ${messageID}-- = ${value}`)
|
||||
bumping.delete(messageID)
|
||||
return false
|
||||
} else {
|
||||
debugSpeedbump(`[speedbump] MULTI ${messageID}-- = ${value}`)
|
||||
bumping.set(messageID, value)
|
||||
return true
|
||||
}
|
||||
return !bumping.delete(messageID)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ async function updatePins(channelID, roomID, convertedTimestamp) {
|
|||
throw e
|
||||
}
|
||||
|
||||
const kstate = await ks.roomToKState(roomID, [["m.room.pinned_events", ""]])
|
||||
const kstate = await ks.roomToKState(roomID)
|
||||
const pinned = pinsToList.pinsToList(discordPins, kstate)
|
||||
|
||||
const diff = ks.diffKState(kstate, {"m.room.pinned_events/": {pinned}})
|
||||
|
|
|
|||
|
|
@ -6,10 +6,8 @@ const passthrough = require("../../passthrough")
|
|||
const {sync, select, from} = passthrough
|
||||
/** @type {import("./message-to-event")} */
|
||||
const messageToEvent = sync.require("../converters/message-to-event")
|
||||
/** @type {import("../../discord/utils")} */
|
||||
const dUtils = sync.require("../../discord/utils")
|
||||
/** @type {import("../../matrix/utils")} */
|
||||
const mxUtils = sync.require("../../matrix/utils")
|
||||
/** @type {import("../../m2d/converters/utils")} */
|
||||
const utils = sync.require("../../m2d/converters/utils")
|
||||
|
||||
function eventCanBeEdited(ev) {
|
||||
// Discord does not allow files, images, attachments, or videos to be edited.
|
||||
|
|
@ -20,10 +18,6 @@ function eventCanBeEdited(ev) {
|
|||
if (ev.old.event_type === "m.sticker") {
|
||||
return false
|
||||
}
|
||||
// Discord does not allow the data of polls to be edited, they may only be responded to.
|
||||
if (ev.old.event_type === "org.matrix.msc3381.poll.start" || ev.old.event_type === "org.matrix.msc3381.poll.end") {
|
||||
return false
|
||||
}
|
||||
// Anything else is fair game.
|
||||
return true
|
||||
}
|
||||
|
|
@ -38,21 +32,17 @@ function eventIsText(ev) {
|
|||
* @param {import("../../matrix/api")} api simple-as-nails dependency injection for the matrix API
|
||||
*/
|
||||
async function editToChanges(message, guild, api) {
|
||||
// If it is a user edit, allow deleting old messages (e.g. they might have removed text from an image).
|
||||
// If it is the system adding a generated embed to a message, don't delete old messages since the system only sends partial data.
|
||||
// Since an update in August 2024, the system always provides the full data of message updates. I'll leave in the old code since it won't cause problems.
|
||||
|
||||
const isGeneratedEmbed = !("content" in message)
|
||||
|
||||
// Figure out what events we will be replacing
|
||||
|
||||
const roomID = select("channel_room", "room_id", {channel_id: message.channel_id}).pluck().get()
|
||||
assert(roomID)
|
||||
const oldEventRows = select("event_message", ["event_id", "event_type", "event_subtype", "part", "reaction_part", "source"], {message_id: message.id}).all()
|
||||
|
||||
// If it is a user edit, allow deleting old messages (e.g. they might have removed text from an image).
|
||||
// If it is the system adding a generated embed to a message, don't delete old messages since the system only sends partial data.
|
||||
// Since an update in August 2024, the system always provides the full data of message updates.
|
||||
// Now, this code path is only used by generated embeds for messages that were originally sent from Matrix.
|
||||
|
||||
const originallyFromMatrix = oldEventRows.some(r => r.source === 0)
|
||||
const mightBeGeneratedEmbed = !("content" in message) || originallyFromMatrix
|
||||
|
||||
// Figure out who to send as
|
||||
const oldEventRows = select("event_message", ["event_id", "event_type", "event_subtype", "part", "reaction_part"], {message_id: message.id}).all()
|
||||
|
||||
/** @type {string?} Null if we don't have a sender in the room, which will happen if it's a webhook's message. The bridge bot will do the edit instead. */
|
||||
let senderMxid = null
|
||||
|
|
@ -62,7 +52,7 @@ async function editToChanges(message, guild, api) {
|
|||
// Should be a system generated embed. We want the embed to be sent by the same user who sent the message, so that the messages get grouped in most clients.
|
||||
const eventID = oldEventRows[0].event_id // a calling function should have already checked that there is at least one message to edit
|
||||
const event = await api.getEvent(roomID, eventID)
|
||||
if (mxUtils.eventSenderIsFromDiscord(event.sender)) {
|
||||
if (utils.eventSenderIsFromDiscord(event.sender)) {
|
||||
senderMxid = event.sender
|
||||
}
|
||||
}
|
||||
|
|
@ -81,25 +71,13 @@ async function editToChanges(message, guild, api) {
|
|||
+ The events must have the same subtype.
|
||||
Events will therefore be divided into four categories:
|
||||
*/
|
||||
/**
|
||||
* 1. Events that are matched, and should be edited by sending another m.replace event
|
||||
* @type {{old: typeof oldEventRows[0], oldMentions?: any, newFallbackContent: typeof newFallbackContent[0], newInnerContent: typeof newInnerContent[0]}[]}
|
||||
*/
|
||||
/** 1. Events that are matched, and should be edited by sending another m.replace event */
|
||||
let eventsToReplace = []
|
||||
/**
|
||||
* 2. Events that are present in the old version only, and should be blanked or redacted
|
||||
* @type {{old: typeof oldEventRows[0]}[]}
|
||||
*/
|
||||
/** 2. Events that are present in the old version only, and should be blanked or redacted */
|
||||
let eventsToRedact = []
|
||||
/**
|
||||
* 3. Events that are present in the new version only, and should be sent as new, with references back to the context
|
||||
* @type {typeof newInnerContent}
|
||||
*/
|
||||
/** 3. Events that are present in the new version only, and should be sent as new, with references back to the context */
|
||||
let eventsToSend = []
|
||||
/**
|
||||
* 4. Events that are matched and have definitely not changed, so they don't need to be edited or replaced at all.
|
||||
* @type {(typeof eventsToRedact[0] | typeof eventsToReplace[0])[]}
|
||||
*/
|
||||
/** 4. Events that are matched and have definitely not changed, so they don't need to be edited or replaced at all. */
|
||||
let unchangedEvents = []
|
||||
|
||||
function shift() {
|
||||
|
|
@ -136,31 +114,16 @@ async function editToChanges(message, guild, api) {
|
|||
eventsToRedact = oldEventRows.map(e => ({old: e}))
|
||||
|
||||
// If this is a generated embed update, only allow the embeds to be updated, since the system only sends data about events. Ignore changes to other things.
|
||||
// This also prevents Matrix events that were re-subtyped during conversion (e.g. large image -> text link) from being mistakenly included.
|
||||
if (mightBeGeneratedEmbed) {
|
||||
unchangedEvents = unchangedEvents.concat(
|
||||
dUtils.filterTo(eventsToRedact, e => e.old.event_subtype === "m.notice" && e.old.source === 1), // Move everything except embeds from eventsToRedact to unchangedEvents.
|
||||
dUtils.filterTo(eventsToReplace, e => e.old.event_subtype === "m.notice" && e.old.source === 1) // Move everything except embeds from eventsToReplace to unchangedEvents.
|
||||
)
|
||||
eventsToSend = eventsToSend.filter(e => e.msgtype === "m.notice") // Don't send new events that aren't the embed.
|
||||
}
|
||||
|
||||
// Don't post new generated embeds for messages if it's been a while since the message was sent. Detached embeds look weird.
|
||||
const messageQuiteOld = message.timestamp && new Date(message.timestamp).getTime() < Date.now() - 30 * 1000 // older than 30 seconds ago
|
||||
// Don't send anything new at all if it's been longer since the message was sent. Detached messages are just inappropriate.
|
||||
const messageReallyOld = message.timestamp && new Date(message.timestamp).getTime() < Date.now() - 2 * 60 * 1000 // older than 2 minutes ago
|
||||
// Don't post new generated embeds for messages if the setting was disabled.
|
||||
const embedsEnabled = select("guild_space", "url_preview", {guild_id: guild?.id}).pluck().get() ?? 1
|
||||
if (messageReallyOld) {
|
||||
eventsToSend = [] // Only allow edits to change and delete, but not send new.
|
||||
} else if ((messageQuiteOld || !embedsEnabled) && !message.author?.bot) {
|
||||
eventsToSend = eventsToSend.filter(e => e.msgtype !== "m.notice") // Only send events that aren't embeds.
|
||||
if (isGeneratedEmbed) {
|
||||
unchangedEvents.push(...eventsToRedact.filter(e => e.old.event_subtype !== "m.notice")) // Move them from eventsToRedact to unchangedEvents.
|
||||
eventsToRedact = eventsToRedact.filter(e => e.old.event_subtype === "m.notice")
|
||||
}
|
||||
|
||||
// Now, everything in eventsToSend and eventsToRedact is a real change, but everything in eventsToReplace might not have actually changed!
|
||||
// (Example: a MESSAGE_UPDATE for a text+image message - Discord does not allow the image to be changed, but the text might have been.)
|
||||
// So we'll remove entries from eventsToReplace that *definitely* cannot have changed. (This is category 4 mentioned above.) Everything remaining *may* have changed.
|
||||
unchangedEvents = unchangedEvents.concat(dUtils.filterTo(eventsToReplace, ev => eventCanBeEdited(ev))) // Move them from eventsToReplace to unchangedEvents.
|
||||
unchangedEvents.push(...eventsToReplace.filter(ev => !eventCanBeEdited(ev))) // Move them from eventsToRedact to unchangedEvents.
|
||||
eventsToReplace = eventsToReplace.filter(eventCanBeEdited)
|
||||
|
||||
// Now, everything in eventsToReplace has the potential to have changed, but did it actually?
|
||||
// (Example: if a URL preview was generated or updated, the message text won't have changed.)
|
||||
|
|
@ -169,7 +132,6 @@ async function editToChanges(message, guild, api) {
|
|||
const event = eventsToReplace[i]
|
||||
if (!eventIsText(event)) continue // not text, can't analyse
|
||||
const oldEvent = await api.getEvent(roomID, eventsToReplace[i].old.event_id)
|
||||
eventsToReplace[i].oldMentions = oldEvent.content["m.mentions"]
|
||||
const oldEventBodyWithoutQuotedReply = oldEvent.content.body?.replace(/^(>.*\n)*\n*/sm, "")
|
||||
if (oldEventBodyWithoutQuotedReply !== event.newInnerContent.body) continue // event changed, must replace it
|
||||
// Move it from eventsToRedact to unchangedEvents.
|
||||
|
|
@ -217,38 +179,23 @@ async function editToChanges(message, guild, api) {
|
|||
}
|
||||
|
||||
// Removing unnecessary properties before returning
|
||||
return {
|
||||
roomID,
|
||||
eventsToReplace: eventsToReplace.map(e => ({oldID: e.old.event_id, newContent: makeReplacementEventContent(e.old.event_id, e.oldMentions, e.newFallbackContent, e.newInnerContent)})),
|
||||
eventsToRedact: eventsToRedact.map(e => e.old.event_id),
|
||||
eventsToSend,
|
||||
senderMxid,
|
||||
promotions
|
||||
}
|
||||
eventsToRedact = eventsToRedact.map(e => e.old.event_id)
|
||||
eventsToReplace = eventsToReplace.map(e => ({oldID: e.old.event_id, newContent: makeReplacementEventContent(e.old.event_id, e.newFallbackContent, e.newInnerContent)}))
|
||||
|
||||
return {roomID, eventsToReplace, eventsToRedact, eventsToSend, senderMxid, promotions}
|
||||
}
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @param {string} oldID
|
||||
* @param {any} oldMentions
|
||||
* @param {T} newFallbackContent
|
||||
* @param {T} newInnerContent
|
||||
* @returns {import("../../types").Event.ReplacementContent<T>} content
|
||||
*/
|
||||
function makeReplacementEventContent(oldID, oldMentions, newFallbackContent, newInnerContent) {
|
||||
const mentions = {}
|
||||
const newMentionUsers = new Set(newFallbackContent["m.mentions"]?.user_ids || [])
|
||||
const oldMentionUsers = new Set(oldMentions?.user_ids || [])
|
||||
const mentionDiff = newMentionUsers.difference(oldMentionUsers)
|
||||
if (mentionDiff.size) {
|
||||
mentions.user_ids = [...mentionDiff.values()]
|
||||
}
|
||||
if (newFallbackContent["m.mentions"]?.room && !oldMentions?.room) {
|
||||
mentions.room = true
|
||||
}
|
||||
function makeReplacementEventContent(oldID, newFallbackContent, newInnerContent) {
|
||||
const content = {
|
||||
...newFallbackContent,
|
||||
"m.mentions": mentions,
|
||||
"m.mentions": {},
|
||||
"m.new_content": {
|
||||
...newInnerContent
|
||||
},
|
||||
|
|
|
|||
|
|
@ -42,14 +42,7 @@ test("edit2changes: bot response", async t => {
|
|||
const {senderMxid, eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.bot_response, data.guild.general, {
|
||||
getEvent(roomID, eventID) {
|
||||
t.equal(eventID, "$fdD9OZ55xg3EAsfvLZza5tMhtjUO91Wg3Otuo96TplY")
|
||||
return {
|
||||
content: {
|
||||
"m.mentions": {
|
||||
user_ids: ["@cadence:cadence.moe"],
|
||||
},
|
||||
body: "dummy"
|
||||
}
|
||||
}
|
||||
return {content: {body: "dummy"}}
|
||||
},
|
||||
async getJoinedMembers(roomID) {
|
||||
t.equal(roomID, "!hYnGGlPHlbujVVfktC:cadence.moe")
|
||||
|
|
@ -78,18 +71,18 @@ test("edit2changes: bot response", async t => {
|
|||
newContent: {
|
||||
$type: "m.room.message",
|
||||
msgtype: "m.text",
|
||||
body: "* :ae_botrac4r: [@cadence](https://matrix.to/#/@cadence:cadence.moe) asked ````, I respond: Stop drinking paint. (No)\n\nHit :bn_re: to reroll.",
|
||||
body: "* :ae_botrac4r: @cadence asked ````, I respond: Stop drinking paint. (No)\n\nHit :bn_re: to reroll.",
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: '* <img data-mx-emoticon height="32" src="mxc://cadence.moe/skqfuItqxNmBYekzmVKyoLzs" title=":ae_botrac4r:" alt=":ae_botrac4r:"> <a href="https://matrix.to/#/@cadence:cadence.moe">@cadence</a> asked <code></code>, I respond: Stop drinking paint. (No)<br><br>Hit <img data-mx-emoticon height="32" src="mxc://cadence.moe/OIpqpfxTnHKokcsYqDusxkBT" title=":bn_re:" alt=":bn_re:"> to reroll.',
|
||||
formatted_body: '* <img data-mx-emoticon height="32" src="mxc://cadence.moe/skqfuItqxNmBYekzmVKyoLzs" title=":ae_botrac4r:" alt=":ae_botrac4r:"> @cadence asked <code></code>, I respond: Stop drinking paint. (No)<br><br>Hit <img data-mx-emoticon height="32" src="mxc://cadence.moe/OIpqpfxTnHKokcsYqDusxkBT" title=":bn_re:" alt=":bn_re:"> to reroll.',
|
||||
"m.mentions": {
|
||||
// Client-Server API spec 11.37.7: Copy Discord's behaviour by not re-notifying anyone that an *edit occurred*
|
||||
},
|
||||
// *** Replaced With: ***
|
||||
"m.new_content": {
|
||||
msgtype: "m.text",
|
||||
body: ":ae_botrac4r: [@cadence](https://matrix.to/#/@cadence:cadence.moe) asked ````, I respond: Stop drinking paint. (No)\n\nHit :bn_re: to reroll.",
|
||||
body: ":ae_botrac4r: @cadence asked ````, I respond: Stop drinking paint. (No)\n\nHit :bn_re: to reroll.",
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: '<img data-mx-emoticon height="32" src="mxc://cadence.moe/skqfuItqxNmBYekzmVKyoLzs" title=":ae_botrac4r:" alt=":ae_botrac4r:"> <a href="https://matrix.to/#/@cadence:cadence.moe">@cadence</a> asked <code></code>, I respond: Stop drinking paint. (No)<br><br>Hit <img data-mx-emoticon height="32" src="mxc://cadence.moe/OIpqpfxTnHKokcsYqDusxkBT" title=":bn_re:" alt=":bn_re:"> to reroll.',
|
||||
formatted_body: '<img data-mx-emoticon height="32" src="mxc://cadence.moe/skqfuItqxNmBYekzmVKyoLzs" title=":ae_botrac4r:" alt=":ae_botrac4r:"> @cadence asked <code></code>, I respond: Stop drinking paint. (No)<br><br>Hit <img data-mx-emoticon height="32" src="mxc://cadence.moe/OIpqpfxTnHKokcsYqDusxkBT" title=":bn_re:" alt=":bn_re:"> to reroll.',
|
||||
"m.mentions": {
|
||||
// Client-Server API spec 11.37.7: This should contain the mentions for the final version of the event
|
||||
"user_ids": ["@cadence:cadence.moe"]
|
||||
|
|
@ -188,7 +181,12 @@ test("edit2changes: edit of reply to skull webp attachment with content", async
|
|||
newContent: {
|
||||
$type: "m.room.message",
|
||||
msgtype: "m.text",
|
||||
body: "* Edit",
|
||||
body: "> Extremity: Image\n\n* Edit",
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body:
|
||||
'<mx-reply><blockquote><a href="https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$oLyUTyZ_7e_SUzGNWZKz880ll9amLZvXGbArJCKai2Q">In reply to</a> Extremity'
|
||||
+ '<br>Image</blockquote></mx-reply>'
|
||||
+ '* Edit',
|
||||
"m.mentions": {},
|
||||
"m.new_content": {
|
||||
msgtype: "m.text",
|
||||
|
|
@ -273,61 +271,6 @@ test("edit2changes: promotes the text event when multiple rows have part = 1 (sh
|
|||
])
|
||||
})
|
||||
|
||||
test("edit2changes: promotes newly sent event", async t => {
|
||||
const {eventsToReplace, eventsToRedact, eventsToSend, promotions} = await editToChanges({
|
||||
channel_id: "1160894080998461480",
|
||||
id: "1404133238414376971",
|
||||
content: "hi",
|
||||
attachments: [{
|
||||
id: "1157854643037163610",
|
||||
filename: "Screenshot_20231001_034036.jpg",
|
||||
size: 51981,
|
||||
url: "https://cdn.discordapp.com/attachments/176333891320283136/1157854643037163610/Screenshot_20231001_034036.jpg?ex=651a1faa&is=6518ce2a&hm=eb5ca80a3fa7add8765bf404aea2028a28a2341e4a62435986bcdcf058da82f3&",
|
||||
proxy_url: "https://media.discordapp.net/attachments/176333891320283136/1157854643037163610/Screenshot_20231001_034036.jpg?ex=651a1faa&is=6518ce2a&hm=eb5ca80a3fa7add8765bf404aea2028a28a2341e4a62435986bcdcf058da82f3&",
|
||||
width: 1080,
|
||||
height: 1170,
|
||||
content_type: "image/jpeg"
|
||||
}],
|
||||
author: {
|
||||
username: "cadence.worm",
|
||||
global_name: "Cadence"
|
||||
}
|
||||
}, data.guild.general, {
|
||||
async getEvent(roomID, eventID) {
|
||||
t.equal(eventID, "$uUKLcTQvik5tgtTGDKuzn0Ci4zcCvSoUcYn2X7mXm9I")
|
||||
return {
|
||||
type: "m.room.message",
|
||||
sender: "@_ooye_cadence.worm:cadence.moe",
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
body: "hi"
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
t.deepEqual(eventsToRedact, ["$LhmoWWvYyn5_AHkfb6FaXmLI6ZOC1kloql5P40YDmIk"])
|
||||
t.deepEqual(eventsToReplace, [])
|
||||
t.deepEqual(eventsToSend, [{
|
||||
$type: "m.room.message",
|
||||
body: "Screenshot_20231001_034036.jpg",
|
||||
external_url: "https://bridge.example.org/download/discordcdn/176333891320283136/1157854643037163610/Screenshot_20231001_034036.jpg",
|
||||
filename: "Screenshot_20231001_034036.jpg",
|
||||
info: {
|
||||
mimetype: "image/jpeg",
|
||||
size: 51981,
|
||||
w: 1080,
|
||||
h: 1170
|
||||
},
|
||||
url: "mxc://cadence.moe/zAXdQriaJuLZohDDmacwWWDR",
|
||||
"m.mentions": {},
|
||||
msgtype: "m.image"
|
||||
}])
|
||||
t.deepEqual(promotions, [
|
||||
{column: "reaction_part", nextEvent: true}
|
||||
])
|
||||
// assert that the event parts will be consistent in database after this
|
||||
})
|
||||
|
||||
test("edit2changes: generated embed", async t => {
|
||||
let called = 0
|
||||
const {senderMxid, eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.embed_generated_social_media_image, data.guild.general, {
|
||||
|
|
@ -372,7 +315,6 @@ test("edit2changes: generated embed", async t => {
|
|||
|
||||
test("edit2changes: generated embed on a reply", async t => {
|
||||
let called = 0
|
||||
data.message_update.embed_generated_on_reply.timestamp = new Date().toISOString()
|
||||
const {senderMxid, eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.embed_generated_on_reply, data.guild.general, {
|
||||
getEvent(roomID, eventID) {
|
||||
called++
|
||||
|
|
@ -418,21 +360,3 @@ test("edit2changes: generated embed on a reply", async t => {
|
|||
t.equal(senderMxid, "@_ooye_cadence:cadence.moe")
|
||||
t.equal(called, 1)
|
||||
})
|
||||
|
||||
test("edit2changes: don't generate embed if it's been too long since the message", async t => {
|
||||
const {senderMxid, eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.embed_generated_social_media_image_for_matrix_user, data.guild.general)
|
||||
t.deepEqual(eventsToRedact, [])
|
||||
t.deepEqual(eventsToReplace, [])
|
||||
t.deepEqual(eventsToSend, [])
|
||||
t.deepEqual(promotions, [])
|
||||
t.equal(senderMxid, null)
|
||||
})
|
||||
|
||||
test("edit2changes: don't generate new data in situations where m->d(->m) subtypes don't match, like large files", async t => {
|
||||
const {senderMxid, eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message.large_file_from_matrix)
|
||||
t.deepEqual(eventsToRedact, [])
|
||||
t.deepEqual(eventsToReplace, [])
|
||||
t.deepEqual(eventsToSend, [])
|
||||
t.deepEqual(promotions, [])
|
||||
t.equal(senderMxid, null)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,161 +0,0 @@
|
|||
// @ts-check
|
||||
|
||||
const assert = require("assert")
|
||||
|
||||
const {reg} = require("../../matrix/read-registration")
|
||||
const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex))
|
||||
|
||||
/**
|
||||
* @typedef {{text: string, index: number, end: number}} Token
|
||||
*/
|
||||
|
||||
/** @typedef {{mxids: {localpart: string, mxid: string, displayname?: string | null}[], names: {displaynameTokens: Token[], mxid: string}[]}} ProcessedJoined */
|
||||
|
||||
const lengthBonusLengthCap = 50
|
||||
const lengthBonusValue = 0.5
|
||||
/**
|
||||
* Score by how many characters in a row at the start of input are in localpart. 2x if it matches at the start. +1 tiebreaker bonus if it matches all.
|
||||
* 0 = no match
|
||||
* @param {string} localpart
|
||||
* @param {string} input
|
||||
* @param {string | null} [displayname] only for the super tiebreaker
|
||||
* @returns {{score: number, matchedInputTokens: Token[]}}
|
||||
*/
|
||||
function scoreLocalpart(localpart, input, displayname) {
|
||||
let score = 0
|
||||
let atStart = false
|
||||
let matchingLocations = []
|
||||
do {
|
||||
atStart = matchingLocations[0] === 0
|
||||
let chars = input[score]
|
||||
if (score === 0) {
|
||||
// add all possible places
|
||||
let i = 0
|
||||
while ((i = localpart.indexOf(chars, i)) !== -1) {
|
||||
matchingLocations.push(i)
|
||||
i++
|
||||
}
|
||||
} else {
|
||||
// trim down remaining places
|
||||
matchingLocations = matchingLocations.filter(i => localpart[i+score] === input[score])
|
||||
}
|
||||
if (matchingLocations.length) {
|
||||
score++
|
||||
if (score === localpart.length) break
|
||||
}
|
||||
} while (matchingLocations.length)
|
||||
/** @type {Token} */
|
||||
const fakeToken = {text: input.slice(0, score), index: 0, end: score}
|
||||
const displaynameLength = displayname?.length ?? 0
|
||||
if (score === localpart.length) score = score * 2 + 1 + Math.max(((lengthBonusLengthCap-displaynameLength)/lengthBonusLengthCap)*lengthBonusValue, 0)
|
||||
else if (atStart) score = score * 2
|
||||
return {score, matchedInputTokens: [fakeToken]}
|
||||
}
|
||||
|
||||
const decayDistance = 20
|
||||
const decayValue = 0.33
|
||||
/**
|
||||
* Score by how many tokens in sequence (not necessarily back to back) at the start of input are in display name tokens. Score each token on its length. 2x if it matches at the start. +1 tiebreaker bonus if it matches all
|
||||
* @param {Token[]} displaynameTokens
|
||||
* @param {Token[]} inputTokens
|
||||
* @returns {{score: number, matchedInputTokens: Token[]}}
|
||||
*/
|
||||
function scoreName(displaynameTokens, inputTokens) {
|
||||
let matchedInputTokens = []
|
||||
let score = 0
|
||||
let searchFrom = 0
|
||||
for (let nextInputTokenIndex = 0; nextInputTokenIndex < inputTokens.length; nextInputTokenIndex++) {
|
||||
// take next
|
||||
const nextToken = inputTokens[nextInputTokenIndex]
|
||||
// see if it's there
|
||||
let foundAt = displaynameTokens.findIndex((tk, idx) => idx >= searchFrom && tk.text === nextToken.text)
|
||||
if (foundAt !== -1) {
|
||||
// update scoring
|
||||
matchedInputTokens.push(nextToken)
|
||||
score += nextToken.text.length * Math.max(((decayDistance-foundAt)*(1+decayValue))/(decayDistance*(1+decayValue)), decayValue) // decay score 100%->33% the further into the displayname it's found
|
||||
// prepare for next loop
|
||||
searchFrom = foundAt + 1
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
const firstTextualInputToken = inputTokens.find(t => t.text.match(/^\w/))
|
||||
if (matchedInputTokens[0] === inputTokens[0] || matchedInputTokens[0] === firstTextualInputToken) score *= 2
|
||||
if (matchedInputTokens.length === displaynameTokens.length) score += 1
|
||||
return {score, matchedInputTokens}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @returns {Token[]}
|
||||
*/
|
||||
function tokenise(name) {
|
||||
name = name.replaceAll("\ufe0f", "").normalize().toLowerCase()
|
||||
let index = 0
|
||||
let result = []
|
||||
for (const part of name.split(/(_|\s|\b)/g)) {
|
||||
if (part.trim()) {
|
||||
result.push({text: part, index, end: index + part.length})
|
||||
}
|
||||
index += part.length
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{mxid: string, displayname?: string | null}[]} joined
|
||||
* @returns {ProcessedJoined}
|
||||
*/
|
||||
function processJoined(joined) {
|
||||
joined = joined.filter(j => !userRegex.some(rx => j.mxid.match(rx)))
|
||||
return {
|
||||
mxids: joined.map(j => {
|
||||
const localpart = j.mxid.match(/@([^:]*)/)
|
||||
assert(localpart)
|
||||
return {
|
||||
localpart: localpart[1].toLowerCase(),
|
||||
mxid: j.mxid,
|
||||
displayname: j.displayname
|
||||
}
|
||||
}),
|
||||
names: joined.filter(j => j.displayname).map(j => {
|
||||
return {
|
||||
// @ts-ignore
|
||||
displaynameTokens: tokenise(j.displayname),
|
||||
mxid: j.mxid
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ProcessedJoined} pjr
|
||||
* @param {string} maximumWrittenSection lowercase please
|
||||
* @param {number} baseOffset
|
||||
* @param {string} prefix
|
||||
* @param {string} content
|
||||
*/
|
||||
function findMention(pjr, maximumWrittenSection, baseOffset, prefix, content) {
|
||||
if (!pjr.mxids.length && !pjr.names.length) return
|
||||
const maximumWrittenSectionTokens = tokenise(maximumWrittenSection)
|
||||
/** @type {{mxid: string, scored: {score: number, matchedInputTokens: Token[]}}[]} */
|
||||
let allItems = pjr.mxids.map(mxid => ({...mxid, scored: scoreLocalpart(mxid.localpart, maximumWrittenSection, mxid.displayname)}))
|
||||
allItems = allItems.concat(pjr.names.map(name => ({...name, scored: scoreName(name.displaynameTokens, maximumWrittenSectionTokens)})))
|
||||
const best = allItems.sort((a, b) => b.scored.score - a.scored.score)[0]
|
||||
if (best.scored.score > 4) { // requires in smallest case perfect match of 2 characters, or in largest case a partial middle match of 5+ characters in a row
|
||||
// Highlight the relevant part of the message
|
||||
const start = baseOffset + best.scored.matchedInputTokens[0].index
|
||||
const end = baseOffset + prefix.length + best.scored.matchedInputTokens.slice(-1)[0].end
|
||||
const newContent = content.slice(0, start) + "[" + content.slice(start, end) + "](https://matrix.to/#/" + best.mxid + ")" + content.slice(end)
|
||||
return {
|
||||
mxid: best.mxid,
|
||||
newContent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports.scoreLocalpart = scoreLocalpart
|
||||
module.exports.scoreName = scoreName
|
||||
module.exports.tokenise = tokenise
|
||||
module.exports.processJoined = processJoined
|
||||
module.exports.findMention = findMention
|
||||
|
|
@ -1,129 +0,0 @@
|
|||
// @ts-check
|
||||
|
||||
const {test} = require("supertape")
|
||||
const {processJoined, scoreLocalpart, scoreName, tokenise, findMention} = require("./find-mentions")
|
||||
|
||||
test("score localpart: score against cadence", t => {
|
||||
const localparts = [
|
||||
"cadence",
|
||||
"cadence_test",
|
||||
"roblkyogre",
|
||||
"cat",
|
||||
"arcade_cabinet"
|
||||
]
|
||||
t.deepEqual(localparts.map(l => scoreLocalpart(l, "cadence").score), [
|
||||
15.5,
|
||||
14,
|
||||
0,
|
||||
4,
|
||||
4
|
||||
])
|
||||
})
|
||||
|
||||
test("score mxid: tiebreak multiple perfect matches on name length", t => {
|
||||
const users = [
|
||||
{displayname: "Emma [it/its] ⚡️", localpart: "emma"},
|
||||
{displayname: "Emma [it/its]", localpart: "emma"}
|
||||
]
|
||||
const results = users.map(u => scoreLocalpart(u.localpart, "emma", u.displayname).score)
|
||||
t.ok(results[0] < results[1], `comparison: ${results.join(" < ")}`)
|
||||
})
|
||||
|
||||
test("score name: score against cadence", t => {
|
||||
const names = [
|
||||
"bgt lover",
|
||||
"Ash 🦑 (xey/it)",
|
||||
"Cadence, Maid of Creation, Eye of Clarity, Empress of Hope ☆",
|
||||
"underscore_idiot #sunshine",
|
||||
"INX | Evil Lillith (she/her)",
|
||||
"INX | Lillith (she/her)",
|
||||
"🌟luna🌟",
|
||||
"#1 Ritsuko Kinnie"
|
||||
]
|
||||
t.deepEqual(names.map(n => scoreName(tokenise(n), tokenise("cadence")).score), [
|
||||
0,
|
||||
0,
|
||||
14,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
])
|
||||
})
|
||||
|
||||
test("score name: nothing scored after a token doesn't match", t => {
|
||||
const names = [
|
||||
"bgt lover",
|
||||
"Ash 🦑 (xey/it)",
|
||||
"Cadence, Maid of Creation, Eye of Clarity, Empress of Hope ☆",
|
||||
"underscore_idiot #sunshine",
|
||||
"INX | Evil Lillith (she/her)",
|
||||
"INX | Lillith (she/her)",
|
||||
"🌟luna🌟",
|
||||
"#1 Ritsuko Kinnie"
|
||||
]
|
||||
t.deepEqual(names.map(n => scoreName(tokenise(n), tokenise("I hope so")).score), [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
])
|
||||
})
|
||||
|
||||
test("score name: prefers earlier match", t => {
|
||||
const names = [
|
||||
"INX | Lillith (she/her)",
|
||||
"INX | Evil Lillith (she/her)"
|
||||
]
|
||||
const results = names.map(n => scoreName(tokenise(n), tokenise("lillith")).score)
|
||||
t.ok(results[0] > results[1], `comparison: ${results.join(" > ")}`)
|
||||
})
|
||||
|
||||
test("score name: matches lots of tokens", t => {
|
||||
t.deepEqual(
|
||||
Math.round(scoreName(tokenise("Cadence, Maid of Creation, Eye of Clarity, Empress of Hope ☆"), tokenise("cadence maid of creation eye of clarity empress of hope")).score),
|
||||
65
|
||||
)
|
||||
})
|
||||
|
||||
test("score name: prefers variation when you specify it", t => {
|
||||
const names = [
|
||||
"Cadence (test account)",
|
||||
"Cadence"
|
||||
]
|
||||
const results = names.map(n => scoreName(tokenise(n), tokenise("cadence test")).score)
|
||||
t.ok(results[0] > results[1], `comparison: ${results.join(" > ")}`)
|
||||
})
|
||||
|
||||
test("score name: prefers original when not specified", t => {
|
||||
const names = [
|
||||
"Cadence (test account)",
|
||||
"Cadence"
|
||||
]
|
||||
const results = names.map(n => scoreName(tokenise(n), tokenise("cadence")).score)
|
||||
t.ok(results[0] < results[1], `comparison: ${results.join(" < ")}`)
|
||||
})
|
||||
|
||||
test("score name: finds match location", t => {
|
||||
const message = "evil lillith is an inspiration"
|
||||
const result = scoreName(tokenise("INX | Evil Lillith (she/her)"), tokenise(message))
|
||||
const startLocation = result.matchedInputTokens[0].index
|
||||
const endLocation = result.matchedInputTokens.slice(-1)[0].end
|
||||
t.equal(message.slice(startLocation, endLocation), "evil lillith")
|
||||
})
|
||||
|
||||
test("find mention: test various tiebreakers", t => {
|
||||
const found = findMention(processJoined([{
|
||||
mxid: "@emma:conduit.rory.gay",
|
||||
displayname: "Emma [it/its] ⚡️"
|
||||
}, {
|
||||
mxid: "@emma:rory.gay",
|
||||
displayname: "Emma [it/its]"
|
||||
}]), "emma ⚡ curious which one this prefers", 0, "@", "@emma ⚡ curious which one this prefers")
|
||||
t.equal(found?.mxid, "@emma:conduit.rory.gay")
|
||||
})
|
||||
|
|
@ -1,34 +1,26 @@
|
|||
const {test} = require("supertape")
|
||||
const {messageToEvent} = require("./message-to-event")
|
||||
const data = require("../../../test/data")
|
||||
const {mockGetEffectivePower} = require("../../matrix/utils.test")
|
||||
const {db} = require("../../passthrough")
|
||||
|
||||
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...",
|
||||
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>",
|
||||
"m.mentions": {},
|
||||
msgtype: "m.notice",
|
||||
}])
|
||||
})
|
||||
|
||||
test("message2event embeds: nothing but a field", async t => {
|
||||
const events = await messageToEvent(data.message_with_embeds.nothing_but_a_field, data.guild.general, {})
|
||||
t.deepEqual(events, [{
|
||||
$type: "m.room.message",
|
||||
body: "> ↪️ @papiophidian: used `/stats`",
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: "<blockquote>↪️ <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">@papiophidian</a> used <code>/stats</code></blockquote>",
|
||||
"m.mentions": {},
|
||||
msgtype: "m.text",
|
||||
}, {
|
||||
$type: "m.room.message",
|
||||
"m.mentions": {},
|
||||
msgtype: "m.notice",
|
||||
body: "↪️ PapiOphidian used `/stats`"
|
||||
+ "\n| ### Amanda 🎵#2192 :online:"
|
||||
body: "| ### 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>'
|
||||
+ '<blockquote><p><strong>Amanda 🎵#2192 <img data-mx-emoticon height=\"32\" src=\"mxc://cadence.moe/LCEqjStXCxvRQccEkuslXEyZ\" title=\":online:\" alt=\":online:\">'
|
||||
formatted_body: '<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'
|
||||
+ '<br><strong>❯ Memory:</strong><br>64.45MB</p></blockquote>'
|
||||
|
|
@ -94,7 +86,17 @@ test("message2event embeds: blockquote in embed", async t => {
|
|||
let called = 0
|
||||
const events = await messageToEvent(data.message_with_embeds.blockquote_in_embed, data.guild.general, {}, {
|
||||
api: {
|
||||
getEffectivePower: mockGetEffectivePower(),
|
||||
async getStateEvent(roomID, type, key) {
|
||||
called++
|
||||
t.equal(roomID, "!qzDBLKlildpzrrOnFZ:cadence.moe")
|
||||
t.equal(type, "m.room.power_levels")
|
||||
t.equal(key, "")
|
||||
return {
|
||||
users: {
|
||||
"@_ooye_bot:cadence.moe": 100
|
||||
}
|
||||
}
|
||||
},
|
||||
async getJoinedMembers(roomID) {
|
||||
called++
|
||||
t.equal(roomID, "!qzDBLKlildpzrrOnFZ:cadence.moe")
|
||||
|
|
@ -122,7 +124,7 @@ test("message2event embeds: blockquote in embed", async t => {
|
|||
formatted_body: "<blockquote><p><strong><a href=\"https://matrix.to/#/!qzDBLKlildpzrrOnFZ:cadence.moe/$dVCLyj6kxb3DaAWDtjcv2kdSny8JMMHdDhCMz8mDxVo?via=cadence.moe&via=example.invalid\">⏺️ minimus</a></strong></p><p>reply draft<br><blockquote>The following is a message composed via consensus of the Stinker Council.<br><br>For those who are not currently aware of our existence, we represent the organization known as Wonderland. Our previous mission centered around the assortment and study of puzzling objects, entities and other assorted phenomena. This mission was the focus of our organization for more than 28 years.<br><br>Due to circumstances outside of our control, this directive has now changed. Our new mission will be the extermination of the stinker race.<br><br>There will be no further communication.</blockquote></p><p><a href=\"https://matrix.to/#/!qzDBLKlildpzrrOnFZ:cadence.moe/$dVCLyj6kxb3DaAWDtjcv2kdSny8JMMHdDhCMz8mDxVo?via=cadence.moe&via=example.invalid\">Go to Message</a></p></blockquote>",
|
||||
"m.mentions": {}
|
||||
}])
|
||||
t.equal(called, 1, "should call getJoinedMembers once")
|
||||
t.equal(called, 2, "should call getStateEvent and getJoinedMembers once each")
|
||||
})
|
||||
|
||||
test("message2event embeds: crazy html is all escaped", async t => {
|
||||
|
|
@ -152,12 +154,17 @@ test("message2event embeds: title without url", async t => {
|
|||
const events = await messageToEvent(data.message_with_embeds.title_without_url, data.guild.general)
|
||||
t.deepEqual(events, [{
|
||||
$type: "m.room.message",
|
||||
msgtype: "m.notice",
|
||||
body: "↪️ PapiOphidian used `/stats`"
|
||||
+ "\n| ## Hi, I'm Amanda!\n| \n| I condone pirating music!",
|
||||
body: "> ↪️ @papiophidian: used `/stats`",
|
||||
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>'
|
||||
+ `<blockquote><p><strong>Hi, I'm Amanda!</strong></p><p>I condone pirating music!</p></blockquote>`,
|
||||
formatted_body: "<blockquote>↪️ <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">@papiophidian</a> used <code>/stats</code></blockquote>",
|
||||
"m.mentions": {},
|
||||
msgtype: "m.text",
|
||||
}, {
|
||||
$type: "m.room.message",
|
||||
msgtype: "m.notice",
|
||||
body: "| ## Hi, I'm Amanda!\n| \n| I condone pirating music!",
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: `<blockquote><p><strong>Hi, I'm Amanda!</strong></p><p>I condone pirating music!</p></blockquote>`,
|
||||
"m.mentions": {}
|
||||
}])
|
||||
})
|
||||
|
|
@ -166,12 +173,17 @@ test("message2event embeds: url without title", async t => {
|
|||
const events = await messageToEvent(data.message_with_embeds.url_without_title, data.guild.general)
|
||||
t.deepEqual(events, [{
|
||||
$type: "m.room.message",
|
||||
msgtype: "m.notice",
|
||||
body: "↪️ PapiOphidian used `/stats`"
|
||||
+ "\n| I condone pirating music!",
|
||||
body: "> ↪️ @papiophidian: used `/stats`",
|
||||
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>'
|
||||
+ `<blockquote><p>I condone pirating music!</p></blockquote>`,
|
||||
formatted_body: "<blockquote>↪️ <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">@papiophidian</a> used <code>/stats</code></blockquote>",
|
||||
"m.mentions": {},
|
||||
msgtype: "m.text",
|
||||
}, {
|
||||
$type: "m.room.message",
|
||||
msgtype: "m.notice",
|
||||
body: "| I condone pirating music!",
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: `<blockquote><p>I condone pirating music!</p></blockquote>`,
|
||||
"m.mentions": {}
|
||||
}])
|
||||
})
|
||||
|
|
@ -180,12 +192,17 @@ test("message2event embeds: author without url", async t => {
|
|||
const events = await messageToEvent(data.message_with_embeds.author_without_url, data.guild.general)
|
||||
t.deepEqual(events, [{
|
||||
$type: "m.room.message",
|
||||
msgtype: "m.notice",
|
||||
body: "↪️ PapiOphidian used `/stats`"
|
||||
+ "\n| ## Amanda\n| \n| I condone pirating music!",
|
||||
body: "> ↪️ @papiophidian: used `/stats`",
|
||||
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>'
|
||||
+ `<blockquote><p><strong>Amanda</strong></p><p>I condone pirating music!</p></blockquote>`,
|
||||
formatted_body: "<blockquote>↪️ <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">@papiophidian</a> used <code>/stats</code></blockquote>",
|
||||
"m.mentions": {},
|
||||
msgtype: "m.text",
|
||||
}, {
|
||||
$type: "m.room.message",
|
||||
msgtype: "m.notice",
|
||||
body: "| ## Amanda\n| \n| I condone pirating music!",
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: `<blockquote><p><strong>Amanda</strong></p><p>I condone pirating music!</p></blockquote>`,
|
||||
"m.mentions": {}
|
||||
}])
|
||||
})
|
||||
|
|
@ -194,12 +211,17 @@ test("message2event embeds: author url without name", async t => {
|
|||
const events = await messageToEvent(data.message_with_embeds.author_url_without_name, data.guild.general)
|
||||
t.deepEqual(events, [{
|
||||
$type: "m.room.message",
|
||||
msgtype: "m.notice",
|
||||
body: "↪️ PapiOphidian used `/stats`"
|
||||
+ "\n| I condone pirating music!",
|
||||
body: "> ↪️ @papiophidian: used `/stats`",
|
||||
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>'
|
||||
+ `<blockquote><p>I condone pirating music!</p></blockquote>`,
|
||||
formatted_body: "<blockquote>↪️ <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">@papiophidian</a> used <code>/stats</code></blockquote>",
|
||||
"m.mentions": {},
|
||||
msgtype: "m.text",
|
||||
}, {
|
||||
$type: "m.room.message",
|
||||
msgtype: "m.notice",
|
||||
body: "| I condone pirating music!",
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: `<blockquote><p>I condone pirating music!</p></blockquote>`,
|
||||
"m.mentions": {}
|
||||
}])
|
||||
})
|
||||
|
|
@ -299,21 +321,6 @@ test("message2event embeds: youtube video", async t => {
|
|||
}])
|
||||
})
|
||||
|
||||
test("message2event embeds: embed not bridged if its link was spoilered", async t => {
|
||||
const events = await messageToEvent({
|
||||
...data.message_with_embeds.youtube_video,
|
||||
content: "||https://youtu.be/kDMHHw8JqLE?si=NaqNjVTtXugHeG_E\n\n\nJutomi I'm gonna make these sounds in your walls tonight||"
|
||||
}, data.guild.general)
|
||||
t.deepEqual(events, [{
|
||||
$type: "m.room.message",
|
||||
msgtype: "m.text",
|
||||
body: "[spoiler]",
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: `<span data-mx-spoiler=""><a href="https://youtu.be/kDMHHw8JqLE?si=NaqNjVTtXugHeG_E">https://youtu.be/kDMHHw8JqLE?si=NaqNjVTtXugHeG_E</a><br><br><br>Jutomi I'm gonna make these sounds in your walls tonight</span>`,
|
||||
"m.mentions": {}
|
||||
}])
|
||||
})
|
||||
|
||||
test("message2event embeds: tenor gif should show a video link without a provider", async t => {
|
||||
const events = await messageToEvent(data.message_with_embeds.tenor_gif, data.guild.general, {}, {})
|
||||
t.deepEqual(events, [{
|
||||
|
|
@ -333,22 +340,19 @@ test("message2event embeds: tenor gif should show a video link without a provide
|
|||
}])
|
||||
})
|
||||
|
||||
test("message2event embeds: klipy gif should send in customised format", async t => {
|
||||
const events = await messageToEvent(data.message_with_embeds.klipy_gif, data.guild.general, {}, {})
|
||||
t.deepEqual(events, [{
|
||||
$type: "m.room.message",
|
||||
msgtype: "m.text",
|
||||
body: "[GIF] Cute Corgi Waddle https://static.klipy.com/ii/d7aec6f6f171607374b2065c836f92f4/5b/5b/7ndEhcilPNKJ8O.mp4",
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: "<blockquote>➿ <a href=\"https://static.klipy.com/ii/d7aec6f6f171607374b2065c836f92f4/5b/5b/7ndEhcilPNKJ8O.mp4\">Cute Corgi Waddle</a></blockquote>",
|
||||
"m.mentions": {}
|
||||
}])
|
||||
})
|
||||
|
||||
test("message2event embeds: if discord creates an embed preview for a discord channel link, don't copy that embed", async t => {
|
||||
const events = await messageToEvent(data.message_with_embeds.discord_server_included_punctuation_bad_discord, data.guild.general, {}, {
|
||||
api: {
|
||||
getEffectivePower: mockGetEffectivePower(),
|
||||
async getStateEvent(roomID, type, key) {
|
||||
t.equal(roomID, "!TqlyQmifxGUggEmdBN:cadence.moe")
|
||||
t.equal(type, "m.room.power_levels")
|
||||
t.equal(key, "")
|
||||
return {
|
||||
users: {
|
||||
"@_ooye_bot:cadence.moe": 100
|
||||
}
|
||||
}
|
||||
},
|
||||
async getJoinedMembers(roomID) {
|
||||
t.equal(roomID, "!TqlyQmifxGUggEmdBN:cadence.moe")
|
||||
return {
|
||||
|
|
@ -14,23 +14,20 @@ const file = sync.require("../../matrix/file")
|
|||
const emojiToKey = sync.require("./emoji-to-key")
|
||||
/** @type {import("../actions/lottie")} */
|
||||
const lottie = sync.require("../actions/lottie")
|
||||
/** @type {import("../../matrix/utils")} */
|
||||
const mxUtils = sync.require("../../matrix/utils")
|
||||
/** @type {import("../../m2d/converters/utils")} */
|
||||
const mxUtils = sync.require("../../m2d/converters/utils")
|
||||
/** @type {import("../../discord/utils")} */
|
||||
const dUtils = sync.require("../../discord/utils")
|
||||
/** @type {import("./find-mentions")} */
|
||||
const findMentions = sync.require("./find-mentions")
|
||||
/** @type {import("../../discord/interactions/poll-responses")} */
|
||||
const pollResponses = sync.require("../../discord/interactions/poll-responses")
|
||||
const {reg} = require("../../matrix/read-registration")
|
||||
|
||||
const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex))
|
||||
|
||||
/**
|
||||
* @param {DiscordTypes.APIMessage} message
|
||||
* @param {DiscordTypes.APIGuild} guild
|
||||
* @param {boolean} useHTML
|
||||
* @param {string[]} spoilers
|
||||
*/
|
||||
function getDiscordParseCallbacks(message, guild, useHTML, spoilers = []) {
|
||||
function getDiscordParseCallbacks(message, guild, useHTML) {
|
||||
return {
|
||||
/** @param {{id: string, type: "discordUser"}} node */
|
||||
user: node => {
|
||||
|
|
@ -39,8 +36,8 @@ function getDiscordParseCallbacks(message, guild, useHTML, spoilers = []) {
|
|||
const username = message.mentions?.find(ment => ment.id === node.id)?.username
|
||||
|| message.referenced_message?.mentions?.find(ment => ment.id === node.id)?.username
|
||||
|| (interaction?.user.id === node.id ? interaction.user.username : null)
|
||||
|| (message.author?.id === node.id ? message.author.username : null)
|
||||
|| "unknown-user"
|
||||
|| (message.author.id === node.id ? message.author.username : null)
|
||||
|| node.id
|
||||
if (mxid && useHTML) {
|
||||
return `<a href="https://matrix.to/#/${mxid}">@${username}</a>`
|
||||
} else {
|
||||
|
|
@ -93,10 +90,6 @@ function getDiscordParseCallbacks(message, guild, useHTML, spoilers = []) {
|
|||
here: () => {
|
||||
if (message.mention_everyone) return "@room"
|
||||
return "@here"
|
||||
},
|
||||
spoiler: node => {
|
||||
spoilers.push(node.raw)
|
||||
return useHTML
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -109,10 +102,9 @@ const embedTitleParser = markdown.markdownEngine.parserFor({
|
|||
|
||||
/**
|
||||
* @param {{room?: boolean, user_ids?: string[]}} mentions
|
||||
* @param {Omit<DiscordTypes.APIAttachment, "id" | "proxy_url">} attachment
|
||||
* @param {boolean} [alwaysLink]
|
||||
* @param {DiscordTypes.APIAttachment} attachment
|
||||
*/
|
||||
async function attachmentToEvent(mentions, attachment, alwaysLink) {
|
||||
async function attachmentToEvent(mentions, attachment) {
|
||||
const external_url = dUtils.getPublicUrlForCdn(attachment.url)
|
||||
const emoji =
|
||||
attachment.content_type?.startsWith("image/jp") ? "📸"
|
||||
|
|
@ -133,7 +125,7 @@ async function attachmentToEvent(mentions, attachment, alwaysLink) {
|
|||
}
|
||||
}
|
||||
// for large files, always link them instead of uploading so I don't use up all the space in the content repo
|
||||
else if (alwaysLink || attachment.size > reg.ooye.max_file_size) {
|
||||
else if (attachment.size > reg.ooye.max_file_size) {
|
||||
return {
|
||||
$type: "m.room.message",
|
||||
"m.mentions": mentions,
|
||||
|
|
@ -186,7 +178,7 @@ async function attachmentToEvent(mentions, attachment, alwaysLink) {
|
|||
info: {
|
||||
mimetype: attachment.content_type,
|
||||
size: attachment.size,
|
||||
duration: attachment.duration_secs && Math.round(attachment.duration_secs * 1000)
|
||||
duration: attachment.duration_secs ? Math.round(attachment.duration_secs * 1000) : undefined
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
|
@ -206,61 +198,6 @@ async function attachmentToEvent(mentions, attachment, alwaysLink) {
|
|||
}
|
||||
}
|
||||
|
||||
/** @param {DiscordTypes.APIPoll} poll */
|
||||
async function pollToEvent(poll) {
|
||||
let fallbackText = poll.question.text
|
||||
if (poll.allow_multiselect) {
|
||||
var maxSelections = poll.answers.length;
|
||||
} else {
|
||||
var maxSelections = 1;
|
||||
}
|
||||
let answers = poll.answers.map(answer=>{
|
||||
let matrixText = answer.poll_media.text
|
||||
if (answer.poll_media.emoji) {
|
||||
if (answer.poll_media.emoji.id) {
|
||||
// Custom emoji. It seems like no Matrix client allows custom emoji in poll answers, so leaving this unimplemented.
|
||||
} else {
|
||||
matrixText = "[" + answer.poll_media.emoji.name + "] " + matrixText
|
||||
}
|
||||
}
|
||||
let matrixAnswer = {
|
||||
id: answer.answer_id.toString(),
|
||||
"org.matrix.msc1767.text": matrixText
|
||||
}
|
||||
fallbackText = fallbackText + "\n" + answer.answer_id.toString() + ". " + matrixText
|
||||
return matrixAnswer;
|
||||
})
|
||||
return {
|
||||
/** @type {"org.matrix.msc3381.poll.start"} */
|
||||
$type: "org.matrix.msc3381.poll.start",
|
||||
"org.matrix.msc3381.poll.start": {
|
||||
question: {
|
||||
"org.matrix.msc1767.text": poll.question.text,
|
||||
body: poll.question.text,
|
||||
msgtype: "m.text"
|
||||
},
|
||||
kind: "org.matrix.msc3381.poll.disclosed", // Discord always lets you see results, so keeping this consistent with that.
|
||||
max_selections: maxSelections,
|
||||
answers: answers
|
||||
},
|
||||
"org.matrix.msc1767.text": fallbackText
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DiscordTypes.APIMessageInteraction} interaction
|
||||
* @param {boolean} isThinkingInteraction
|
||||
*/
|
||||
function getFormattedInteraction(interaction, isThinkingInteraction) {
|
||||
const mxid = select("sim", "mxid", {user_id: interaction.user.id}).pluck().get()
|
||||
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>`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DiscordTypes.APIMessage} message
|
||||
* @param {DiscordTypes.APIGuild} guild
|
||||
|
|
@ -273,7 +210,6 @@ function getFormattedInteraction(interaction, isThinkingInteraction) {
|
|||
* @returns {Promise<{$type: string, $sender?: string, [x: string]: any}[]>}
|
||||
*/
|
||||
async function messageToEvent(message, guild, options = {}, di) {
|
||||
message = structuredClone(message)
|
||||
const events = []
|
||||
|
||||
/* c8 ignore next 7 */
|
||||
|
|
@ -285,38 +221,6 @@ async function messageToEvent(message, guild, options = {}, di) {
|
|||
return []
|
||||
}
|
||||
|
||||
if (message.type === DiscordTypes.MessageType.PollResult) {
|
||||
const pollMessageID = message.message_reference?.message_id
|
||||
if (!pollMessageID) return []
|
||||
const event_id = select("event_message", "event_id", {message_id: pollMessageID}).pluck().get()
|
||||
const roomID = select("channel_room", "room_id", {channel_id: message.channel_id}).pluck().get()
|
||||
const pollQuestionText = select("poll", "question_text", {message_id: pollMessageID}).pluck().get()
|
||||
if (!event_id || !roomID || !pollQuestionText) return [] // drop it if the corresponding poll start was not bridged
|
||||
|
||||
const rep = new mxUtils.MatrixStringBuilder()
|
||||
rep.addLine(`The poll ${pollQuestionText} has closed.`, tag`The poll <a href="https://matrix.to/#/${roomID}/${event_id}">${pollQuestionText}</a> has closed.`)
|
||||
|
||||
const {messageString} = pollResponses.getCombinedResults(pollMessageID, true) // poll results have already been double-checked before this point, so these totals will be accurate
|
||||
rep.addLine(markdown.toHTML(messageString, {discordOnly: true, escapeHTML: false}), markdown.toHTML(messageString, {}))
|
||||
|
||||
const {body, formatted_body} = rep.get()
|
||||
|
||||
return [{
|
||||
$type: "org.matrix.msc3381.poll.end",
|
||||
"m.relates_to": {
|
||||
rel_type: "m.reference",
|
||||
event_id
|
||||
},
|
||||
"org.matrix.msc3381.poll.end": {},
|
||||
"org.matrix.msc1767.text": body,
|
||||
"org.matrix.msc1767.html": formatted_body,
|
||||
body: body,
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: formatted_body,
|
||||
msgtype: "m.text"
|
||||
}]
|
||||
}
|
||||
|
||||
if (message.type === DiscordTypes.MessageType.ThreadStarterMessage) {
|
||||
// This is the message that appears at the top of a thread when the thread was based off an existing message.
|
||||
// It's just a message reference, no content.
|
||||
|
|
@ -335,8 +239,14 @@ async function messageToEvent(message, guild, options = {}, di) {
|
|||
}
|
||||
|
||||
const interaction = message.interaction_metadata || message.interaction
|
||||
const isInteraction = message.type === DiscordTypes.MessageType.ChatInputCommand && !!interaction && "name" in interaction
|
||||
const isThinkingInteraction = isInteraction && !!((message.flags || 0) & DiscordTypes.MessageFlags.Loading)
|
||||
if (message.type === DiscordTypes.MessageType.ChatInputCommand && interaction && "name" in interaction) {
|
||||
// Commands are sent by the responding bot. Need to attach the metadata of the person using the command at the top.
|
||||
let content = message.content
|
||||
if (content) content = `\n${content}`
|
||||
else if ((message.flags || 0) & DiscordTypes.MessageFlags.Loading) content = " — interaction loading..."
|
||||
content = `> ↪️ <@${interaction.user.id}> used \`/${interaction.name}\`${content}`
|
||||
message = {...message, content} // editToChanges reuses the object so we can't mutate it. have to clone it
|
||||
}
|
||||
|
||||
/**
|
||||
@type {{room?: boolean, user_ids?: string[]}}
|
||||
|
|
@ -355,9 +265,8 @@ async function messageToEvent(message, guild, options = {}, di) {
|
|||
- So make sure we don't do anything in this case.
|
||||
*/
|
||||
const mentions = {}
|
||||
/** @type {{event_id: string, room_id: string, source: number, channel_id: string}?} */
|
||||
/** @type {{event_id: string, room_id: string, source: number}?} */
|
||||
let repliedToEventRow = null
|
||||
let repliedToEventInDifferentRoom = false
|
||||
let repliedToUnknownEvent = false
|
||||
let repliedToEventSenderMxid = null
|
||||
|
||||
|
|
@ -371,9 +280,9 @@ async function messageToEvent(message, guild, options = {}, di) {
|
|||
// Mentions scenarios 1 and 2, part A. i.e. translate relevant message.mentions to m.mentions
|
||||
// (Still need to do scenarios 1 and 2 part B, and scenario 3.)
|
||||
if (message.type === DiscordTypes.MessageType.Reply && message.message_reference?.message_id) {
|
||||
const row = await getHistoricalEventRow(message.message_reference?.message_id)
|
||||
if (row && "event_id" in row) {
|
||||
repliedToEventRow = Object.assign(row, {channel_id: row.reference_channel_id})
|
||||
const row = from("event_message").join("message_channel", "message_id").join("channel_room", "channel_id").select("event_id", "room_id", "source").and("WHERE message_id = ? AND part = 0").get(message.message_reference.message_id)
|
||||
if (row) {
|
||||
repliedToEventRow = row
|
||||
} else if (message.referenced_message) {
|
||||
repliedToUnknownEvent = true
|
||||
}
|
||||
|
|
@ -385,8 +294,8 @@ async function messageToEvent(message, guild, options = {}, di) {
|
|||
assert(message.embeds[0].description)
|
||||
const match = message.embeds[0].description.match(/\/channels\/[0-9]*\/[0-9]*\/([0-9]{2,})/)
|
||||
if (match) {
|
||||
const row = await getHistoricalEventRow(match[1])
|
||||
if (row && "event_id" in row) {
|
||||
const row = from("event_message").join("message_channel", "message_id").join("channel_room", "channel_id").select("event_id", "room_id", "source").and("WHERE message_id = ? AND part = 0").get(match[1])
|
||||
if (row) {
|
||||
/*
|
||||
we generate a partial referenced_message based on what PK provided. we don't need everything, since this will only be used for further message-to-event converting.
|
||||
the following properties are necessary:
|
||||
|
|
@ -404,7 +313,7 @@ async function messageToEvent(message, guild, options = {}, di) {
|
|||
}
|
||||
}
|
||||
message.embeds.shift()
|
||||
repliedToEventRow = Object.assign(row, {channel_id: row.reference_channel_id})
|
||||
repliedToEventRow = row
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -431,34 +340,6 @@ async function messageToEvent(message, guild, options = {}, di) {
|
|||
return promise
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} messageID
|
||||
* @param {string} [timestampChannelID]
|
||||
*/
|
||||
async function getHistoricalEventRow(messageID, timestampChannelID) {
|
||||
/** @type {{room_id: string} | {event_id: string, room_id: string, reference_channel_id: string, source: number} | null | undefined} */
|
||||
let row = from("event_message").join("message_room", "message_id").join("historical_channel_room", "historical_room_index")
|
||||
.select("event_id", "room_id", "reference_channel_id", "source").where({message_id: messageID}).and("ORDER BY part ASC").get()
|
||||
if (!row && timestampChannelID) {
|
||||
const ts = dUtils.snowflakeToTimestampExact(messageID)
|
||||
const oldestRow = from("historical_channel_room").selectUnsafe("max(upgraded_timestamp)", "room_id")
|
||||
.where({reference_channel_id: timestampChannelID}).and("and upgraded_timestamp < ?").get(ts)
|
||||
if (oldestRow?.room_id) {
|
||||
row = {room_id: oldestRow.room_id}
|
||||
try {
|
||||
const {event_id} = await di.api.getEventForTimestamp(oldestRow.room_id, ts)
|
||||
row = {
|
||||
event_id,
|
||||
room_id: oldestRow.room_id,
|
||||
reference_channel_id: oldestRow.reference_channel_id,
|
||||
source: 1
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate Discord message links to Matrix event links.
|
||||
* If OOYE has handled this message in the past, this is an instant database lookup.
|
||||
|
|
@ -470,13 +351,27 @@ async function messageToEvent(message, guild, options = {}, di) {
|
|||
for (const match of [...content.matchAll(/https:\/\/(?:ptb\.|canary\.|www\.)?discord(?:app)?\.com\/channels\/[0-9]+\/([0-9]+)\/([0-9]+)/g)]) {
|
||||
assert(typeof match.index === "number")
|
||||
const [_, channelID, messageID] = match
|
||||
const result = await (async () => {
|
||||
const row = await getHistoricalEventRow(messageID, channelID)
|
||||
if (!row) return `${match[0]} [event is from another server]`
|
||||
const via = await getViaServersMemo(row.room_id)
|
||||
if (!("event_id" in row)) return `[unknown event in https://matrix.to/#/${row.room_id}?${via}]`
|
||||
return `https://matrix.to/#/${row.room_id}/${row.event_id}?${via}`
|
||||
})()
|
||||
let result
|
||||
|
||||
const roomID = select("channel_room", "room_id", {channel_id: channelID}).pluck().get()
|
||||
if (roomID) {
|
||||
const eventID = select("event_message", "event_id", {message_id: messageID}).pluck().get()
|
||||
const via = await getViaServersMemo(roomID)
|
||||
if (eventID && roomID) {
|
||||
result = `https://matrix.to/#/${roomID}/${eventID}?${via}`
|
||||
} else {
|
||||
const ts = dUtils.snowflakeToTimestampExact(messageID)
|
||||
try {
|
||||
const {event_id} = await di.api.getEventForTimestamp(roomID, ts)
|
||||
result = `https://matrix.to/#/${roomID}/${event_id}?${via}`
|
||||
} catch (e) {
|
||||
// M_NOT_FOUND: Unable to find event from <ts> in direction Direction.FORWARDS
|
||||
result = `[unknown event, timestamp resolution failed, in room: https://matrix.to/#/${roomID}?${via}]`
|
||||
}
|
||||
}
|
||||
} else {
|
||||
result = `${match[0]} [event is from another server]`
|
||||
}
|
||||
|
||||
content = content.slice(0, match.index + offset) + result + content.slice(match.index + match[0].length + offset)
|
||||
offset += result.length - match[0].length
|
||||
|
|
@ -491,7 +386,6 @@ async function messageToEvent(message, guild, options = {}, di) {
|
|||
return content.replace(/https:\/\/(cdn|media)\.discordapp\.(?:com|net)\/attachments\/([0-9]+)\/([0-9]+)\/([-A-Za-z0-9_.,]+)/g, url => dUtils.getPublicUrlForCdn(url))
|
||||
}
|
||||
|
||||
const spoilers = []
|
||||
/**
|
||||
* Translate links and emojis and mentions and stuff. Give back the text and HTML so they can be combined into bigger events.
|
||||
* @param {string} content Partial or complete Discord message content
|
||||
|
|
@ -503,12 +397,6 @@ async function messageToEvent(message, guild, options = {}, di) {
|
|||
content = transformAttachmentLinks(content)
|
||||
content = await transformContentMessageLinks(content)
|
||||
|
||||
// Remove smalltext from non-bots (I don't like it). Webhooks included due to PluralKit.
|
||||
const isHumanOrDataMissing = !message.author?.bot
|
||||
if (isHumanOrDataMissing || dUtils.isWebhookMessage(message)) {
|
||||
content = content.replaceAll(/^-# +([^\n].*?)/gm, "...$1")
|
||||
}
|
||||
|
||||
// Handling emojis that we don't know about. The emoji has to be present in the DB for it to be picked up in the emoji markdown converter.
|
||||
// So we scan the message ahead of time for all its emojis and ensure they are in the DB.
|
||||
const emojiMatches = [...content.matchAll(/<(a?):([^:>]{1,64}):([0-9]+)>/g)]
|
||||
|
|
@ -537,7 +425,7 @@ async function messageToEvent(message, guild, options = {}, di) {
|
|||
}
|
||||
|
||||
let html = await markdown.toHtmlWithPostParser(content, transformParsedVia, {
|
||||
discordCallback: getDiscordParseCallbacks(message, guild, true, spoilers),
|
||||
discordCallback: getDiscordParseCallbacks(message, guild, true),
|
||||
...customOptions
|
||||
}, customParser, customHtmlOutput)
|
||||
|
||||
|
|
@ -571,78 +459,65 @@ async function messageToEvent(message, guild, options = {}, di) {
|
|||
}
|
||||
|
||||
// Fallback body/formatted_body for replies
|
||||
// Generate a fallback if native replies are unsupported, which is in the following situations:
|
||||
// 1. The replied-to event is in a different room to where the reply will be sent (i.e. a room upgrade occurred between)
|
||||
// 2. The replied-to message has no corresponding Matrix event (repliedToUnknownEvent is true)
|
||||
// This branch is optional - do NOT change anything apart from the reply fallback, since it may not be run
|
||||
if ((repliedToEventRow || repliedToUnknownEvent) && options.includeReplyFallback !== false && events.length === 0) {
|
||||
const latestRoomID = repliedToEventRow ? select("channel_room", "room_id", {channel_id: repliedToEventRow.channel_id}).pluck().get() : null
|
||||
if (latestRoomID !== repliedToEventRow?.room_id) repliedToEventInDifferentRoom = true
|
||||
|
||||
// check that condition 1 or 2 is met
|
||||
if (repliedToEventInDifferentRoom || repliedToUnknownEvent) {
|
||||
let referenced = message.referenced_message
|
||||
if (!referenced) { // backend couldn't be bothered to dereference the message, have to do it ourselves
|
||||
assert(message.message_reference?.message_id)
|
||||
referenced = await discord.snow.channel.getChannelMessage(message.message_reference.channel_id, message.message_reference.message_id)
|
||||
}
|
||||
|
||||
// Username
|
||||
let repliedToDisplayName
|
||||
let repliedToUserHtml
|
||||
if (repliedToEventRow?.source === 0 && repliedToEventSenderMxid) {
|
||||
const match = repliedToEventSenderMxid.match(/^@([^:]*)/)
|
||||
assert(match)
|
||||
repliedToDisplayName = referenced.author.username || match[1] || "a Matrix user" // grab the localpart as the display name, whatever
|
||||
repliedToUserHtml = `<a href="https://matrix.to/#/${repliedToEventSenderMxid}">${repliedToDisplayName}</a>`
|
||||
} else {
|
||||
repliedToDisplayName = referenced.author.global_name || referenced.author.username || "a Discord user"
|
||||
repliedToUserHtml = repliedToDisplayName
|
||||
}
|
||||
|
||||
// Content
|
||||
let repliedToContent = referenced.content
|
||||
if (repliedToContent?.match(/^(-# )?> (-# )?<?:L1:/)) {
|
||||
// If the Discord user is replying to a Matrix user's reply, the fallback is going to contain the emojis and stuff from the bridged rep of the Matrix user's reply quote.
|
||||
// Need to remove that previous reply rep from this fallback body. The fallbody body should only contain the Matrix user's actual message.
|
||||
// ┌──────A─────┐ A reply rep starting with >quote or -#smalltext >quote. Match until the end of the line.
|
||||
// ┆ ┆┌─B─┐ There may be up to 2 reply rep lines in a row if it was created in the old format. Match all lines.
|
||||
repliedToContent = repliedToContent.replace(/^((-# )?> .*\n){1,2}/, "")
|
||||
}
|
||||
if (repliedToContent == "") repliedToContent = "[Media]"
|
||||
const {body: repliedToBody, html: repliedToHtml} = await transformContent(repliedToContent)
|
||||
|
||||
// Now branch on condition 1 or 2 for a different kind of fallback
|
||||
if (repliedToEventRow) {
|
||||
html = `<blockquote><a href="https://matrix.to/#/${repliedToEventRow.room_id}/${repliedToEventRow.event_id}">In reply to</a> ${repliedToUserHtml}`
|
||||
+ `<br>${repliedToHtml}</blockquote>`
|
||||
+ html
|
||||
body = `${repliedToDisplayName}: ${repliedToBody}`.split("\n").map(line => "> " + line).join("\n") // scenario 1 part B for mentions
|
||||
+ "\n\n" + body
|
||||
} else { // repliedToUnknownEvent
|
||||
const dateDisplay = dUtils.howOldUnbridgedMessage(referenced.timestamp, message.timestamp)
|
||||
html = `<blockquote>In reply to ${dateDisplay} from ${repliedToDisplayName}:`
|
||||
+ `<br>${repliedToHtml}</blockquote>`
|
||||
+ html
|
||||
body = `In reply to ${dateDisplay}:\n${repliedToDisplayName}: ${repliedToBody}`.split("\n").map(line => "> " + line).join("\n")
|
||||
+ "\n\n" + body
|
||||
}
|
||||
if ((repliedToEventRow || repliedToUnknownEvent) && options.includeReplyFallback !== false) {
|
||||
let repliedToDisplayName
|
||||
let repliedToUserHtml
|
||||
if (repliedToEventRow?.source === 0 && repliedToEventSenderMxid) {
|
||||
const match = repliedToEventSenderMxid.match(/^@([^:]*)/)
|
||||
assert(match)
|
||||
repliedToDisplayName = message.referenced_message?.author.username || match[1] || "a Matrix user" // grab the localpart as the display name, whatever
|
||||
repliedToUserHtml = `<a href="https://matrix.to/#/${repliedToEventSenderMxid}">${repliedToDisplayName}</a>`
|
||||
} else {
|
||||
repliedToDisplayName = message.referenced_message?.author.global_name || message.referenced_message?.author.username || "a Discord user"
|
||||
repliedToUserHtml = repliedToDisplayName
|
||||
}
|
||||
let repliedToContent = message.referenced_message?.content
|
||||
if (repliedToContent?.match(/^(-# )?> (-# )?<:L1:/)) {
|
||||
// If the Discord user is replying to a Matrix user's reply, the fallback is going to contain the emojis and stuff from the bridged rep of the Matrix user's reply quote.
|
||||
// Need to remove that previous reply rep from this fallback body. The fallbody body should only contain the Matrix user's actual message.
|
||||
// ┌──────A─────┐ A reply rep starting with >quote or -#smalltext >quote. Match until the end of the line.
|
||||
// ┆ ┆┌─B─┐ There may be up to 2 reply rep lines in a row if it was created in the old format. Match all lines.
|
||||
repliedToContent = repliedToContent.replace(/^((-# )?> .*\n){1,2}/, "")
|
||||
}
|
||||
if (repliedToContent == "") repliedToContent = "[Media]"
|
||||
else if (!repliedToContent) repliedToContent = "[Replied-to message content wasn't provided by Discord]"
|
||||
const {body: repliedToBody, html: repliedToHtml} = await transformContent(repliedToContent)
|
||||
if (repliedToEventRow) {
|
||||
// Generate a reply pointing to the Matrix event we found
|
||||
html = `<mx-reply><blockquote><a href="https://matrix.to/#/${repliedToEventRow.room_id}/${repliedToEventRow.event_id}">In reply to</a> ${repliedToUserHtml}`
|
||||
+ `<br>${repliedToHtml}</blockquote></mx-reply>`
|
||||
+ html
|
||||
body = (`${repliedToDisplayName}: ` // scenario 1 part B for mentions
|
||||
+ repliedToBody).split("\n").map(line => "> " + line).join("\n")
|
||||
+ "\n\n" + body
|
||||
} else { // repliedToUnknownEvent
|
||||
// This reply can't point to the Matrix event because it isn't bridged, we need to indicate this.
|
||||
assert(message.referenced_message)
|
||||
const dateDisplay = dUtils.howOldUnbridgedMessage(message.referenced_message.timestamp, message.timestamp)
|
||||
html = `<blockquote>In reply to ${dateDisplay} from ${repliedToDisplayName}:`
|
||||
+ `<br>${repliedToHtml}</blockquote>`
|
||||
+ html
|
||||
body = (`In reply to ${dateDisplay}:\n${repliedToDisplayName}: `
|
||||
+ repliedToBody).split("\n").map(line => "> " + line).join("\n")
|
||||
+ "\n\n" + body
|
||||
}
|
||||
}
|
||||
|
||||
if (isInteraction && !isThinkingInteraction && events.length === 0) {
|
||||
const formattedInteraction = getFormattedInteraction(interaction, false)
|
||||
body = `${formattedInteraction.body}\n${body}`
|
||||
html = `${formattedInteraction.html}${html}`
|
||||
}
|
||||
|
||||
const newTextMessageEvent = {
|
||||
$type: "m.room.message",
|
||||
"m.mentions": mentions,
|
||||
msgtype,
|
||||
body: body,
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: html
|
||||
body: body
|
||||
}
|
||||
|
||||
const isPlaintext = body === html
|
||||
|
||||
if (!isPlaintext || options.alwaysReturnFormattedBody) {
|
||||
Object.assign(newTextMessageEvent, {
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: html
|
||||
})
|
||||
}
|
||||
|
||||
events.push(newTextMessageEvent)
|
||||
|
|
@ -656,45 +531,24 @@ async function messageToEvent(message, guild, options = {}, di) {
|
|||
message.content = "changed the channel name to **" + message.content + "**"
|
||||
}
|
||||
|
||||
// Handle message type 63, new emoji announcement
|
||||
// @ts-expect-error - should be changed to a DiscordTypes reference once it has been documented
|
||||
if (message.type === 63) {
|
||||
const match = message.content.match(/^<(a?):([^:>]{1,64}):([0-9]+)>$/)
|
||||
assert(match, `message type 63, which announces a new emoji, did not include an emoji. the actual content was: "${message.content}"`)
|
||||
const name = match[2]
|
||||
msgtype = "m.emote"
|
||||
message.content = `added a new emoji, ${message.content} :${name}:`
|
||||
}
|
||||
|
||||
// Send Klipy GIFs in customised form
|
||||
let isKlipyGIF = false
|
||||
let isOnlyKlipyGIF = false
|
||||
if (message.embeds?.length === 1 && message.embeds[0].provider?.name === "Klipy" && message.embeds[0].video?.url) {
|
||||
isKlipyGIF = true
|
||||
if (message.content.match(/^https?:\/\/klipy\.com[^ \n]+$/)) {
|
||||
isOnlyKlipyGIF = true
|
||||
}
|
||||
}
|
||||
|
||||
// Forwarded content appears first
|
||||
if (message.message_reference?.type === DiscordTypes.MessageReferenceType.Forward && message.message_reference.message_id && message.message_snapshots?.length) {
|
||||
if (message.message_reference?.type === DiscordTypes.MessageReferenceType.Forward && message.message_snapshots?.length) {
|
||||
// Forwarded notice
|
||||
const row = await getHistoricalEventRow(message.message_reference.message_id, message.message_reference.channel_id)
|
||||
const eventID = select("event_message", "event_id", {message_id: message.message_reference.message_id}).pluck().get()
|
||||
const room = select("channel_room", ["room_id", "name", "nick"], {channel_id: message.message_reference.channel_id}).get()
|
||||
const forwardedNotice = new mxUtils.MatrixStringBuilder()
|
||||
if (room) {
|
||||
const roomName = room && (room.nick || room.name)
|
||||
if (row && "event_id" in row) {
|
||||
const via = await getViaServersMemo(row.room_id)
|
||||
const via = await getViaServersMemo(room.room_id)
|
||||
if (eventID) {
|
||||
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>`
|
||||
tag`🔀 <em>Forwarded from <a href="https://matrix.to/#/${room.room_id}/${eventID}?${via}">${roomName}</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>`
|
||||
tag`🔀 <em>Forwarded from <a href="https://matrix.to/#/${room.room_id}?${via}">${roomName}</a></em>`
|
||||
)
|
||||
}
|
||||
} else {
|
||||
|
|
@ -711,6 +565,7 @@ async function messageToEvent(message, guild, options = {}, di) {
|
|||
// Indent
|
||||
for (const event of forwardedEvents) {
|
||||
if (["m.text", "m.notice"].includes(event.msgtype)) {
|
||||
event.msgtype = "m.notice"
|
||||
event.body = event.body.split("\n").map(l => "» " + l).join("\n")
|
||||
event.formatted_body = `<blockquote>${event.formatted_body}</blockquote>`
|
||||
}
|
||||
|
|
@ -727,42 +582,26 @@ async function messageToEvent(message, guild, options = {}, di) {
|
|||
events.push(...forwardedEvents)
|
||||
}
|
||||
|
||||
if (isThinkingInteraction) {
|
||||
const formattedInteraction = getFormattedInteraction(interaction, true)
|
||||
await addTextEvent(formattedInteraction.body, formattedInteraction.html, "m.notice")
|
||||
}
|
||||
|
||||
// Then text content
|
||||
if (message.content && !isOnlyKlipyGIF && !isThinkingInteraction) {
|
||||
if (message.content) {
|
||||
// Mentions scenario 3: scan the message content for written @mentions of matrix users. Allows for up to one space between @ and mention.
|
||||
let content = message.content
|
||||
if (options.scanTextForMentions !== false) {
|
||||
const matches = [...content.matchAll(/(@ ?)([a-z0-9_.#$][^@\n]+)/gi)]
|
||||
for (let i = matches.length; i--;) {
|
||||
const m = matches[i]
|
||||
const prefix = m[1]
|
||||
const maximumWrittenSection = m[2].toLowerCase()
|
||||
if (m.index > 0 && !content[m.index-1].match(/ |\(|\n/)) continue // must have space before it
|
||||
if (maximumWrittenSection.match(/^everyone\b/) || maximumWrittenSection.match(/^here\b/)) continue // ignore @everyone/@here
|
||||
|
||||
var roomID = roomID ?? select("channel_room", "room_id", {channel_id: message.channel_id}).pluck().get()
|
||||
assert(roomID)
|
||||
var pjr = pjr ?? findMentions.processJoined(Object.entries((await di.api.getJoinedMembers(roomID)).joined).map(([mxid, ev]) => ({mxid, displayname: ev.display_name})))
|
||||
|
||||
const found = findMentions.findMention(pjr, maximumWrittenSection, m.index, prefix, content)
|
||||
if (found) {
|
||||
addMention(found.mxid)
|
||||
content = found.newContent
|
||||
const matches = [...message.content.matchAll(/@ ?([a-z0-9._]+)\b/gi)]
|
||||
if (options.scanTextForMentions !== false && matches.length && matches.some(m => m[1].match(/[a-z]/i) && m[1] !== "everyone" && m[1] !== "here")) {
|
||||
const writtenMentionsText = matches.map(m => m[1].toLowerCase())
|
||||
const roomID = select("channel_room", "room_id", {channel_id: message.channel_id}).pluck().get()
|
||||
assert(roomID)
|
||||
const {joined} = await di.api.getJoinedMembers(roomID)
|
||||
for (const [mxid, member] of Object.entries(joined)) {
|
||||
if (!userRegex.some(rx => mxid.match(rx))) {
|
||||
const localpart = mxid.match(/@([^:]*)/)
|
||||
assert(localpart)
|
||||
const displayName = member.display_name || localpart[1]
|
||||
if (writtenMentionsText.includes(localpart[1].toLowerCase()) || writtenMentionsText.includes(displayName.toLowerCase())) addMention(mxid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Scan the content for emojihax and replace them with real emojis
|
||||
content = content.replaceAll(/\[([a-zA-Z0-9_-]{2,32})(?:~[0-9]+)?\]\(https:\/\/cdn\.discordapp\.com\/emojis\/([0-9]+)\.[^ \n)`]+\)/g, (_, name, id) => {
|
||||
return `<:${name}:${id}>`
|
||||
})
|
||||
|
||||
const {body, html} = await transformContent(content)
|
||||
const {body, html} = await transformContent(message.content)
|
||||
await addTextEvent(body, html, msgtype)
|
||||
}
|
||||
|
||||
|
|
@ -811,125 +650,8 @@ async function messageToEvent(message, guild, options = {}, di) {
|
|||
|
||||
// Then attachments
|
||||
if (message.attachments) {
|
||||
const attachmentEvents = await Promise.all(message.attachments.map(attachment => attachmentToEvent(mentions, attachment)))
|
||||
|
||||
// Try to merge attachment events with the previous event
|
||||
// This means that if the attachments ended up as a text link, and especially if there were many of them, the events will be joined together.
|
||||
let prev = events.at(-1)
|
||||
for (const atch of attachmentEvents) {
|
||||
if (atch.msgtype === "m.text" && prev?.body && prev?.formatted_body && ["m.text", "m.notice"].includes(prev?.msgtype)) {
|
||||
prev.body = prev.body + "\n" + atch.body
|
||||
prev.formatted_body = prev.formatted_body + "<br>" + atch.formatted_body
|
||||
} else {
|
||||
events.push(atch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Then components
|
||||
if (message.components?.length) {
|
||||
const stack = new mxUtils.MatrixStringBuilderStack()
|
||||
/** @param {DiscordTypes.APIMessageComponent} component */
|
||||
async function processComponent(component) {
|
||||
// Standalone components
|
||||
if (component.type === DiscordTypes.ComponentType.TextDisplay) {
|
||||
const {body, html} = await transformContent(component.content)
|
||||
stack.msb.addParagraph(body, html)
|
||||
}
|
||||
else if (component.type === DiscordTypes.ComponentType.Separator) {
|
||||
stack.msb.addParagraph("----", "<hr>")
|
||||
}
|
||||
else if (component.type === DiscordTypes.ComponentType.File) {
|
||||
/** @type {{[k in keyof DiscordTypes.APIUnfurledMediaItem]-?: NonNullable<DiscordTypes.APIUnfurledMediaItem[k]>}} */ // @ts-ignore
|
||||
const file = component.file
|
||||
assert(component.name && component.size && file.content_type)
|
||||
const ev = await attachmentToEvent({}, {...file, filename: component.name, size: component.size}, true)
|
||||
stack.msb.addLine(ev.body, ev.formatted_body)
|
||||
}
|
||||
else if (component.type === DiscordTypes.ComponentType.MediaGallery) {
|
||||
const description = component.items.length === 1 ? component.items[0].description || "Image:" : "Image gallery:"
|
||||
const images = component.items.map(item => {
|
||||
const publicURL = dUtils.getPublicUrlForCdn(item.media.url)
|
||||
return {
|
||||
url: publicURL,
|
||||
estimatedName: item.media.url.match(/\/([^/?]+)(\?|$)/)?.[1] || publicURL
|
||||
}
|
||||
})
|
||||
stack.msb.addLine(`🖼️ ${description} ${images.map(i => i.url).join(", ")}`, tag`🖼️ ${description} $${images.map(i => tag`<a href="${i.url}">${i.estimatedName}</a>`).join(", ")}`)
|
||||
}
|
||||
// string select, text input, user select, role select, mentionable select, channel select
|
||||
|
||||
// Components that can have things nested
|
||||
else if (component.type === DiscordTypes.ComponentType.Container) {
|
||||
// May contain action row, text display, section, media gallery, separator, file
|
||||
stack.bump()
|
||||
for (const innerComponent of component.components) {
|
||||
await processComponent(innerComponent)
|
||||
}
|
||||
let {body, formatted_body} = stack.shift().get()
|
||||
body = body.split("\n").map(l => "| " + l).join("\n")
|
||||
formatted_body = `<blockquote>${formatted_body}</blockquote>`
|
||||
if (stack.msb.body) stack.msb.body += "\n\n"
|
||||
stack.msb.add(body, formatted_body)
|
||||
}
|
||||
else if (component.type === DiscordTypes.ComponentType.Section) {
|
||||
// May contain text display, possibly more in the future
|
||||
// Accessory may be button or thumbnail
|
||||
stack.bump()
|
||||
for (const innerComponent of component.components) {
|
||||
await processComponent(innerComponent)
|
||||
}
|
||||
if (component.accessory) {
|
||||
stack.bump()
|
||||
await processComponent(component.accessory)
|
||||
const {body, formatted_body} = stack.shift().get()
|
||||
stack.msb.addLine(body, formatted_body)
|
||||
}
|
||||
const {body, formatted_body} = stack.shift().get()
|
||||
stack.msb.addParagraph(body, formatted_body)
|
||||
}
|
||||
else if (component.type === DiscordTypes.ComponentType.ActionRow) {
|
||||
const linkButtons = component.components.filter(c => c.type === DiscordTypes.ComponentType.Button && c.style === DiscordTypes.ButtonStyle.Link)
|
||||
if (linkButtons.length) {
|
||||
stack.msb.addLine("")
|
||||
for (const linkButton of linkButtons) {
|
||||
await processComponent(linkButton)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Components that can only be inside things
|
||||
else if (component.type === DiscordTypes.ComponentType.Thumbnail) {
|
||||
// May only be a section accessory
|
||||
stack.msb.add(`🖼️ ${component.media.url}`, tag`🖼️ <a href="${component.media.url}">${component.media.url}</a>`)
|
||||
}
|
||||
else if (component.type === DiscordTypes.ComponentType.Button) {
|
||||
// May only be a section accessory or in an action row (up to 5)
|
||||
if (component.style === DiscordTypes.ButtonStyle.Link) {
|
||||
if (component.label) {
|
||||
stack.msb.add(`[${component.label} ${component.url}] `, tag`<a href="${component.url}">${component.label}</a> `)
|
||||
} else {
|
||||
stack.msb.add(component.url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Not handling file upload or label because they are modal-only components
|
||||
}
|
||||
|
||||
for (const component of message.components) {
|
||||
await processComponent(component)
|
||||
}
|
||||
|
||||
const {body, formatted_body} = stack.msb.get()
|
||||
if (body.trim().length) {
|
||||
await addTextEvent(body, formatted_body, "m.text")
|
||||
}
|
||||
}
|
||||
|
||||
// Then polls
|
||||
if (message.poll) {
|
||||
const pollEvent = await pollToEvent(message.poll)
|
||||
events.push(pollEvent)
|
||||
const attachmentEvents = await Promise.all(message.attachments.map(attachmentToEvent.bind(null, mentions)))
|
||||
events.push(...attachmentEvents)
|
||||
}
|
||||
|
||||
// Then embeds
|
||||
|
|
@ -943,43 +665,13 @@ async function messageToEvent(message, guild, options = {}, di) {
|
|||
continue // Matrix's own URL previews are fine for images.
|
||||
}
|
||||
|
||||
if (embed.type === "video" && embed.video?.url && !embed.title && message.content.includes(embed.video.url)) {
|
||||
continue // Doesn't add extra information and the direct video URL is already there.
|
||||
}
|
||||
|
||||
if (embed.type === "poll_result") {
|
||||
// The code here is only for the message to be bridged to Matrix. Dealing with the Discord-side updates is in d2m/actions/poll-end.js.
|
||||
}
|
||||
|
||||
if (embed.url?.startsWith("https://discord.com/")) {
|
||||
continue // If discord creates an embed preview for a discord channel link, don't copy that embed
|
||||
}
|
||||
|
||||
if (embed.url && spoilers.some(sp => sp.match(/\bhttps?:\/\/[a-z]/))) {
|
||||
// If the original message had spoilered URLs, don't generate any embeds for links.
|
||||
// This logic is the same as the Discord desktop client. It doesn't match specific embeds to specific spoilered text, it's all or nothing.
|
||||
// It's not easy to do much better because posting a link like youtu.be generates an embed.url with youtube.com/watch, so you can't match up the text without making at least that a special case.
|
||||
continue
|
||||
}
|
||||
|
||||
// Start building up a replica ("rep") of the embed in Discord-markdown format, which we will convert into both plaintext and formatted body at once
|
||||
const rep = new mxUtils.MatrixStringBuilder()
|
||||
|
||||
if (isKlipyGIF) {
|
||||
assert(embed.video?.url)
|
||||
rep.add("[GIF] ", "➿ ")
|
||||
if (embed.title) {
|
||||
rep.add(`${embed.title} ${embed.video.url}`, tag`<a href="${embed.video.url}">${embed.title}</a>`)
|
||||
} else {
|
||||
rep.add(embed.video.url)
|
||||
}
|
||||
|
||||
let {body, formatted_body: html} = rep.get()
|
||||
html = `<blockquote>${html}</blockquote>`
|
||||
await addTextEvent(body, html, "m.text")
|
||||
continue
|
||||
}
|
||||
|
||||
// Provider
|
||||
if (embed.provider?.name && embed.provider.name !== "Tenor") {
|
||||
if (embed.provider.url) {
|
||||
|
|
@ -1076,7 +768,7 @@ async function messageToEvent(message, guild, options = {}, di) {
|
|||
}
|
||||
|
||||
// Rich replies
|
||||
if (repliedToEventRow && !repliedToEventInDifferentRoom) {
|
||||
if (repliedToEventRow) {
|
||||
Object.assign(events[0], {
|
||||
"m.relates_to": {
|
||||
"m.in_reply_to": {
|
||||
|
|
@ -1086,16 +778,6 @@ async function messageToEvent(message, guild, options = {}, di) {
|
|||
})
|
||||
}
|
||||
|
||||
// Strip formatted_body where equivalent to body
|
||||
if (!options.alwaysReturnFormattedBody) {
|
||||
for (const event of events) {
|
||||
if (event.$type === "m.room.message" && "msgtype" in event && ["m.text", "m.notice"].includes(event.msgtype) && event.body === event.formatted_body) {
|
||||
delete event.format
|
||||
delete event.formatted_body
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return events
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -50,7 +50,11 @@ test("message2event: pk reply to matrix is converted to native matrix reply", as
|
|||
]
|
||||
},
|
||||
msgtype: "m.text",
|
||||
body: "this is a reply",
|
||||
body: "> cadence [they]: now for my next experiment:\n\nthis is a reply",
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: '<mx-reply><blockquote><a href="https://matrix.to/#/!TqlyQmifxGUggEmdBN:cadence.moe/$NB6nPgO2tfXyIwwDSF0Ga0BUrsgX1S-0Xl-jAvI8ucU">In reply to</a> <a href="https://matrix.to/#/@cadence:cadence.moe">cadence [they]</a><br>'
|
||||
+ "now for my next experiment:</blockquote></mx-reply>"
|
||||
+ "this is a reply",
|
||||
"m.relates_to": {
|
||||
"m.in_reply_to": {
|
||||
event_id: "$NB6nPgO2tfXyIwwDSF0Ga0BUrsgX1S-0Xl-jAvI8ucU"
|
||||
|
|
@ -76,7 +80,11 @@ test("message2event: pk reply to discord is converted to native matrix reply", a
|
|||
$type: "m.room.message",
|
||||
msgtype: "m.text",
|
||||
"m.mentions": {},
|
||||
body: "this is a reply",
|
||||
body: "> wing: some text\n\nthis is a reply",
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: '<mx-reply><blockquote><a href="https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA">In reply to</a> wing<br>'
|
||||
+ "some text</blockquote></mx-reply>"
|
||||
+ "this is a reply",
|
||||
"m.relates_to": {
|
||||
"m.in_reply_to": {
|
||||
event_id: "$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA"
|
||||
|
|
@ -112,7 +120,11 @@ test("message2event: pk reply to matrix attachment is converted to native matrix
|
|||
"m.mentions": {
|
||||
user_ids: ["@ampflower:matrix.org"]
|
||||
},
|
||||
body: "Cat nod",
|
||||
body: "> Ampflower 🌺: [Media]\n\nCat nod",
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: '<mx-reply><blockquote><a href="https://matrix.to/#/!TqlyQmifxGUggEmdBN:cadence.moe/$OEEK-Wam2FTh6J-6kVnnJ6KnLA_lLRnLTHatKKL62-Y">In reply to</a> <a href="https://matrix.to/#/@ampflower:matrix.org">Ampflower 🌺</a><br>'
|
||||
+ "[Media]</blockquote></mx-reply>"
|
||||
+ "Cat nod",
|
||||
"m.relates_to": {
|
||||
"m.in_reply_to": {
|
||||
event_id: "$OEEK-Wam2FTh6J-6kVnnJ6KnLA_lLRnLTHatKKL62-Y"
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
const {test} = require("supertape")
|
||||
const {messageToEvent} = require("./message-to-event")
|
||||
const data = require("../../../test/data")
|
||||
|
||||
test("message2event components: pk question mark output", async t => {
|
||||
const events = await messageToEvent(data.message_with_components.pk_question_mark_response, data.guild.general, {})
|
||||
t.deepEqual(events, [{
|
||||
$type: "m.room.message",
|
||||
body:
|
||||
"| ### Lillith (INX)"
|
||||
+ "\n| "
|
||||
+ "\n| **Display name:** Lillith (she/her)"
|
||||
+ "\n| **Pronouns:** She/Her"
|
||||
+ "\n| **Message count:** 3091"
|
||||
+ "\n| 🖼️ https://files.inx.moe/p/cdn/lillith.webp"
|
||||
+ "\n| "
|
||||
+ "\n| ----"
|
||||
+ "\n| "
|
||||
+ "\n| **Proxy tags:**"
|
||||
+ "\n| ``l;text``"
|
||||
+ "\n| ``l:text``"
|
||||
+ "\n| ``l.text``"
|
||||
+ "\n| ``textl.``"
|
||||
+ "\n| ``textl;``"
|
||||
+ "\n| ``textl:``"
|
||||
+ "\n"
|
||||
+ "\n-# System ID: `xffgnx` ∙ Member ID: `pphhoh`"
|
||||
+ "\n-# Created: 2025-12-31 03:16:45 UTC"
|
||||
+ "\n[View on dashboard https://dash.pluralkit.me/profile/m/pphhoh] "
|
||||
+ "\n"
|
||||
+ "\n----"
|
||||
+ "\n"
|
||||
+ "\n| **System:** INX (`xffgnx`)"
|
||||
+ "\n| **Member:** Lillith (`pphhoh`)"
|
||||
+ "\n| **Sent by:** infinidoge1337 (@unknown-user:)"
|
||||
+ "\n| "
|
||||
+ "\n| **Account Roles (7)**"
|
||||
+ "\n| §b, !, ‼, Ears Port Ping, Ears Update Ping, Yttr Ping, unsup Ping"
|
||||
+ "\n| 🖼️ https://files.inx.moe/p/cdn/lillith.webp"
|
||||
+ "\n| "
|
||||
+ "\n| ----"
|
||||
+ "\n| "
|
||||
+ "\n| Same hat"
|
||||
+ "\n| 🖼️ Image: https://bridge.example.org/download/discordcdn/934955898965729280/1466556006527012987/image.png"
|
||||
+ "\n"
|
||||
+ "\n-# Original Message ID: 1466556003645657118 · <t:1769724599:f>",
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: "<blockquote>"
|
||||
+ "<h3>Lillith (INX)</h3>"
|
||||
+ "<p><strong>Display name:</strong> Lillith (she/her)"
|
||||
+ "<br><strong>Pronouns:</strong> She/Her"
|
||||
+ "<br><strong>Message count:</strong> 3091</p>"
|
||||
+ `🖼️ <a href="https://files.inx.moe/p/cdn/lillith.webp">https://files.inx.moe/p/cdn/lillith.webp</a>`
|
||||
+ "<hr>"
|
||||
+ "<p><strong>Proxy tags:</strong>"
|
||||
+ "<br><code>l;text</code>"
|
||||
+ "<br><code>l:text</code>"
|
||||
+ "<br><code>l.text</code>"
|
||||
+ "<br><code>textl.</code>"
|
||||
+ "<br><code>textl;</code>"
|
||||
+ "<br><code>textl:</code></p></blockquote>"
|
||||
+ "<p><sub>System ID: <code>xffgnx</code> ∙ Member ID: <code>pphhoh</code></sub><br>"
|
||||
+ "<sub>Created: 2025-12-31 03:16:45 UTC</sub></p>"
|
||||
+ `<a href="https://dash.pluralkit.me/profile/m/pphhoh">View on dashboard</a> `
|
||||
+ "<hr>"
|
||||
+ "<blockquote><p><strong>System:</strong> INX (<code>xffgnx</code>)"
|
||||
+ "<br><strong>Member:</strong> Lillith (<code>pphhoh</code>)"
|
||||
+ "<br><strong>Sent by:</strong> infinidoge1337 (@unknown-user:)"
|
||||
+ "<br><br><strong>Account Roles (7)</strong>"
|
||||
+ "<br>§b, !, ‼, Ears Port Ping, Ears Update Ping, Yttr Ping, unsup Ping</p>"
|
||||
+ `🖼️ <a href="https://files.inx.moe/p/cdn/lillith.webp">https://files.inx.moe/p/cdn/lillith.webp</a>`
|
||||
+ "<hr>"
|
||||
+ "<p>Same hat</p>"
|
||||
+ `🖼️ Image: <a href="https://bridge.example.org/download/discordcdn/934955898965729280/1466556006527012987/image.png">image.png</a></blockquote>`
|
||||
+ "<p><sub>Original Message ID: 1466556003645657118 · <t:1769724599:f></sub></p>",
|
||||
"m.mentions": {},
|
||||
msgtype: "m.text",
|
||||
}])
|
||||
})
|
||||
|
|
@ -2,7 +2,6 @@ const {test} = require("supertape")
|
|||
const {messageToEvent} = require("./message-to-event")
|
||||
const {MatrixServerError} = require("../../matrix/mreq")
|
||||
const data = require("../../../test/data")
|
||||
const {mockGetEffectivePower} = require("../../matrix/utils.test")
|
||||
const Ty = require("../../types")
|
||||
|
||||
/**
|
||||
|
|
@ -67,7 +66,17 @@ test("message2event: simple room mention", async t => {
|
|||
let called = 0
|
||||
const events = await messageToEvent(data.message.simple_room_mention, data.guild.general, {}, {
|
||||
api: {
|
||||
getEffectivePower: mockGetEffectivePower(),
|
||||
async getStateEvent(roomID, type, key) {
|
||||
called++
|
||||
t.equal(roomID, "!BnKuBPCvyfOkhcUjEu:cadence.moe")
|
||||
t.equal(type, "m.room.power_levels")
|
||||
t.equal(key, "")
|
||||
return {
|
||||
users: {
|
||||
"@_ooye_bot:cadence.moe": 100
|
||||
}
|
||||
}
|
||||
},
|
||||
async getJoinedMembers(roomID) {
|
||||
called++
|
||||
t.equal(roomID, "!BnKuBPCvyfOkhcUjEu:cadence.moe")
|
||||
|
|
@ -88,14 +97,24 @@ test("message2event: simple room mention", async t => {
|
|||
format: "org.matrix.custom.html",
|
||||
formatted_body: '<a href="https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe?via=cadence.moe&via=matrix.org">#worm-farm</a>'
|
||||
}])
|
||||
t.equal(called, 1, "should call getJoinedMembers")
|
||||
t.equal(called, 2, "should call getStateEvent and getJoinedMembers once each")
|
||||
})
|
||||
|
||||
test("message2event: simple room link", async t => {
|
||||
let called = 0
|
||||
const events = await messageToEvent(data.message.simple_room_link, data.guild.general, {}, {
|
||||
api: {
|
||||
getEffectivePower: mockGetEffectivePower(),
|
||||
async getStateEvent(roomID, type, key) {
|
||||
called++
|
||||
t.equal(roomID, "!BnKuBPCvyfOkhcUjEu:cadence.moe")
|
||||
t.equal(type, "m.room.power_levels")
|
||||
t.equal(key, "")
|
||||
return {
|
||||
users: {
|
||||
"@_ooye_bot:cadence.moe": 100
|
||||
}
|
||||
}
|
||||
},
|
||||
async getJoinedMembers(roomID) {
|
||||
called++
|
||||
t.equal(roomID, "!BnKuBPCvyfOkhcUjEu:cadence.moe")
|
||||
|
|
@ -116,14 +135,24 @@ test("message2event: simple room link", async t => {
|
|||
format: "org.matrix.custom.html",
|
||||
formatted_body: '<a href="https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe?via=cadence.moe&via=matrix.org">#worm-farm</a>'
|
||||
}])
|
||||
t.equal(called, 1, "should call getJoinedMembers once")
|
||||
t.equal(called, 2, "should call getStateEvent and getJoinedMembers once each")
|
||||
})
|
||||
|
||||
test("message2event: nicked room mention", async t => {
|
||||
let called = 0
|
||||
const events = await messageToEvent(data.message.nicked_room_mention, data.guild.general, {}, {
|
||||
api: {
|
||||
getEffectivePower: mockGetEffectivePower(),
|
||||
async getStateEvent(roomID, type, key) {
|
||||
called++
|
||||
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
|
||||
t.equal(type, "m.room.power_levels")
|
||||
t.equal(key, "")
|
||||
return {
|
||||
users: {
|
||||
"@_ooye_bot:cadence.moe": 100
|
||||
}
|
||||
}
|
||||
},
|
||||
async getJoinedMembers(roomID) {
|
||||
called++
|
||||
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
|
||||
|
|
@ -144,7 +173,7 @@ test("message2event: nicked room mention", async t => {
|
|||
format: "org.matrix.custom.html",
|
||||
formatted_body: '<a href="https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe?via=cadence.moe&via=matrix.org">#main</a>'
|
||||
}])
|
||||
t.equal(called, 1, "should call getJoinedMembers once")
|
||||
t.equal(called, 2, "should call getStateEvent and getJoinedMembers once each")
|
||||
})
|
||||
|
||||
test("message2event: unknown room mention", async t => {
|
||||
|
|
@ -195,7 +224,17 @@ test("message2event: simple message link", async t => {
|
|||
let called = 0
|
||||
const events = await messageToEvent(data.message.simple_message_link, data.guild.general, {}, {
|
||||
api: {
|
||||
getEffectivePower: mockGetEffectivePower(),
|
||||
async getStateEvent(roomID, type, key) {
|
||||
called++
|
||||
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
|
||||
t.equal(type, "m.room.power_levels")
|
||||
t.equal(key, "")
|
||||
return {
|
||||
users: {
|
||||
"@_ooye_bot:cadence.moe": 100
|
||||
}
|
||||
}
|
||||
},
|
||||
async getJoinedMembers(roomID) {
|
||||
called++
|
||||
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
|
||||
|
|
@ -216,14 +255,13 @@ test("message2event: simple message link", async t => {
|
|||
format: "org.matrix.custom.html",
|
||||
formatted_body: '<a href="https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg?via=cadence.moe&via=super.invalid">https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg?via=cadence.moe&via=super.invalid</a>'
|
||||
}])
|
||||
t.equal(called, 1, "should call getJoinedMembers once")
|
||||
t.equal(called, 2, "should call getStateEvent and getJoinedMembers once each")
|
||||
})
|
||||
|
||||
test("message2event: message link that OOYE doesn't know about", async t => {
|
||||
let called = 0
|
||||
const events = await messageToEvent(data.message.message_link_to_before_ooye, data.guild.general, {}, {
|
||||
api: {
|
||||
getEffectivePower: mockGetEffectivePower(),
|
||||
async getEventForTimestamp(roomID, ts) {
|
||||
called++
|
||||
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
|
||||
|
|
@ -232,6 +270,17 @@ test("message2event: message link that OOYE doesn't know about", async t => {
|
|||
origin_server_ts: 1613287812754
|
||||
}
|
||||
},
|
||||
async getStateEvent(roomID, type, key) { // for ?via calculation
|
||||
called++
|
||||
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
|
||||
t.equal(type, "m.room.power_levels")
|
||||
t.equal(key, "")
|
||||
return {
|
||||
users: {
|
||||
"@_ooye_bot:cadence.moe": 100
|
||||
}
|
||||
}
|
||||
},
|
||||
async getJoinedMembers(roomID) { // for ?via calculation
|
||||
called++
|
||||
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
|
||||
|
|
@ -254,7 +303,7 @@ test("message2event: message link that OOYE doesn't know about", async t => {
|
|||
formatted_body: "Me: I'll scroll up to find a certain message I'll send<br><em>scrolls up and clicks message links for god knows how long</em><br><em>completely forgets what they were looking for and simply begins scrolling up to find some fun moments</em><br><em>stumbles upon:</em> "
|
||||
+ '<a href="https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$E8IQDGFqYzOU7BwY5Z74Bg-cwaU9OthXSroaWtgYc7U?via=cadence.moe&via=matrix.org">https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$E8IQDGFqYzOU7BwY5Z74Bg-cwaU9OthXSroaWtgYc7U?via=cadence.moe&via=matrix.org</a>'
|
||||
}])
|
||||
t.equal(called, 2, "getEventForTimestamp and getJoinedMembers should be called once each")
|
||||
t.equal(called, 3, "getEventForTimestamp, getStateEvent, and getJoinedMembers should be called once each")
|
||||
})
|
||||
|
||||
test("message2event: message timestamp failed to fetch", async t => {
|
||||
|
|
@ -269,7 +318,17 @@ test("message2event: message timestamp failed to fetch", async t => {
|
|||
error: "Unable to find event from 1726762095974 in direction Direction.FORWARDS"
|
||||
}, {})
|
||||
},
|
||||
getEffectivePower: mockGetEffectivePower(),
|
||||
async getStateEvent(roomID, type, key) { // for ?via calculation
|
||||
called++
|
||||
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
|
||||
t.equal(type, "m.room.power_levels")
|
||||
t.equal(key, "")
|
||||
return {
|
||||
users: {
|
||||
"@_ooye_bot:cadence.moe": 100
|
||||
}
|
||||
}
|
||||
},
|
||||
async getJoinedMembers(roomID) { // for ?via calculation
|
||||
called++
|
||||
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
|
||||
|
|
@ -287,12 +346,12 @@ test("message2event: message timestamp failed to fetch", async t => {
|
|||
"m.mentions": {},
|
||||
msgtype: "m.text",
|
||||
body: "Me: I'll scroll up to find a certain message I'll send\n_scrolls up and clicks message links for god knows how long_\n_completely forgets what they were looking for and simply begins scrolling up to find some fun moments_\n_stumbles upon:_ "
|
||||
+ "[unknown event in https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe?via=cadence.moe&via=matrix.org]",
|
||||
+ "[unknown event, timestamp resolution failed, in room: https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe?via=cadence.moe&via=matrix.org]",
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: "Me: I'll scroll up to find a certain message I'll send<br><em>scrolls up and clicks message links for god knows how long</em><br><em>completely forgets what they were looking for and simply begins scrolling up to find some fun moments</em><br><em>stumbles upon:</em> "
|
||||
+ '[unknown event in <a href="https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe?via=cadence.moe&via=matrix.org">https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe?via=cadence.moe&via=matrix.org</a>]'
|
||||
+ '[unknown event, timestamp resolution failed, in room: <a href="https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe?via=cadence.moe&via=matrix.org">https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe?via=cadence.moe&via=matrix.org</a>]'
|
||||
}])
|
||||
t.equal(called, 2, "getEventForTimestamp and getJoinedMembers should be called once each")
|
||||
t.equal(called, 3, "getEventForTimestamp, getStateEvent, and getJoinedMembers should be called once each")
|
||||
})
|
||||
|
||||
test("message2event: message link from another server", async t => {
|
||||
|
|
@ -423,7 +482,12 @@ test("message2event: reply to skull webp attachment with content", async t => {
|
|||
},
|
||||
"m.mentions": {},
|
||||
msgtype: "m.text",
|
||||
body: "Reply"
|
||||
body: "> Extremity: Image\n\nReply",
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body:
|
||||
'<mx-reply><blockquote><a href="https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$oLyUTyZ_7e_SUzGNWZKz880ll9amLZvXGbArJCKai2Q">In reply to</a> Extremity'
|
||||
+ '<br>Image</blockquote></mx-reply>'
|
||||
+ 'Reply'
|
||||
}, {
|
||||
$type: "m.room.message",
|
||||
"m.mentions": {},
|
||||
|
|
@ -467,7 +531,12 @@ test("message2event: simple reply to matrix user", async t => {
|
|||
]
|
||||
},
|
||||
msgtype: "m.text",
|
||||
body: "Reply"
|
||||
body: "> cadence: so can you reply to my webhook uwu\n\nReply",
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body:
|
||||
'<mx-reply><blockquote><a href="https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4">In reply to</a> <a href="https://matrix.to/#/@cadence:cadence.moe">cadence</a>'
|
||||
+ '<br>so can you reply to my webhook uwu</blockquote></mx-reply>'
|
||||
+ 'Reply'
|
||||
}])
|
||||
})
|
||||
|
||||
|
|
@ -529,7 +598,12 @@ test("message2event: reply to matrix user with mention", async t => {
|
|||
]
|
||||
},
|
||||
msgtype: "m.text",
|
||||
body: "kys"
|
||||
body: "> okay 🤍 yay 🤍: @extremity: you owe me $30\n\nkys",
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body:
|
||||
'<mx-reply><blockquote><a href="https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$7P2O_VTQNHvavX5zNJ35DV-dbJB1Ag80tGQP_JzGdhk">In reply to</a> <a href="https://matrix.to/#/@cadence:cadence.moe">okay 🤍 yay 🤍</a>'
|
||||
+ '<br><a href="https://matrix.to/#/@_ooye_extremity:cadence.moe">@extremity</a> you owe me $30</blockquote></mx-reply>'
|
||||
+ 'kys'
|
||||
}])
|
||||
})
|
||||
|
||||
|
|
@ -641,7 +715,9 @@ test("message2event: simple reply in thread to a matrix user's reply", async t =
|
|||
user_ids: ["@cadence:cadence.moe"]
|
||||
},
|
||||
msgtype: "m.text",
|
||||
body: "Well, they don't seem to..."
|
||||
body: "> cadence [they]: What about them?\n\nWell, they don't seem to...",
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: "<mx-reply><blockquote><a href=\"https://matrix.to/#/!FuDZhlOAtqswlyxzeR:cadence.moe/$nUM-ABBF8KdnvrhXwLlYAE9dgDl_tskOvvcNIBrtsVo\">In reply to</a> <a href=\"https://matrix.to/#/@cadence:cadence.moe\">cadence [they]</a><br>What about them?</blockquote></mx-reply>Well, they don't seem to...",
|
||||
}])
|
||||
})
|
||||
|
||||
|
|
@ -678,7 +754,9 @@ test("message2event: infinidoge's reply to ami's matrix smalltext reply to infin
|
|||
user_ids: ["@ami:the-apothecary.club"]
|
||||
},
|
||||
msgtype: "m.text",
|
||||
body: `Most likely`
|
||||
body: `> Ami (she/her): let me guess they got a lot of bug reports like "empty chest with no loot?"\n\nMost likely`,
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: `<mx-reply><blockquote><a href="https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$W1nsDhNIojWrcQOdnOD9RaEvrz2qyZErQoNhPRs1nK4">In reply to</a> <a href="https://matrix.to/#/@ami:the-apothecary.club">Ami (she/her)</a><br>let me guess they got a lot of bug reports like "empty chest with no loot?"</blockquote></mx-reply>Most likely`,
|
||||
}])
|
||||
})
|
||||
|
||||
|
|
@ -715,7 +793,9 @@ test("message2event: infinidoge's reply to ami's matrix smalltext singleline rep
|
|||
user_ids: ["@ami:the-apothecary.club"]
|
||||
},
|
||||
msgtype: "m.text",
|
||||
body: `Most likely`
|
||||
body: `> Ami (she/her): let me guess they got a lot of bug reports like "empty chest with no loot?"\n\nMost likely`,
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: `<mx-reply><blockquote><a href="https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$W1nsDhNIojWrcQOdnOD9RaEvrz2qyZErQoNhPRs1nK4">In reply to</a> <a href="https://matrix.to/#/@ami:the-apothecary.club">Ami (she/her)</a><br>let me guess they got a lot of bug reports like "empty chest with no loot?"</blockquote></mx-reply>Most likely`,
|
||||
}])
|
||||
})
|
||||
|
||||
|
|
@ -789,13 +869,11 @@ test("message2event: simple written @mention for matrix user", async t => {
|
|||
]
|
||||
},
|
||||
msgtype: "m.text",
|
||||
body: "[@ash](https://matrix.to/#/@she_who_brings_destruction:cadence.moe) do you need anything from the store btw as I'm heading there after gym",
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: `<a href="https://matrix.to/#/@she_who_brings_destruction:cadence.moe">@ash</a> do you need anything from the store btw as I'm heading there after gym`
|
||||
body: "@ash do you need anything from the store btw as I'm heading there after gym"
|
||||
}])
|
||||
})
|
||||
|
||||
test("message2event: many written @mentions for matrix users", async t => {
|
||||
test("message2event: advanced written @mentions for matrix users", async t => {
|
||||
let called = 0
|
||||
const events = await messageToEvent(data.message.advanced_written_at_mention_for_matrix, data.guild.general, {}, {
|
||||
api: {
|
||||
|
|
@ -833,200 +911,16 @@ test("message2event: many written @mentions for matrix users", async t => {
|
|||
$type: "m.room.message",
|
||||
"m.mentions": {
|
||||
user_ids: [
|
||||
"@huckleton:cadence.moe",
|
||||
"@cadence:cadence.moe"
|
||||
"@cadence:cadence.moe",
|
||||
"@huckleton:cadence.moe"
|
||||
]
|
||||
},
|
||||
msgtype: "m.text",
|
||||
body: "[@Cadence](https://matrix.to/#/@cadence:cadence.moe), tell me about @Phil, the creator of the Chin Trick, who has become ever more powerful under the mentorship of @botrac4r and [@huck](https://matrix.to/#/@huckleton:cadence.moe)",
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: `<a href="https://matrix.to/#/@cadence:cadence.moe">@Cadence</a>, tell me about @Phil, the creator of the Chin Trick, who has become ever more powerful under the mentorship of @botrac4r and <a href="https://matrix.to/#/@huckleton:cadence.moe">@huck</a>`
|
||||
body: "@Cadence, tell me about @Phil, the creator of the Chin Trick, who has become ever more powerful under the mentorship of @botrac4r and @huck"
|
||||
}])
|
||||
t.equal(called, 1, "should only look up the member list once")
|
||||
})
|
||||
|
||||
test("message2event: written @mentions may match part of the name", async t => {
|
||||
let called = 0
|
||||
const events = await messageToEvent({
|
||||
...data.message.advanced_written_at_mention_for_matrix,
|
||||
content: "I wonder if @cadence saw this?"
|
||||
}, data.guild.general, {}, {
|
||||
api: {
|
||||
async getJoinedMembers(roomID) {
|
||||
called++
|
||||
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
|
||||
return new Promise(resolve => {
|
||||
setTimeout(() => {
|
||||
resolve({
|
||||
joined: {
|
||||
"@secret:cadence.moe": {
|
||||
display_name: "cadence [they]",
|
||||
avatar_url: "whatever"
|
||||
},
|
||||
"@huckleton:cadence.moe": {
|
||||
display_name: "huck",
|
||||
avatar_url: "whatever"
|
||||
},
|
||||
"@_ooye_botrac4r:cadence.moe": {
|
||||
display_name: "botrac4r",
|
||||
avatar_url: "whatever"
|
||||
},
|
||||
"@_ooye_bot:cadence.moe": {
|
||||
display_name: "Out Of Your Element",
|
||||
avatar_url: "whatever"
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
t.deepEqual(events, [{
|
||||
$type: "m.room.message",
|
||||
"m.mentions": {
|
||||
user_ids: [
|
||||
"@secret:cadence.moe",
|
||||
]
|
||||
},
|
||||
msgtype: "m.text",
|
||||
body: "I wonder if [@cadence](https://matrix.to/#/@secret:cadence.moe) saw this?",
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: `I wonder if <a href="https://matrix.to/#/@secret:cadence.moe">@cadence</a> saw this?`
|
||||
}])
|
||||
})
|
||||
|
||||
test("message2event: written @mentions may match part of the mxid", async t => {
|
||||
let called = 0
|
||||
const events = await messageToEvent({
|
||||
...data.message.advanced_written_at_mention_for_matrix,
|
||||
content: "I wonder if @huck saw this?"
|
||||
}, data.guild.general, {}, {
|
||||
api: {
|
||||
async getJoinedMembers(roomID) {
|
||||
called++
|
||||
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
|
||||
return new Promise(resolve => {
|
||||
setTimeout(() => {
|
||||
resolve({
|
||||
joined: {
|
||||
"@cadence:cadence.moe": {
|
||||
display_name: "cadence [they]",
|
||||
avatar_url: "whatever"
|
||||
},
|
||||
"@huckleton:cadence.moe": {
|
||||
display_name: "wa",
|
||||
avatar_url: "whatever"
|
||||
},
|
||||
"@_ooye_botrac4r:cadence.moe": {
|
||||
display_name: "botrac4r",
|
||||
avatar_url: "whatever"
|
||||
},
|
||||
"@_ooye_bot:cadence.moe": {
|
||||
display_name: "Out Of Your Element",
|
||||
avatar_url: "whatever"
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
t.deepEqual(events, [{
|
||||
$type: "m.room.message",
|
||||
"m.mentions": {
|
||||
user_ids: [
|
||||
"@huckleton:cadence.moe",
|
||||
]
|
||||
},
|
||||
msgtype: "m.text",
|
||||
body: "I wonder if [@huck](https://matrix.to/#/@huckleton:cadence.moe) saw this?",
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: `I wonder if <a href="https://matrix.to/#/@huckleton:cadence.moe">@huck</a> saw this?`
|
||||
}])
|
||||
})
|
||||
|
||||
test("message2event: written @mentions do not match in URLs", async t => {
|
||||
const events = await messageToEvent({
|
||||
...data.message.advanced_written_at_mention_for_matrix,
|
||||
content: "the fucking around with pixel composer continues https://pub.mastodon.sleeping.town/@exa/116037641900024965"
|
||||
}, data.guild.general, {}, {})
|
||||
t.deepEqual(events, [{
|
||||
$type: "m.room.message",
|
||||
"m.mentions": {},
|
||||
msgtype: "m.text",
|
||||
body: "the fucking around with pixel composer continues https://pub.mastodon.sleeping.town/@exa/116037641900024965",
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: `the fucking around with pixel composer continues <a href="https://pub.mastodon.sleeping.town/@exa/116037641900024965">https://pub.mastodon.sleeping.town/@exa/116037641900024965</a>`
|
||||
}])
|
||||
})
|
||||
|
||||
test("message2event: entire message may match elaborate display name", async t => {
|
||||
let called = 0
|
||||
const events = await messageToEvent({
|
||||
...data.message.advanced_written_at_mention_for_matrix,
|
||||
content: "@Cadence, Maid of Creation, Eye of Clarity, Empress of Hope ☆"
|
||||
}, data.guild.general, {}, {
|
||||
api: {
|
||||
async getJoinedMembers(roomID) {
|
||||
called++
|
||||
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
|
||||
return new Promise(resolve => {
|
||||
setTimeout(() => {
|
||||
resolve({
|
||||
joined: {
|
||||
"@wa:cadence.moe": {
|
||||
display_name: "Cadence, Maid of Creation, Eye of Clarity, Empress of Hope ☆",
|
||||
avatar_url: "whatever"
|
||||
},
|
||||
"@huckleton:cadence.moe": {
|
||||
display_name: "huck",
|
||||
avatar_url: "whatever"
|
||||
},
|
||||
"@_ooye_botrac4r:cadence.moe": {
|
||||
display_name: "botrac4r",
|
||||
avatar_url: "whatever"
|
||||
},
|
||||
"@_ooye_bot:cadence.moe": {
|
||||
display_name: "Out Of Your Element",
|
||||
avatar_url: "whatever"
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
t.deepEqual(events, [{
|
||||
$type: "m.room.message",
|
||||
"m.mentions": {
|
||||
user_ids: [
|
||||
"@wa:cadence.moe",
|
||||
]
|
||||
},
|
||||
msgtype: "m.text",
|
||||
body: "[@Cadence, Maid of Creation, Eye of Clarity, Empress of Hope ☆](https://matrix.to/#/@wa:cadence.moe)",
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: `<a href="https://matrix.to/#/@wa:cadence.moe">@Cadence, Maid of Creation, Eye of Clarity, Empress of Hope ☆</a>`
|
||||
}])
|
||||
})
|
||||
|
||||
test("message2event: spoilers are removed from plaintext body", async t => {
|
||||
const events = await messageToEvent({
|
||||
content: "||**beatrice**||"
|
||||
})
|
||||
t.deepEqual(events, [{
|
||||
$type: "m.room.message",
|
||||
"m.mentions": {},
|
||||
msgtype: "m.text",
|
||||
body: "[spoiler]",
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: `<span data-mx-spoiler=""><strong>beatrice</strong></span>`
|
||||
}])
|
||||
})
|
||||
|
||||
test("message2event: very large attachment is linked instead of being uploaded", async t => {
|
||||
const events = await messageToEvent({
|
||||
content: "hey",
|
||||
|
|
@ -1037,66 +931,18 @@ test("message2event: very large attachment is linked instead of being uploaded",
|
|||
size: 100e6
|
||||
}]
|
||||
})
|
||||
t.deepEqual(events, [{
|
||||
$type: "m.room.message",
|
||||
"m.mentions": {},
|
||||
msgtype: "m.text",
|
||||
body: "hey\n📄 Uploaded file: https://bridge.example.org/download/discordcdn/123/456/789.mega (100 MB)",
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: 'hey<br>📄 Uploaded file: <a href="https://bridge.example.org/download/discordcdn/123/456/789.mega">hey.jpg</a> (100 MB)'
|
||||
}])
|
||||
})
|
||||
|
||||
test("message2event: multiple attachments are combined into the same event where possible", async t => {
|
||||
const events = await messageToEvent({
|
||||
content: "hey",
|
||||
attachments: [{
|
||||
filename: "hey.jpg",
|
||||
url: "https://cdn.discordapp.com/attachments/123/456/789.mega",
|
||||
content_type: "application/i-made-it-up",
|
||||
size: 100e6
|
||||
}, {
|
||||
filename: "SPOILER_secret.jpg",
|
||||
url: "https://cdn.discordapp.com/attachments/123/456/SPOILER_secret.jpg",
|
||||
content_type: "image/jpeg",
|
||||
size: 38291
|
||||
}, {
|
||||
filename: "my enemies.txt",
|
||||
url: "https://cdn.discordapp.com/attachments/123/456/my_enemies.txt",
|
||||
content_type: "text/plain",
|
||||
size: 8911
|
||||
}, {
|
||||
filename: "hey.jpg",
|
||||
url: "https://cdn.discordapp.com/attachments/123/456/789.mega",
|
||||
content_type: "application/i-made-it-up",
|
||||
size: 100e6
|
||||
}]
|
||||
})
|
||||
t.deepEqual(events, [{
|
||||
$type: "m.room.message",
|
||||
"m.mentions": {},
|
||||
msgtype: "m.text",
|
||||
body: "hey"
|
||||
+ "\n📄 Uploaded file: https://bridge.example.org/download/discordcdn/123/456/789.mega (100 MB)"
|
||||
+ "\n📸 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/123/456/SPOILER_secret.jpg (38 KB)"
|
||||
+ "\n📄 Uploaded file: https://bridge.example.org/download/discordcdn/123/456/789.mega (100 MB)",
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: "hey"
|
||||
+ `<br>📄 Uploaded file: <a href="https://bridge.example.org/download/discordcdn/123/456/789.mega">hey.jpg</a> (100 MB)`
|
||||
+ `<br><blockquote>📸 Uploaded SPOILER file: <a href="https://bridge.example.org/download/discordcdn/123/456/SPOILER_secret.jpg">https://bridge.example.org/download/discordcdn/123/456/SPOILER_secret.jpg</a> (38 KB)</blockquote>`
|
||||
+ `<br>📄 Uploaded file: <a href="https://bridge.example.org/download/discordcdn/123/456/789.mega">hey.jpg</a> (100 MB)`
|
||||
}, {
|
||||
$type: "m.room.message",
|
||||
"m.mentions": {},
|
||||
msgtype: "m.file",
|
||||
body: "my enemies.txt",
|
||||
filename: "my enemies.txt",
|
||||
external_url: "https://bridge.example.org/download/discordcdn/123/456/my_enemies.txt",
|
||||
url: "mxc://cadence.moe/y89EOTRp2lbeOkgdsEleGOge",
|
||||
info: {
|
||||
mimetype: "text/plain",
|
||||
size: 8911
|
||||
}
|
||||
msgtype: "m.text",
|
||||
body: "📄 Uploaded file: https://bridge.example.org/download/discordcdn/123/456/789.mega (100 MB)",
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: '📄 Uploaded file: <a href="https://bridge.example.org/download/discordcdn/123/456/789.mega">hey.jpg</a> (100 MB)'
|
||||
}])
|
||||
})
|
||||
|
||||
|
|
@ -1171,18 +1017,6 @@ test("message2event: emoji that hasn't been registered yet", async t => {
|
|||
}])
|
||||
})
|
||||
|
||||
test("message2event: emojihax", async t => {
|
||||
const events = await messageToEvent(data.message.emojihax, data.guild.general, {})
|
||||
t.deepEqual(events, [{
|
||||
$type: "m.room.message",
|
||||
"m.mentions": {},
|
||||
msgtype: "m.text",
|
||||
body: "I only violate the don't modify our console part of terms of service :troll:",
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: `I only violate the don't modify our console part of terms of service <img data-mx-emoticon height="32" src="mxc://cadence.moe/bvVJFgOIyNcAknKCbmaHDktG" title=":troll:" alt=":troll:">`
|
||||
}])
|
||||
})
|
||||
|
||||
test("message2event: emoji triple long name", async t => {
|
||||
const events = await messageToEvent(data.message.emoji_triple_long_name, data.guild.general, {})
|
||||
t.deepEqual(events, [{
|
||||
|
|
@ -1302,7 +1136,6 @@ test("message2event: forwarded image", async t => {
|
|||
test("message2event: constructed forwarded message", async t => {
|
||||
const events = await messageToEvent(data.message.constructed_forwarded_message, {}, {}, {
|
||||
api: {
|
||||
getEffectivePower: mockGetEffectivePower(),
|
||||
async getJoinedMembers() {
|
||||
return {
|
||||
joined: {
|
||||
|
|
@ -1319,10 +1152,10 @@ test("message2event: constructed forwarded message", async t => {
|
|||
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&via=matrix.org">[jump to event]</a></em>`
|
||||
formatted_body: `🔀 <em>Forwarded from <a href="https://matrix.to/#/!qzDBLKlildpzrrOnFZ:cadence.moe/$tBIT8mO7XTTCgIINyiAIy6M2MSoPAdJenRl_RLyYuaE?via=cadence.moe&via=matrix.org">wonderland</a></em>`
|
||||
+ `<br><blockquote>What's cooking, good looking? <img data-mx-emoticon height="32" src="mxc://cadence.moe/WbYqNlACRuicynBfdnPYtmvc" title=":hipposcope:" alt=":hipposcope:"></blockquote>`,
|
||||
"m.mentions": {},
|
||||
msgtype: "m.text",
|
||||
msgtype: "m.notice",
|
||||
},
|
||||
{
|
||||
$type: "m.room.message",
|
||||
|
|
@ -1361,7 +1194,6 @@ test("message2event: constructed forwarded message", async t => {
|
|||
test("message2event: constructed forwarded text", async t => {
|
||||
const events = await messageToEvent(data.message.constructed_forwarded_text, {}, {}, {
|
||||
api: {
|
||||
getEffectivePower: mockGetEffectivePower(),
|
||||
async getJoinedMembers() {
|
||||
return {
|
||||
joined: {
|
||||
|
|
@ -1378,10 +1210,10 @@ test("message2event: constructed forwarded text", async t => {
|
|||
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&via=matrix.org">[jump to room]</a></em>`
|
||||
formatted_body: `🔀 <em>Forwarded from <a href="https://matrix.to/#/!CzvdIdUQXgUjDVKxeU:cadence.moe?via=cadence.moe&via=matrix.org">amanda-spam</a></em>`
|
||||
+ `<br><blockquote>What's cooking, good looking?</blockquote>`,
|
||||
"m.mentions": {},
|
||||
msgtype: "m.text",
|
||||
msgtype: "m.notice",
|
||||
},
|
||||
{
|
||||
$type: "m.room.message",
|
||||
|
|
@ -1404,7 +1236,7 @@ test("message2event: don't scan forwarded messages for mentions", async t => {
|
|||
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"
|
||||
msgtype: "m.notice"
|
||||
}
|
||||
])
|
||||
})
|
||||
|
|
@ -1499,7 +1331,6 @@ test("message2event: vc invite event renders embed", async t => {
|
|||
test("message2event: vc invite event renders embed with room link", async t => {
|
||||
const events = await messageToEvent({content: "https://discord.gg/placeholder?event=1381174024801095751"}, {}, {}, {
|
||||
api: {
|
||||
getEffectivePower: mockGetEffectivePower(),
|
||||
getJoinedMembers: async () => ({
|
||||
joined: {
|
||||
"@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null},
|
||||
|
|
@ -1549,7 +1380,6 @@ test("message2event: channel links are converted even inside lists (parser post-
|
|||
+ "\nThis list will probably change in the future"
|
||||
}, data.guild.general, {}, {
|
||||
api: {
|
||||
getEffectivePower: mockGetEffectivePower(),
|
||||
getJoinedMembers(roomID) {
|
||||
called++
|
||||
t.equal(roomID, "!qzDBLKlildpzrrOnFZ:cadence.moe")
|
||||
|
|
@ -1581,226 +1411,3 @@ test("message2event: channel links are converted even inside lists (parser post-
|
|||
])
|
||||
t.equal(called, 1)
|
||||
})
|
||||
|
||||
test("message2event: emoji added special message", async t => {
|
||||
const events = await messageToEvent(data.special_message.emoji_added)
|
||||
t.deepEqual(events, [
|
||||
{
|
||||
$type: "m.room.message",
|
||||
msgtype: "m.emote",
|
||||
body: "added a new emoji, :cx_marvelous: :cx_marvelous:",
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: `added a new emoji, <img data-mx-emoticon height="32" src="mxc://cadence.moe/TPZdosVUjTIopsLijkygIbti" title=":cx_marvelous:" alt=":cx_marvelous:"> :cx_marvelous:`,
|
||||
"m.mentions": {}
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
test("message2event: cross-room reply", async t => {
|
||||
let called = 0
|
||||
const events = await messageToEvent({
|
||||
type: 19,
|
||||
message_reference: {
|
||||
channel_id: "1161864271370666075",
|
||||
guild_id: "1160893336324931584",
|
||||
message_id: "1458091145136443547"
|
||||
},
|
||||
referenced_message: {
|
||||
channel_id: "1161864271370666075",
|
||||
id: "1458091145136443547",
|
||||
content: "",
|
||||
attachments: [{
|
||||
filename: "image.png",
|
||||
id: "1456813607693193478",
|
||||
size: 104006,
|
||||
content_type: "image/png",
|
||||
url: "https://cdn.discordapp.com/attachments/1160893337029586956/1458790740338409605/image.png?ex=696194ff&is=6960437f&hm=923d0ef7d1b249470be49edbc37628cc4ff8a438f0ab12f54c045578135f7050"
|
||||
}],
|
||||
author: {
|
||||
username: "Cadence, Maid of Creation, Eye of Clarity, Empress of Hope ☆"
|
||||
}
|
||||
},
|
||||
content: "cross-room reply"
|
||||
}, {}, {}, {api: {
|
||||
async getEvent(roomID, eventID) {
|
||||
called++
|
||||
t.equal(roomID, "!mHmhQQPwXNananaOLD:cadence.moe")
|
||||
t.equal(eventID, "$pgzCQjq_y5sy8RvWOUuoF3obNHjs8iNvt9c-odrOCPY")
|
||||
return {
|
||||
type: "m.room.message",
|
||||
sender: "@cadence:cadence.moe",
|
||||
content: {
|
||||
"body": "image.png",
|
||||
"info": {
|
||||
"h": 738,
|
||||
"mimetype": "image/png",
|
||||
"org.matrix.msc4230.is_animated": false,
|
||||
"size": 111189,
|
||||
"w": 772,
|
||||
"xyz.amorgan.blurhash": "L255Oa~qRPD$-pxuoJoLIUM{xuxu"
|
||||
},
|
||||
"m.mentions": {},
|
||||
"msgtype": "m.image",
|
||||
"url": "mxc://matrix.org/QbSujQjRLekzPknKlPsXbGDS"
|
||||
}
|
||||
}
|
||||
}
|
||||
}})
|
||||
t.deepEqual(events, [
|
||||
{
|
||||
$type: "m.room.message",
|
||||
msgtype: "m.text",
|
||||
body: "> Cadence, Maid of Creation, Eye of Clarity, Empress of Hope ☆: [Media]\n\ncross-room reply",
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: `<blockquote><a href="https://matrix.to/#/!mHmhQQPwXNananaOLD:cadence.moe/$pgzCQjq_y5sy8RvWOUuoF3obNHjs8iNvt9c-odrOCPY">In reply to</a> <a href="https://matrix.to/#/@cadence:cadence.moe">Cadence, Maid of Creation, Eye of Clarity, Empress of Hope ☆</a><br>[Media]</blockquote>cross-room reply`,
|
||||
"m.mentions": {
|
||||
user_ids: [
|
||||
"@cadence:cadence.moe"
|
||||
]
|
||||
}
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
test("message2event: forwarded message with unreferenced mention", async t => {
|
||||
const events = await messageToEvent({
|
||||
type: 0,
|
||||
content: "",
|
||||
attachments: [],
|
||||
embeds: [],
|
||||
timestamp: "2026-01-20T14:14:21.281Z",
|
||||
edited_timestamp: null,
|
||||
flags: 16384,
|
||||
components: [],
|
||||
id: "1463174818823405651",
|
||||
channel_id: "893634327722721290",
|
||||
author: {
|
||||
id: "100031256988766208",
|
||||
username: "leo60228",
|
||||
discriminator: "0",
|
||||
avatar: "8a164f29946f23eb4f45cde71a75e5a6",
|
||||
avatar_decoration_data: null,
|
||||
public_flags: 768,
|
||||
global_name: "leo vriska",
|
||||
primary_guild: null,
|
||||
collectibles: null,
|
||||
display_name_styles: null
|
||||
},
|
||||
bot: false,
|
||||
pinned: false,
|
||||
mentions: [],
|
||||
mention_roles: [],
|
||||
mention_everyone: false,
|
||||
tts: false,
|
||||
message_reference: {
|
||||
type: 1,
|
||||
channel_id: "937181373943382036",
|
||||
message_id: "1032034158261846038",
|
||||
guild_id: "936370934292549712"
|
||||
},
|
||||
message_snapshots: [
|
||||
{
|
||||
message: {
|
||||
type: 0,
|
||||
content: "<@77084495118868480>",
|
||||
attachments: [
|
||||
{
|
||||
id: "1463174815119704114",
|
||||
filename: "2022-10-18_16-49-46.mp4",
|
||||
size: 51238885,
|
||||
url: "https://cdn.discordapp.com/attachments/893634327722721290/1463174815119704114/2022-10-18_16-49-46.mp4?ex=6970df3c&is=696f8dbc&hm=515d3cbcc8464bdada7f4c3d9ccc8174f671cb75391ce21a46a804fcb1e4befe&",
|
||||
proxy_url: "https://media.discordapp.net/attachments/893634327722721290/1463174815119704114/2022-10-18_16-49-46.mp4?ex=6970df3c&is=696f8dbc&hm=515d3cbcc8464bdada7f4c3d9ccc8174f671cb75391ce21a46a804fcb1e4befe&",
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
content_type: "video/mp4",
|
||||
content_scan_version: 3,
|
||||
spoiler: false
|
||||
}
|
||||
],
|
||||
embeds: [],
|
||||
timestamp: "2022-10-18T20:55:17.597Z",
|
||||
edited_timestamp: null,
|
||||
flags: 0,
|
||||
components: []
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
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)",
|
||||
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>",
|
||||
"m.mentions": {}
|
||||
}])
|
||||
})
|
||||
|
||||
test("message2event: single-choice poll", async t => {
|
||||
const events = await messageToEvent(data.message.poll_single_choice, data.guild.general, {})
|
||||
t.deepEqual(events, [{
|
||||
$type: "org.matrix.msc3381.poll.start",
|
||||
"org.matrix.msc3381.poll.start": {
|
||||
question: {
|
||||
"org.matrix.msc1767.text": "only one answer allowed!",
|
||||
body: "only one answer allowed!",
|
||||
msgtype: "m.text"
|
||||
},
|
||||
kind: "org.matrix.msc3381.poll.disclosed", // Discord always lets you see results, so keeping this consistent with that.
|
||||
max_selections: 1,
|
||||
answers: [{
|
||||
id: "1",
|
||||
"org.matrix.msc1767.text": "[\ud83d\udc4d] answer one"
|
||||
}, {
|
||||
id: "2",
|
||||
"org.matrix.msc1767.text": "[\ud83d\udc4e] answer two"
|
||||
}, {
|
||||
id: "3",
|
||||
"org.matrix.msc1767.text": "answer three"
|
||||
}]
|
||||
},
|
||||
"org.matrix.msc1767.text": "only one answer allowed!\n1. [\ud83d\udc4d] answer one\n2. [\ud83d\udc4e] answer two\n3. answer three"
|
||||
}])
|
||||
})
|
||||
|
||||
test("message2event: multiple-choice poll", async t => {
|
||||
const events = await messageToEvent(data.message.poll_multiple_choice, data.guild.general, {})
|
||||
t.deepEqual(events, [{
|
||||
$type: "org.matrix.msc3381.poll.start",
|
||||
"org.matrix.msc3381.poll.start": {
|
||||
question: {
|
||||
"org.matrix.msc1767.text": "more than one answer allowed",
|
||||
body: "more than one answer allowed",
|
||||
msgtype: "m.text"
|
||||
},
|
||||
kind: "org.matrix.msc3381.poll.disclosed", // Discord always lets you see results, so keeping this consistent with that.
|
||||
max_selections: 3,
|
||||
answers: [{
|
||||
id: "1",
|
||||
"org.matrix.msc1767.text": "[😭] no"
|
||||
}, {
|
||||
id: "2",
|
||||
"org.matrix.msc1767.text": "oh no"
|
||||
}, {
|
||||
id: "3",
|
||||
"org.matrix.msc1767.text": "oh noooooo"
|
||||
}]
|
||||
},
|
||||
"org.matrix.msc1767.text": "more than one answer allowed\n1. [😭] no\n2. oh no\n3. oh noooooo"
|
||||
}])
|
||||
})
|
||||
|
||||
test("message2event: smalltext from regular user", async t => {
|
||||
const events = await messageToEvent({
|
||||
content: "-# hmm",
|
||||
author: {
|
||||
bot: false
|
||||
}
|
||||
})
|
||||
t.deepEqual(events, [{
|
||||
$type: "m.room.message",
|
||||
msgtype: "m.text",
|
||||
"m.mentions": {},
|
||||
body: "...hmm"
|
||||
}])
|
||||
})
|
||||
|
|
|
|||
|
|
@ -3,11 +3,10 @@
|
|||
const {select} = require("../../passthrough")
|
||||
|
||||
/**
|
||||
* @param {import("discord-api-types/v10").RESTGetAPIChannelMessagesPinsResult} pins
|
||||
* @param {import("discord-api-types/v10").RESTGetAPIChannelPinsResult} pins
|
||||
* @param {{"m.room.pinned_events/"?: {pinned?: string[]}}} kstate
|
||||
*/
|
||||
function pinsToList(pins, kstate) {
|
||||
/** Most recent last. */
|
||||
let alreadyPinned = kstate["m.room.pinned_events/"]?.pinned || []
|
||||
|
||||
// If any of the already pinned messages are bridged messages then remove them from the already pinned list.
|
||||
|
|
@ -16,13 +15,13 @@ function pinsToList(pins, kstate) {
|
|||
// * Matrix-only unbridged messages that are pinned will remain pinned.
|
||||
alreadyPinned = alreadyPinned.filter(event_id => {
|
||||
const messageID = select("event_message", "message_id", {event_id}).pluck().get()
|
||||
return !messageID || pins.items.find(m => m.message.id === messageID) // if it is bridged then remove it from the filter
|
||||
return !messageID || pins.find(m => m.id === messageID) // if it is bridged then remove it from the filter
|
||||
})
|
||||
|
||||
/** @type {string[]} */
|
||||
const result = []
|
||||
for (const pin of pins.items) {
|
||||
const eventID = select("event_message", "event_id", {message_id: pin.message.id, part: 0}).pluck().get()
|
||||
for (const message of pins) {
|
||||
const eventID = select("event_message", "event_id", {message_id: message.id, part: 0}).pluck().get()
|
||||
if (eventID && !alreadyPinned.includes(eventID)) result.push(eventID)
|
||||
}
|
||||
result.reverse()
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
const {test} = require("supertape")
|
||||
const data = require("../../../test/data")
|
||||
const {pinsToList} = require("./pins-to-list")
|
||||
const mixin = require("@cloudrac3r/mixin-deep")
|
||||
|
||||
test("pins2list: converts known IDs, ignores unknown IDs", t => {
|
||||
const result = pinsToList(data.pins.faked, {})
|
||||
|
|
@ -47,9 +46,7 @@ test("pins2list: already pinned unknown items are not moved", t => {
|
|||
})
|
||||
|
||||
test("pins2list: bridged messages can be unpinned", t => {
|
||||
const shortPins = mixin({}, data.pins.faked)
|
||||
shortPins.items = shortPins.items.slice(0, -2)
|
||||
const result = pinsToList(shortPins, {
|
||||
const result = pinsToList(data.pins.faked.slice(0, -2), {
|
||||
"m.room.pinned_events/": {
|
||||
pinned: [
|
||||
"$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA",
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ const DiscordTypes = require("discord-api-types/v10")
|
|||
|
||||
const passthrough = require("../../passthrough")
|
||||
const {discord, sync, select} = passthrough
|
||||
/** @type {import("../../matrix/utils")} */
|
||||
const utils = sync.require("../../matrix/utils")
|
||||
/** @type {import("../../m2d/converters/utils")} */
|
||||
const utils = sync.require("../../m2d/converters/utils")
|
||||
|
||||
/**
|
||||
* @typedef ReactionRemoveRequest
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ const assert = require("assert").strict
|
|||
|
||||
const passthrough = require("../../passthrough")
|
||||
const {discord, sync, db, select} = passthrough
|
||||
/** @type {import("../../matrix/utils")} */
|
||||
const mxUtils = sync.require("../../matrix/utils")
|
||||
/** @type {import("../../m2d/converters/utils")} */
|
||||
const mxUtils = sync.require("../../m2d/converters/utils")
|
||||
const {reg} = require("../../matrix/read-registration.js")
|
||||
|
||||
const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex))
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ const {test} = require("supertape")
|
|||
const {threadToAnnouncement} = require("./thread-to-announcement")
|
||||
const data = require("../../../test/data")
|
||||
const Ty = require("../../types")
|
||||
const {mockGetEffectivePower} = require("../../matrix/utils.test")
|
||||
|
||||
/**
|
||||
* @param {string} roomID
|
||||
|
|
@ -31,7 +30,13 @@ function mockGetEvent(t, roomID_in, eventID_in, outer) {
|
|||
}
|
||||
|
||||
const viaApi = {
|
||||
getEffectivePower: mockGetEffectivePower(),
|
||||
async getStateEvent(roomID, type, key) {
|
||||
return {
|
||||
users: {
|
||||
"@_ooye_bot:cadence.moe": 100
|
||||
}
|
||||
}
|
||||
},
|
||||
async getJoinedMembers(roomID) {
|
||||
return {
|
||||
joined: {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
const assert = require("assert")
|
||||
const {reg} = require("../../matrix/read-registration")
|
||||
const Ty = require("../../types")
|
||||
|
||||
const passthrough = require("../../passthrough")
|
||||
const {select} = passthrough
|
||||
|
|
@ -14,16 +13,16 @@ const SPECIAL_USER_MAPPINGS = new Map([
|
|||
/**
|
||||
* Downcased and stripped username. Can only include a basic set of characters.
|
||||
* https://spec.matrix.org/v1.6/appendices/#user-identifiers
|
||||
* @param {import("discord-api-types/v10").APIUser | Ty.WebhookAuthor} user
|
||||
* @param {import("discord-api-types/v10").APIUser} user
|
||||
* @returns {string} localpart
|
||||
*/
|
||||
function downcaseUsername(user) {
|
||||
// First, try to convert the username to the set of allowed characters
|
||||
let downcased = user.username.toLowerCase()
|
||||
// spaces and slashes to underscores...
|
||||
.replace(/[ /]/g, "_")
|
||||
// spaces to underscores...
|
||||
.replace(/ /g, "_")
|
||||
// remove disallowed characters...
|
||||
.replace(/[^a-z0-9._=-]*/g, "")
|
||||
.replace(/[^a-z0-9._=/-]*/g, "")
|
||||
// remove leading and trailing dashes and underscores...
|
||||
.replace(/(?:^[_-]*|[_-]*$)/g, "")
|
||||
// If requested, also make the Discord user ID part of the username
|
||||
|
|
@ -86,49 +85,4 @@ function userToSimName(user) {
|
|||
throw new Error(`Ran out of suggestions when generating sim name. downcased: "${downcased}"`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Webhooks have an ID specific to that webhook, but a single webhook can send messages with any user name.
|
||||
* The point of this feature (gated by guild_space webhook_profile) is to create persistent Matrix accounts for individual webhook "users".
|
||||
* This is convenient when using a bridge to a platform that does not assign persistent user IDs (e.g. IRC, Minecraft).
|
||||
* In this case, webhook "users" are disambiguated by their username (downcased).
|
||||
* @param {Ty.WebhookAuthor} author
|
||||
* @returns {string}
|
||||
*/
|
||||
function webhookAuthorToFakeUserID(author) {
|
||||
const downcased = downcaseUsername(author)
|
||||
return `webhook_${downcased}`
|
||||
}
|
||||
|
||||
function isWebhookUserID(userID) {
|
||||
return userID.match(/^webhook_[a-z90-9._=/-]+$/)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Ty.WebhookAuthor} author
|
||||
* @returns {string}
|
||||
*/
|
||||
function webhookAuthorToSimName(author) {
|
||||
assert(!SPECIAL_USER_MAPPINGS.has(author.id), "Special users should have followed the other code path.")
|
||||
|
||||
// 1. Is sim user already registered?
|
||||
const fakeUserID = webhookAuthorToFakeUserID(author)
|
||||
const existing = select("sim", "user_id", {user_id: fakeUserID}).pluck().get()
|
||||
assert.equal(existing, null, "Shouldn't try to create a new name for an existing sim")
|
||||
|
||||
// 2. Register based on username (could be new or old format)
|
||||
const downcased = "webhook_" + downcaseUsername(author)
|
||||
|
||||
// Check for conflicts with already registered sims
|
||||
const matches = select("sim", "sim_name", {}, "WHERE sim_name LIKE ? ESCAPE '@'").pluck().all(downcased + "%")
|
||||
// Keep generating until we get a suggestion that doesn't conflict
|
||||
for (const suggestion of generateLocalpartAlternatives([downcased])) {
|
||||
if (!matches.includes(suggestion)) return suggestion
|
||||
}
|
||||
/* c8 ignore next */
|
||||
throw new Error(`Ran out of suggestions when generating sim name. downcased: "${downcased}"`)
|
||||
}
|
||||
|
||||
module.exports.userToSimName = userToSimName
|
||||
module.exports.webhookAuthorToFakeUserID = webhookAuthorToFakeUserID
|
||||
module.exports.webhookAuthorToSimName = webhookAuthorToSimName
|
||||
module.exports.isWebhookUserID = isWebhookUserID
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ const {test} = require("supertape")
|
|||
const tryToCatch = require("try-to-catch")
|
||||
const assert = require("assert")
|
||||
const data = require("../../../test/data")
|
||||
const {userToSimName, webhookAuthorToSimName} = require("./user-to-mxid")
|
||||
const {userToSimName} = require("./user-to-mxid")
|
||||
|
||||
test("user2name: cannot create user for a webhook", async t => {
|
||||
const [error] = await tryToCatch(() => userToSimName({discriminator: "0000"}))
|
||||
|
|
@ -21,12 +21,8 @@ test("user2name: works on single emoji at the end", t => {
|
|||
t.equal(userToSimName({username: "Melody 🎵", discriminator: "2192"}), "melody")
|
||||
})
|
||||
|
||||
test("user2name: works on really weird name", t => {
|
||||
t.equal(userToSimName({username: "*** D3 &W (89) _7//-", discriminator: "0001"}), "d3_w_89__7")
|
||||
})
|
||||
|
||||
test("user2name: treats slashes", t => {
|
||||
t.equal(userToSimName({username: "Evil Lillith (she/her)", discriminator: "5892"}), "evil_lillith_she_her")
|
||||
test("user2name: works on crazy name", t => {
|
||||
t.equal(userToSimName({username: "*** D3 &W (89) _7//-", discriminator: "0001"}), "d3_w_89__7//")
|
||||
})
|
||||
|
||||
test("user2name: adds discriminator if name is unavailable (old tag format)", t => {
|
||||
|
|
@ -49,17 +45,10 @@ test("user2name: works on special user", t => {
|
|||
t.equal(userToSimName(data.user.clyde_ai), "clyde_ai")
|
||||
})
|
||||
|
||||
test("webhook author: can generate sim names", t => {
|
||||
t.equal(webhookAuthorToSimName({
|
||||
username: "Cadence, Maid of Creation, Eye of Clarity, Empress of Hope ☆",
|
||||
avatar: null,
|
||||
id: "123"
|
||||
}), "webhook_cadence_maid_of_creation_eye_of_clarity_empress_of_hope")
|
||||
})
|
||||
|
||||
test("user2name: includes ID if requested in config", t => {
|
||||
const {reg} = require("../../matrix/read-registration")
|
||||
reg.ooye.include_user_id_in_mxid = true
|
||||
t.equal(userToSimName({username: "Harry Styles!", discriminator: "0001", id: "123456"}), "123456_harry_styles")
|
||||
t.equal(userToSimName({username: "f***", discriminator: "0001", id: "123456"}), "123456_f")
|
||||
t.equal(userToSimName({username: "f***", discriminator: "0001", id: "123456"}), "123456_f")
|
||||
reg.ooye.include_user_id_in_mxid = false
|
||||
})
|
||||
|
|
|
|||
|
|
@ -20,19 +20,17 @@ class DiscordClient {
|
|||
* @param {string} listen "full", "half", "no" - whether to set up the event listeners for OOYE to operate
|
||||
*/
|
||||
constructor(discordToken, listen = "full") {
|
||||
/** @type {import("cloudstorm").IClientOptions["intents"]} */
|
||||
const intents = [
|
||||
"DIRECT_MESSAGES", "DIRECT_MESSAGE_REACTIONS", "DIRECT_MESSAGE_TYPING",
|
||||
"GUILDS", "GUILD_EMOJIS_AND_STICKERS", "GUILD_MESSAGES", "GUILD_MESSAGE_REACTIONS", "GUILD_MESSAGE_TYPING", "GUILD_WEBHOOKS", "GUILD_MESSAGE_POLLS",
|
||||
"MESSAGE_CONTENT"
|
||||
]
|
||||
if (reg.ooye.receive_presences !== false) intents.push("GUILD_PRESENCES")
|
||||
this.discordToken = discordToken
|
||||
this.snow = new SnowTransfer(discordToken)
|
||||
this.cloud = new CloudStorm(discordToken, {
|
||||
shards: [0],
|
||||
reconnect: true,
|
||||
snowtransferInstance: this.snow,
|
||||
intents,
|
||||
intents: [
|
||||
"DIRECT_MESSAGES", "DIRECT_MESSAGE_REACTIONS", "DIRECT_MESSAGE_TYPING",
|
||||
"GUILDS", "GUILD_EMOJIS_AND_STICKERS", "GUILD_MESSAGES", "GUILD_MESSAGE_REACTIONS", "GUILD_MESSAGE_TYPING", "GUILD_WEBHOOKS",
|
||||
"MESSAGE_CONTENT", "GUILD_PRESENCES"
|
||||
],
|
||||
ws: {
|
||||
compress: false,
|
||||
encoding: "json"
|
||||
|
|
|
|||
|
|
@ -6,6 +6,10 @@ const DiscordTypes = require("discord-api-types/v10")
|
|||
const passthrough = require("../passthrough")
|
||||
const {sync, db} = passthrough
|
||||
|
||||
function populateGuildID(guildID, channelID) {
|
||||
db.prepare("UPDATE channel_room SET guild_id = ? WHERE channel_id = ?").run(guildID, channelID)
|
||||
}
|
||||
|
||||
const utils = {
|
||||
/**
|
||||
* @param {import("./discord-client")} client
|
||||
|
|
@ -37,17 +41,18 @@ const utils = {
|
|||
channel.guild_id = message.d.id
|
||||
arr.push(channel.id)
|
||||
client.channels.set(channel.id, channel)
|
||||
populateGuildID(message.d.id, channel.id)
|
||||
}
|
||||
for (const thread of message.d.threads || []) {
|
||||
// @ts-ignore
|
||||
thread.guild_id = message.d.id
|
||||
arr.push(thread.id)
|
||||
client.channels.set(thread.id, thread)
|
||||
populateGuildID(message.d.id, thread.id)
|
||||
}
|
||||
|
||||
if (listen === "full") {
|
||||
try {
|
||||
interactions.registerInteractions()
|
||||
await eventDispatcher.checkMissedExpressions(message.d)
|
||||
await eventDispatcher.checkMissedPins(client, message.d)
|
||||
await eventDispatcher.checkMissedMessages(client, message.d)
|
||||
|
|
@ -107,6 +112,7 @@ const utils = {
|
|||
} else if (message.t === "THREAD_CREATE") {
|
||||
client.channels.set(message.d.id, message.d)
|
||||
if (message.d["guild_id"]) {
|
||||
populateGuildID(message.d["guild_id"], message.d.id)
|
||||
const channels = client.guildChannelMap.get(message.d["guild_id"])
|
||||
if (channels && !channels.includes(message.d.id)) channels.push(message.d.id)
|
||||
}
|
||||
|
|
@ -134,6 +140,7 @@ const utils = {
|
|||
} else if (message.t === "CHANNEL_CREATE") {
|
||||
client.channels.set(message.d.id, message.d)
|
||||
if (message.d["guild_id"]) { // obj[prop] notation can be used to access a property without typescript complaining that it doesn't exist on all values something can have
|
||||
populateGuildID(message.d["guild_id"], message.d.id)
|
||||
const channels = client.guildChannelMap.get(message.d["guild_id"])
|
||||
if (channels && !channels.includes(message.d.id)) channels.push(message.d.id)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,12 +32,8 @@ const speedbump = sync.require("./actions/speedbump")
|
|||
const retrigger = sync.require("./actions/retrigger")
|
||||
/** @type {import("./actions/set-presence")} */
|
||||
const setPresence = sync.require("./actions/set-presence")
|
||||
/** @type {import("./actions/poll-vote")} */
|
||||
const vote = sync.require("./actions/poll-vote")
|
||||
/** @type {import("../m2d/event-dispatcher")} */
|
||||
const matrixEventDispatcher = sync.require("../m2d/event-dispatcher")
|
||||
/** @type {import("../discord/interactions/matrix-info")} */
|
||||
const matrixInfoInteraction = sync.require("../discord/interactions/matrix-info")
|
||||
|
||||
const {Semaphore} = require("@chriscdn/promise-semaphore")
|
||||
const checkMissedPinsSema = new Semaphore()
|
||||
|
|
@ -51,15 +47,13 @@ module.exports = {
|
|||
* @param {import("cloudstorm").IGatewayMessage} gatewayMessage
|
||||
*/
|
||||
async onError(client, e, gatewayMessage) {
|
||||
if (gatewayMessage.t === "TYPING_START") return
|
||||
|
||||
matrixEventDispatcher.printError(gatewayMessage.t, "Discord", e, gatewayMessage)
|
||||
|
||||
const channelID = gatewayMessage.d["channel_id"]
|
||||
if (!channelID) return
|
||||
const roomID = select("channel_room", "room_id", {channel_id: channelID}).pluck().get()
|
||||
if (!roomID) return
|
||||
|
||||
if (gatewayMessage.t === "TYPING_START") return
|
||||
|
||||
await matrixEventDispatcher.sendError(roomID, "Discord", gatewayMessage.t, e, gatewayMessage)
|
||||
},
|
||||
|
||||
|
|
@ -73,7 +67,7 @@ module.exports = {
|
|||
async checkMissedMessages(client, guild) {
|
||||
if (guild.unavailable) return
|
||||
const bridgedChannels = select("channel_room", "channel_id").pluck().all()
|
||||
const preparedExists = from("message_room").join("historical_channel_room", "historical_room_index").pluck("message_id").and("WHERE reference_channel_id = ? LIMIT 1").prepare()
|
||||
const preparedExists = db.prepare("SELECT channel_id FROM message_channel WHERE channel_id = ? LIMIT 1")
|
||||
const preparedGet = select("event_message", "event_id", {}, "WHERE message_id = ?").pluck()
|
||||
/** @type {(DiscordTypes.APIChannel & {type: DiscordTypes.GuildChannelType})[]} */
|
||||
let channels = []
|
||||
|
|
@ -93,7 +87,7 @@ module.exports = {
|
|||
const member = guild.members.find(m => m.user?.id === client.user.id)
|
||||
if (!member) return
|
||||
if (!("permission_overwrites" in channel)) continue
|
||||
const permissions = dUtils.getPermissions(guild.id, member.roles, guild.roles, client.user.id, channel.permission_overwrites)
|
||||
const permissions = dUtils.getPermissions(member.roles, guild.roles, client.user.id, channel.permission_overwrites)
|
||||
if (!dUtils.hasAllPermissions(permissions, ["ViewChannel", "ReadMessageHistory"])) continue // We don't have permission to look back in this channel
|
||||
|
||||
/** More recent messages come first. */
|
||||
|
|
@ -152,7 +146,7 @@ module.exports = {
|
|||
const lastPin = updatePins.convertTimestamp(channel.last_pin_timestamp)
|
||||
|
||||
// Permissions check
|
||||
const permissions = dUtils.getPermissions(guild.id, member.roles, guild.roles, client.user.id, channel.permission_overwrites)
|
||||
const permissions = dUtils.getPermissions(member.roles, guild.roles, client.user.id, channel.permission_overwrites)
|
||||
if (!dUtils.hasAllPermissions(permissions, ["ViewChannel", "ReadMessageHistory"])) continue // We don't have permission to look up the pins in this channel
|
||||
|
||||
const row = select("channel_room", ["room_id", "last_bridged_pin_timestamp"], {channel_id: channel.id}).get()
|
||||
|
|
@ -196,21 +190,6 @@ module.exports = {
|
|||
await createSpace.syncSpace(guild)
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {import("./discord-client")} client
|
||||
* @param {DiscordTypes.GatewayGuildRoleUpdateDispatchData} data
|
||||
*/
|
||||
async GUILD_ROLE_UPDATE(client, data) {
|
||||
const guild = client.guilds.get(data.guild_id)
|
||||
if (!guild) return
|
||||
const spaceID = select("guild_space", "space_id", {guild_id: data.guild_id}).pluck().get()
|
||||
if (!spaceID) return
|
||||
|
||||
if (data.role.id === data.guild_id) { // @everyone role changed - find a way to do this more efficiently in the future to handle many role updates
|
||||
await createSpace.syncSpaceFully(guild)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {import("./discord-client")} client
|
||||
* @param {DiscordTypes.GatewayChannelUpdateDispatchData} channelOrThread
|
||||
|
|
@ -250,7 +229,7 @@ module.exports = {
|
|||
const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get()
|
||||
if (!roomID) return // channel wasn't being bridged in the first place
|
||||
// @ts-ignore
|
||||
await createRoom.unbridgeChannel(channel, guildID)
|
||||
await createRoom.unbridgeDeletedChannel(channel, guildID)
|
||||
},
|
||||
|
||||
/**
|
||||
|
|
@ -291,7 +270,12 @@ module.exports = {
|
|||
// Based on looking at data they've sent me over the gateway, this is the best way to check for meaningful changes.
|
||||
// If the message content is a string then it includes all interesting fields and is meaningful.
|
||||
// Otherwise, if there are embeds, then the system generated URL preview embeds.
|
||||
if (!(typeof data.content === "string" || "embeds" in data || "components" in data)) return
|
||||
if (!(typeof data.content === "string" || "embeds" in data)) return
|
||||
|
||||
if (data.webhook_id) {
|
||||
const row = select("webhook", "webhook_id", {webhook_id: data.webhook_id}).pluck().get()
|
||||
if (row) return // The message was sent by the bridge's own webhook on discord. We don't want to reflect this back, so just drop it.
|
||||
}
|
||||
|
||||
if (dUtils.isEphemeralMessage(data)) return // Ephemeral messages are for the eyes of the receiver only!
|
||||
|
||||
|
|
@ -299,10 +283,8 @@ module.exports = {
|
|||
const {affected, row} = await speedbump.maybeDoSpeedbump(data.channel_id, data.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
|
||||
}
|
||||
// Check that the sending-to room exists, and deal with Eventual Consistency(TM)
|
||||
if (retrigger.eventNotFoundThenRetrigger(data.id, module.exports.MESSAGE_UPDATE, client, data)) return
|
||||
|
||||
/** @type {DiscordTypes.GatewayMessageCreateDispatchData} */
|
||||
// @ts-ignore
|
||||
|
|
@ -322,16 +304,7 @@ module.exports = {
|
|||
*/
|
||||
async MESSAGE_REACTION_ADD(client, data) {
|
||||
if (data.user_id === client.user.id) return // m2d reactions are added by the discord bot user - do not reflect them back to matrix.
|
||||
if (data.emoji.name === "❓" && select("event_message", "message_id", {message_id: data.message_id, source: 0, part: 0}).get()) { // source 0 = matrix
|
||||
const guild_id = data.guild_id ?? client.channels.get(data.channel_id)?.["guild_id"]
|
||||
await Promise.all([
|
||||
client.snow.channel.deleteReaction(data.channel_id, data.message_id, data.emoji.name).catch(() => {}),
|
||||
// @ts-ignore - this is all you need for it to do a matrix-side lookup
|
||||
matrixInfoInteraction.dm({guild_id, data: {target_id: data.message_id}, member: {user: {id: data.user_id}}})
|
||||
])
|
||||
} else {
|
||||
await addReaction.addReaction(data)
|
||||
}
|
||||
await addReaction.addReaction(data)
|
||||
},
|
||||
|
||||
/**
|
||||
|
|
@ -391,20 +364,6 @@ module.exports = {
|
|||
await createSpace.syncSpaceExpressions(data, false)
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {import("./discord-client")} client
|
||||
* @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
|
||||
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
|
||||
await vote.removeVote(data)
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {import("./discord-client")} client
|
||||
* @param {DiscordTypes.GatewayPresenceUpdateDispatchData} data
|
||||
|
|
|
|||
225
src/db/migrations/0017-analyze.sql
Normal file
225
src/db/migrations/0017-analyze.sql
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
-- https://www.sqlite.org/lang_analyze.html
|
||||
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
ANALYZE sqlite_schema;
|
||||
|
||||
DELETE FROM "sqlite_stat1";
|
||||
INSERT INTO "sqlite_stat1" ("tbl","idx","stat") VALUES ('sim','sim','625 1'),
|
||||
('reaction','reaction','3242 1'),
|
||||
('channel_room','channel_room','389 1'),
|
||||
('channel_room','sqlite_autoindex_channel_room_1','389 1'),
|
||||
('media_proxy','media_proxy','5068 1'),
|
||||
('sim_proxy','sim_proxy','36 1'),
|
||||
('webhook','webhook','155 1'),
|
||||
('member_cache','member_cache','784 3 1'),
|
||||
('member_power','member_power','1 1 1'),
|
||||
('file','file','21862 1'),
|
||||
('message_channel','message_channel','366884 1'),
|
||||
('lottie','lottie','19 1'),
|
||||
('event_message','event_message','382920 1 1'),
|
||||
('migration',NULL,'1'),
|
||||
('sim_member','sim_member','2871 7 1'),
|
||||
('guild_space','guild_space','32 1'),
|
||||
('guild_active','guild_active','34 1'),
|
||||
('emoji','emoji','2563 1'),
|
||||
('auto_emoji','auto_emoji','3 1');
|
||||
|
||||
DELETE FROM "sqlite_stat4";
|
||||
INSERT INTO "sqlite_stat4" ("tbl","idx","neq","nlt","ndlt","sample") VALUES ('sim','sim','1','69','69',X'0231313137363631373038303932333039353039'),
|
||||
('sim','sim','1','139','139',X'0231313530383936363934333439373931323332'),
|
||||
('sim','sim','1','209','209',X'0231323231383737363334373737323139303732'),
|
||||
('sim','sim','1','279','279',X'0231333039313431353735353334313136383636'),
|
||||
('sim','sim','1','349','349',X'0231333935343433383235363034313635363434'),
|
||||
('sim','sim','1','419','419',X'0231353335363239373830383338353134373030'),
|
||||
('sim','sim','1','489','489',X'0231363930333339333730353930363636383034'),
|
||||
('sim','sim','1','559','559',X'0231383535353736303637393137323137383133'),
|
||||
('reaction','reaction','1','360','360',X'020699d5faceefb5fb4f'),
|
||||
('reaction','reaction','1','721','721',X'0206b61095e98b6b2fb1'),
|
||||
('reaction','reaction','1','1082','1082',X'0206d1dcb418603a5eaa'),
|
||||
('reaction','reaction','1','1443','1443',X'0206ef9fc42b9df746ad'),
|
||||
('reaction','reaction','1','1804','1804',X'02060f38c1f98f130605'),
|
||||
('reaction','reaction','1','2165','2165',X'02062b53df6dab7b1067'),
|
||||
('reaction','reaction','1','2526','2526',X'020645dd7e7f60c4aac7'),
|
||||
('reaction','reaction','1','2887','2887',X'0206658d2fe735805979'),
|
||||
('channel_room','channel_room','1','43','43',X'023331313434393131333330393139333231363330'),
|
||||
('channel_room','channel_room','1','87','87',X'023331313835343033343830303934303335393738'),
|
||||
('channel_room','channel_room','1','131','131',X'023331323139353036353836343139303638393839'),
|
||||
('channel_room','channel_room','1','175','175',X'023331323336353538333034323331303334393630'),
|
||||
('channel_room','channel_room','1','219','219',X'023331323933373932323135333930323234343235'),
|
||||
('channel_room','channel_room','1','263','263',X'023331333333323139363936393333323038303937'),
|
||||
('channel_room','channel_room','1','307','307',X'0231343835363635393733363433333738363938'),
|
||||
('channel_room','channel_room','1','351','351',X'0231373039303432313039353632323234363731'),
|
||||
('channel_room','sqlite_autoindex_channel_room_1','1 1','6 6','6 6',X'034b3321416a6c4c49464e6248646474424a6d4d73503a636164656e63652e6d6f6531313531333434383735363139343833373233'),
|
||||
('channel_room','sqlite_autoindex_channel_room_1','1 1','34 34','34 34',X'034b3321474b4a63424a6b527a47634e4855686c50613a636164656e63652e6d6f6531303237393433323532323237323630343637'),
|
||||
('channel_room','sqlite_autoindex_channel_room_1','1 1','43 43','43 43',X'034b3121484b50534d62736d694673506d6268414f513a636164656e63652e6d6f65313931343837343839393433343034353434'),
|
||||
('channel_room','sqlite_autoindex_channel_room_1','1 1','58 58','58 58',X'034b33214a4479425a685545706874784f6e6f6569513a636164656e63652e6d6f6531323937323836373434353633313236333532'),
|
||||
('channel_room','sqlite_autoindex_channel_room_1','1 1','87 87','87 87',X'034b33214e544d724e686e715271695755654d494d523a636164656e63652e6d6f6531323235323434353738393939373031353536'),
|
||||
('channel_room','sqlite_autoindex_channel_room_1','1 1','108 108','108 108',X'034b332151444e44796656674e7657565345656876713a636164656e63652e6d6f6531313432333134303935353535363435343830'),
|
||||
('channel_room','sqlite_autoindex_channel_room_1','1 1','131 131','131 131',X'034b3121544171536b575752654b43506f584c6a75483a636164656e63652e6d6f65383737303730363531343733363631393532'),
|
||||
('channel_room','sqlite_autoindex_channel_room_1','1 1','175 175','175 175',X'034b3321594249486864714e697255585941587845563a636164656e63652e6d6f6531323335303831373939353936373639333730'),
|
||||
('channel_room','sqlite_autoindex_channel_room_1','1 1','177 177','177 177',X'034b3321594b46454e79716667696951686956496b533a636164656e63652e6d6f6531323934363237303431343530333933373034'),
|
||||
('channel_room','sqlite_autoindex_channel_room_1','1 1','186 186','186 186',X'034b3321596f54644f55766a53765349767266716c653a636164656e63652e6d6f6531323734313936373733383435333430323933'),
|
||||
('channel_room','sqlite_autoindex_channel_room_1','1 1','202 202','202 202',X'034b3121625877616673695372655647676470535a463a636164656e63652e6d6f65373339303137363739373936343336393932'),
|
||||
('channel_room','sqlite_autoindex_channel_room_1','1 1','208 208','208 208',X'034b3321634a4b6843764943795377717a47634551423a636164656e63652e6d6f6531323732363632303331323238373331343834'),
|
||||
('channel_room','sqlite_autoindex_channel_room_1','1 1','219 219','219 219',X'034b3121656455786a56647a6755765844554951434b3a636164656e63652e6d6f65343937313631333530393334353630373738'),
|
||||
('channel_room','sqlite_autoindex_channel_room_1','1 1','242 242','242 242',X'034b31216a4d746e6e6f51414e4278466a486458494d3a636164656e63652e6d6f65373634353135323932303539323731313939'),
|
||||
('channel_room','sqlite_autoindex_channel_room_1','1 1','263 263','263 263',X'034b31216c7a776870666a5a6e59797468656a7453483a636164656e63652e6d6f65383838343831373132383438343030343534'),
|
||||
('channel_room','sqlite_autoindex_channel_room_1','1 1','264 264','264 264',X'034b33216d454c5846716a426958726d7558796943723a636164656e63652e6d6f6531313936393134373631303430393234373432'),
|
||||
('channel_room','sqlite_autoindex_channel_room_1','1 1','268 268','268 268',X'034b33216d557765577571546761574a767769576a653a636164656e63652e6d6f6531323936373131393236333032333830303834'),
|
||||
('channel_room','sqlite_autoindex_channel_room_1','1 1','291 291','291 291',X'034b3321704761494e45534643587a634e42497a724e3a636164656e63652e6d6f6531303237343531333333333533313532353232'),
|
||||
('channel_room','sqlite_autoindex_channel_room_1','1 1','306 306','306 306',X'034b3321717646656248564f4b6876454e54494563763a636164656e63652e6d6f6531323737373238383139323232303230313436'),
|
||||
('channel_room','sqlite_autoindex_channel_room_1','1 1','307 307','307 307',X'034b332171767370666d716f476449634a66794c506c3a636164656e63652e6d6f6531323936393138333638393539343633343735'),
|
||||
('channel_room','sqlite_autoindex_channel_room_1','1 1','351 351','351 351',X'034b3321774e7a7741724a47796f4c5168426f544e4b3a636164656e63652e6d6f6531303238303436373930333435333739383930'),
|
||||
('channel_room','sqlite_autoindex_channel_room_1','1 1','368 368','368 368',X'034b3321794e6d504c7765654a69756570725a677a733a636164656e63652e6d6f6531323531393631373233373731313632363234'),
|
||||
('channel_room','sqlite_autoindex_channel_room_1','1 1','376 376','376 376',X'034b3121796a4879795772466f704c66646878564e423a636164656e63652e6d6f65333336313537353037303734353233313336'),
|
||||
('channel_room','sqlite_autoindex_channel_room_1','1 1','379 379','379 379',X'034b31217a4c4f6b62766b44587551465948594555673a636164656e63652e6d6f65393933383838313433373030393330363331'),
|
||||
('media_proxy','media_proxy','1','563','563',X'02069e6054680b610946'),
|
||||
('media_proxy','media_proxy','1','1127','1127',X'0206bb489b717c9320e4'),
|
||||
('media_proxy','media_proxy','1','1691','1691',X'0206d75f602775b7a27c'),
|
||||
('media_proxy','media_proxy','1','2255','2255',X'0206f2c705ddca4e2b14'),
|
||||
('media_proxy','media_proxy','1','2819','2819',X'02061060db7a5151967b'),
|
||||
('media_proxy','media_proxy','1','3383','3383',X'02062cc47366f7550d22'),
|
||||
('media_proxy','media_proxy','1','3947','3947',X'020647d275ec0d781fc7'),
|
||||
('media_proxy','media_proxy','1','4511','4511',X'02066402024a7ea38249'),
|
||||
('sim_proxy','sim_proxy','1','4','4',X'025531316564343731342d636635652d346333372d393331382d376136353266383732636634'),
|
||||
('sim_proxy','sim_proxy','1','9','9',X'025533346636333932642d323263372d346337382d393063372d326536323734313535613266'),
|
||||
('sim_proxy','sim_proxy','1','14','14',X'025535396662363131392d626133392d346565382d393738612d386432376366303631393633'),
|
||||
('sim_proxy','sim_proxy','1','19','19',X'025539373066366536332d646234632d346531342d383063362d336639343938643961363665'),
|
||||
('sim_proxy','sim_proxy','1','24','24',X'025561636231613335642d313336662d343362332d626365622d326566646634616265306436'),
|
||||
('sim_proxy','sim_proxy','1','29','29',X'025563316635623735392d336136342d343633342d623634632d643461656436316539656632'),
|
||||
('sim_proxy','sim_proxy','1','34','34',X'025566323230373135632d633436332d343532622d626233612d373662646662306365353537'),
|
||||
('webhook','webhook','1','17','17',X'023331313532383834313435343038373230393936'),
|
||||
('webhook','webhook','1','35','35',X'023331313939303936333434333830343631313138'),
|
||||
('webhook','webhook','1','53','53',X'023331323331383036353337373032353736313938'),
|
||||
('webhook','webhook','1','71','71',X'023331323933373836383939343238383036363536'),
|
||||
('webhook','webhook','1','89','89',X'023331333132363031353130363535353537373132'),
|
||||
('webhook','webhook','1','107','107',X'0231323937323734313733303636333133373339'),
|
||||
('webhook','webhook','1','125','125',X'0231353239313736313536333938363832313137'),
|
||||
('webhook','webhook','1','143','143',X'0231363837303238373334333232313437333434'),
|
||||
('member_cache','member_cache','4 1','73 74','48 74',X'034b3921496f4866536e67625a6762747061747a494e3a636164656e63652e6d6f65406875636b6c65746f6e3a636164656e63652e6d6f65'),
|
||||
('member_cache','member_cache','2 1','86 87','57 87',X'034b2d214b5169714663546e764f6f4f424475746a7a3a636164656e63652e6d6f6540726e6c3a636164656e63652e6d6f65'),
|
||||
('member_cache','member_cache','4 1','101 104','68 104',X'034b43214e446249714e704a795076664b526e4e63723a636164656e63652e6d6f6540776f756e6465645f77617272696f723a6d61747269782e6f7267'),
|
||||
('member_cache','member_cache','4 1','110 113','73 113',X'034b3b214f485844457370624d485348716c4445614f3a636164656e63652e6d6f6540717561647261646963616c3a6d61747269782e6f7267'),
|
||||
('member_cache','member_cache','5 1','171 175','111 175',X'034b3b215450616f6a5454444446444847776c7276743a636164656e63652e6d6f6540766962656973766572796f3a6d61747269782e6f7267'),
|
||||
('member_cache','member_cache','39 1','180 208','116 208',X'034b4d2154716c79516d69667847556767456d64424e3a636164656e63652e6d6f6540726f626c6b796f6772653a6372616674696e67636f6d72616465732e6e6574'),
|
||||
('member_cache','member_cache','4 1','231 231','126 231',X'034b3b2156624f77675559777146614e4c5345644e413a636164656e63652e6d6f654061666c6f7765723a73796e646963617465642e676179'),
|
||||
('member_cache','member_cache','9 1','262 263','141 263',X'034b3b21594b46454e79716667696951686956496b533a636164656e63652e6d6f654062656e6d61633a636861742e62656e6d61632e78797a'),
|
||||
('member_cache','member_cache','3 1','283 283','149 283',X'034b35215a615a4d78456f52724d6d4e49554d79446c3a636164656e63652e6d6f6540636164656e63653a636164656e63652e6d6f65'),
|
||||
('member_cache','member_cache','88 1','307 351','166 351',X'034b3b2163427874565278446c5a765356684a58564b3a636164656e63652e6d6f65406a61736b617274683a736c656570696e672e746f776e'),
|
||||
('member_cache','member_cache','11 1','408 415','177 415',X'034b5121654856655270706e6c6f57587177704a6e553a636164656e63652e6d6f65406a61636b736f6e6368656e3636363a6a61636b736f6e6368656e3636362e636f6d'),
|
||||
('member_cache','member_cache','7 1','423 424','181 424',X'034b4b2165724f7079584e465a486a48724568784e583a636164656e63652e6d6f6540616d796973636f6f6c7a3a6d61747269782e6174697573616d792e636f6d'),
|
||||
('member_cache','member_cache','96 1','436 439','187 439',X'034b4b21676865544b5a7451666c444e7070684c49673a636164656e63652e6d6f6540616c65783a73706163652e67616d65727374617665726e2e6f6e6c696e65'),
|
||||
('member_cache','member_cache','96 1','436 527','187 527',X'034b3121676865544b5a7451666c444e7070684c49673a636164656e63652e6d6f654078796c6f626f6c3a616d6265722e74656c'),
|
||||
('member_cache','member_cache','10 1','546 555','197 555',X'0351312169537958674e7851634575586f587073536e3a707573737468656361742e6f726740797562697175653a6e6f70652e63686174'),
|
||||
('member_cache','member_cache','13 1','594 601','224 601',X'034b2b216c7570486a715444537a774f744d59476d493a636164656e63652e6d6f6540656c6c69753a68617368692e7265'),
|
||||
('member_cache','member_cache','2 1','614 615','229 615',X'034b2f216d584978494644676c4861734e53427371773a636164656e63652e6d6f654077696e673a666561746865722e6f6e6c'),
|
||||
('member_cache','member_cache','4 1','616 619','230 619',X'034b2f216d616767455367755a427147425a74536e723a636164656e63652e6d6f654077696e673a666561746865722e6f6e6c'),
|
||||
('member_cache','member_cache','4 1','659 660','259 660',X'034b332172454f73706e5971644f414c4149466e69563a636164656e63652e6d6f6540656c797369613a636164656e63652e6d6f65'),
|
||||
('member_cache','member_cache','4 1','699 701','284 701',X'034b3521766e717a56767678534a586c5a504f5276533a636164656e63652e6d6f6540636164656e63653a636164656e63652e6d6f65'),
|
||||
('member_cache','member_cache','1 1','703 703','285 703',X'034b3521767165714c474851616842464a56566779483a636164656e63652e6d6f654063696465723a6361746769726c2e636c6f7564'),
|
||||
('member_cache','member_cache','4 1','705 705','287 705',X'034b35217750454472596b77497a6f744e66706e57503a636164656e63652e6d6f6540636164656e63653a636164656e63652e6d6f65'),
|
||||
('member_cache','member_cache','35 1','709 709','288 709',X'034b2d2177574f667376757356486f4e4e567242585a3a636164656e63652e6d6f654061613a6361747669626572732e6d65'),
|
||||
('member_cache','member_cache','14 1','747 749','291 749',X'034b3721776c534544496a44676c486d42474b7254703a636164656e63652e6d6f654062616461746e616d65733a62616461742e646576'),
|
||||
('member_power','member_power','1 1','0 0','0 0',X'03350f40636164656e63653a636164656e63652e6d6f652a'),
|
||||
('file','file','1','2429','2429',X'03815f68747470733a2f2f63646e2e646973636f72646170702e636f6d2f6174746163686d656e74732f313039393033313838373530303033343038382f313333313336303134333238333036303833372f50584c5f32303235303132315f3230323934323137372e6a7067'),
|
||||
('file','file','1','4859','4859',X'03817568747470733a2f2f63646e2e646973636f72646170702e636f6d2f6174746163686d656e74732f313134353832313533383832323637323436362f313330323232393131303834373936373331352f53637265656e73686f745f32303234313130325f3034313332365f5265646469742e6a7067'),
|
||||
('file','file','1','7289','7289',X'03815968747470733a2f2f63646e2e646973636f72646170702e636f6d2f6174746163686d656e74732f313231393439383932363436363636323433302f313239373634363930353038353636313234362f494d475f32303234313032305f3135323230302e6a7067'),
|
||||
('file','file','1','9719','9719',X'03814168747470733a2f2f63646e2e646973636f72646170702e636f6d2f6174746163686d656e74732f3135393136353731343139343735393638302f313236383537333933363531343337313635392f494d475f353433362e6a7067'),
|
||||
('file','file','1','12149','12149',X'03813b68747470733a2f2f63646e2e646973636f72646170702e636f6d2f6174746163686d656e74732f3236363736373539303634313233383032372f313237323430333931313939383730313630392f696d6167652e706e67'),
|
||||
('file','file','1','14579','14579',X'03816b68747470733a2f2f63646e2e646973636f72646170702e636f6d2f6174746163686d656e74732f3539383730363933323736303434343936392f313237373532343532343330383632373437372f45585445524e414c5f454449545f323032345f4d5f64726166745f322e646f6378'),
|
||||
('file','file','1','17009','17009',X'03815768747470733a2f2f63646e2e646973636f72646170702e636f6d2f6174746163686d656e74732f3635353231363137333639363238363734362f313333333630333132383634313036303938342f323032352d30312d32375f31372e30312e31352e706e67'),
|
||||
('file','file','1','19439','19439',X'027f68747470733a2f2f63646e2e646973636f72646170702e636f6d2f656d6f6a69732f313230323936303730343936313931323836322e706e67'),
|
||||
('message_channel','message_channel','1','40764','40764',X'023331313630333434353733303030383232383735'),
|
||||
('message_channel','message_channel','1','81529','81529',X'023331313830323437393130343238393837343333'),
|
||||
('message_channel','message_channel','1','122294','122294',X'023331313938303533383732383337363131363230'),
|
||||
('message_channel','message_channel','1','163059','163059',X'023331323237373739373330333839303738303537'),
|
||||
('message_channel','message_channel','1','203824','203824',X'023331323437303438333039303031313538373137'),
|
||||
('message_channel','message_channel','1','244589','244589',X'023331323635363939353734333034303830303034'),
|
||||
('message_channel','message_channel','1','285354','285354',X'023331323835343637363238323434313732383234'),
|
||||
('message_channel','message_channel','1','326119','326119',X'023331333038373932333935313031333736353732'),
|
||||
('lottie','lottie','1','2','2',X'0231373439303534363630373639323138363331'),
|
||||
('lottie','lottie','1','5','5',X'0231373534313037353339323030363731373635'),
|
||||
('lottie','lottie','1','8','8',X'0231373936313430363338303933343433303932'),
|
||||
('lottie','lottie','1','11','11',X'0231373936313431373032363935343835353030'),
|
||||
('lottie','lottie','1','14','14',X'0231383136303837373932323931323832393434'),
|
||||
('lottie','lottie','1','17','17',X'0231383233393736313032393736323930383636'),
|
||||
('event_message','event_message','11 1','14788 14796','14356 14796',X'03336531313532303033373639343137303839303434246d44714a474b3530424a715170394273684e534d64365f3768494354776e70362d793130786d6669766563'),
|
||||
('event_message','event_message','11 1','33806 33815','32914 33815',X'033365313135373630333638373035373836363736322459526f7139484b376e55677a397668796f6e3053424a49497978497a5750734e4f6e39756f765644664d45'),
|
||||
('event_message','event_message','1 1','42546 42546','41335 42546',X'03336531313630363236383737353835363938393237245033436e4f6d6a35462d6939454e4e79586b70796f5679306b74324a654464764276326e746d33566a4355'),
|
||||
('event_message','event_message','2 1','85093 85093','81999 85093',X'0333653131383039393939363531323930343831303424496748666562784533746e623047412d534f7176594a354e4e55385735706c68523159676854636a554734'),
|
||||
('event_message','event_message','11 1','116157 116165','111525 116165',X'03336531313933363837333730363137333237363536245a32305734766c737079566e387a6e2d526a6f64694a51745f5a7851644f5f33744e415169453755356477'),
|
||||
('event_message','event_message','1 1','127640 127640','122328 127640',X'0333653132303132353637313733373633303332323624702d626f415672476a4b45327a7158664f3738387a5a597a376a42624648717431334d386464705467476f'),
|
||||
('event_message','event_message','16 1','140270 140281','134379 140281',X'03336531323039333735363534373138343830343235246c374a4f7543526b7756306d627a69356e5843496b4538416a6951374f67473455456c2d7053445a516649'),
|
||||
('event_message','event_message','11 1','162065 162071','154933 162071',X'0333653132323434383135393033313937373537373424674c77513179796e4b6d5859496b5a597a4a55627a66557a55552d714c4b5f524f454e4250325f6e44766b'),
|
||||
('event_message','event_message','1 1','170187 170187','162530 170187',X'0333653132323937373533363839343532373038333424656c6c76416a544a5847627936767249767470677a555572787231716f5a75536e50525f474b4e35455945'),
|
||||
('event_message','event_message','11 1','178736 178736','170762 178736',X'03336531323333353238303533323338333337353537242d39304668552d36455373594b6435484d7237666d6a414a5f6a576149616e356c4776384e655436564959'),
|
||||
('event_message','event_message','10 1','180317 180325','172228 180325',X'03336531323334363237303030393333383130323637246a4679416449665a4f54432d2d735971715472473735374c7a50504f34386439657963485477686d797751'),
|
||||
('event_message','event_message','1 1','212734 212734','203278 212734',X'03336531323438303131373930303633373637363734246557493950456f4d576b614b4d33416a7030782d6d455f6c4b4a6c495f6d6d44686f6379464b5170534f59'),
|
||||
('event_message','event_message','11 1','228100 228103','217856 228103',X'033365313235333734353736363733373132313336322447326a7a746e5977716a3676304a7a4168576e30725950596470667a5f496f76426771574a4876434c516b'),
|
||||
('event_message','event_message','11 1','240172 240181','229123 240181',X'033365313235393239383538323233303630313735382472635679454c5a76647453547a37366f3669434a4f614a316a756f683835535778494d546c5072517a676f'),
|
||||
('event_message','event_message','10 1','240259 240264','229132 240264',X'0333653132353933303030343035333535373235323024535849376a465f696e71424d714c4f564b347659746852644b31724747502d435a5f355a354f6b774e3751'),
|
||||
('event_message','event_message','2 1','255280 255281','243230 255281',X'03336531323636373837343338353137343234313738246e6e4e576f526a54495757723441463770625454746b7a73784c4d336b312d6d6645665031496b43586e59'),
|
||||
('event_message','event_message','1 1','297828 297828','283436 297828',X'0333653132383637303937353334363834323032313024544342465970356f39767a2d4f6b2d33654f4e433772426d354966615934476b48536e58445257474f4767'),
|
||||
('event_message','event_message','1 1','340375 340375','323489 340375',X'033365313330393336363936343530353934303030392431664536386d2d50546e786d5474345458584c35754847594139353779396c76582d50797150496d395f30'),
|
||||
('event_message','event_message','11 1','343791 343799','326730 343799',X'03336531333131353731333337393331363537323737246731767241315269725951592d3933304f6973587142372d6961686e34684e492d6462374952714176616b'),
|
||||
('event_message','event_message','10 1','363207 363216','344868 363216',X'0333653133323434393732323633393438393834393524784f78636e4749364269545941734d7a4f7557316f526e68356b675378544e30466442386a3037695f4f67'),
|
||||
('event_message','event_message','10 1','363219 363219','344871 363219',X'033365313332343439373532363733323039393732352432586f7938567937643843375a47767472657966326b4c4f4159397546386b4755364c3642435f446b6d77'),
|
||||
('event_message','event_message','10 1','363340 363343','344918 363343',X'03336531333234353037313732333634373530393830244f46645731396649534176706d34646c36367a2d5543337236432d436354506e34752d63444f7036733345'),
|
||||
('event_message','event_message','11 1','369452 369456','350712 369456',X'0333653133323736353831313733343439323336383024574d774330644574417277375f4562554a534465546532577174506d3747584347774570646c4f79326d30'),
|
||||
('event_message','event_message','10 1','372353 372356','353425 372356',X'0333653133323933313737353039313234353035373224366259364d313472667163486d5854476349716d4a4366467471796839794472375a6a487463715a6d4f34'),
|
||||
('sim_member','sim_member','225 1','0 12','0 12',X'034b4721414956694e775a64636b4652764c4f4567433a636164656e63652e6d6f65405f6f6f79655f616b6972615f6e6965723a636164656e63652e6d6f65'),
|
||||
('sim_member','sim_member','2 1','319 319','14 319',X'034b43214456706f6e54524d56456570486378744c423a636164656e63652e6d6f65405f6f6f79655f656e746f6c6f6d613a636164656e63652e6d6f65'),
|
||||
('sim_member','sim_member','68 1','391 440','26 440',X'034b4921457a54624a496c496d45534f746b4e644e4a3a636164656e63652e6d6f65405f6f6f79655f73617475726461797465643a636164656e63652e6d6f65'),
|
||||
('sim_member','sim_member','8 1','638 639','59 639',X'034b3f21497a4f675169446e757346516977796d614c3a636164656e63652e6d6f65405f6f6f79655f636f6f6b69653a636164656e63652e6d6f65'),
|
||||
('sim_member','sim_member','31 1','743 771','86 771',X'034b49214d5071594e414a62576b72474f544a7461703a636164656e63652e6d6f65405f6f6f79655f746865666f6f6c323239343a636164656e63652e6d6f65'),
|
||||
('sim_member','sim_member','26 1','774 787','87 787',X'034b49214d687950614b4250506f496c7365794d6d743a636164656e63652e6d6f65405f6f6f79655f6b79757567727970686f6e3a636164656e63652e6d6f65'),
|
||||
('sim_member','sim_member','27 1','877 881','104 881',X'034b45215063734371724f466a48476f41424270414c3a636164656e63652e6d6f65405f6f6f79655f62696c6c795f626f623a636164656e63652e6d6f65'),
|
||||
('sim_member','sim_member','16 1','956 959','117 959',X'034b43215158526f4a777a63506d5047546d454b454d3a636164656e63652e6d6f65405f6f6f79655f626f7472616334723a636164656e63652e6d6f65'),
|
||||
('sim_member','sim_member','32 1','997 1012','121 1012',X'034b3f215170676c734e587a4c7751594d4c6c734f503a636164656e63652e6d6f65405f6f6f79655f6a75746f6d693a636164656e63652e6d6f65'),
|
||||
('sim_member','sim_member','7 1','1274 1279','157 1279',X'034b4121554d6f6e68556765644d47585a78466658753a636164656e63652e6d6f65405f6f6f79655f6d696e696d75733a636164656e63652e6d6f65'),
|
||||
('sim_member','sim_member','27 1','1415 1439','188 1439',X'034b3d21595868717249786d586e47736961796a59783a636164656e63652e6d6f65405f6f6f79655f73746161663a636164656e63652e6d6f65'),
|
||||
('sim_member','sim_member','16 1','1597 1599','217 1599',X'034b512163466a4479477274466d48796d794c6652453a636164656e63652e6d6f65405f6f6f79655f626f6a61636b5f686f7273656d616e3a636164656e63652e6d6f65'),
|
||||
('sim_member','sim_member','27 1','1758 1761','248 1761',X'034b3d2168665a74624d656f5355564e424850736a743a636164656e63652e6d6f65405f6f6f79655f617a7572653a636164656e63652e6d6f65'),
|
||||
('sim_member','sim_member','25 1','1865 1886','270 1886',X'034b3f216b4c52714b4b555158636962494d744f706c3a636164656e63652e6d6f65405f6f6f79655f7361796f72693a636164656e63652e6d6f65'),
|
||||
('sim_member','sim_member','19 1','1918 1919','276 1919',X'034b3d216b68497350756c465369736d43646c596e493a636164656e63652e6d6f65405f6f6f79655f617a7572653a636164656e63652e6d6f65'),
|
||||
('sim_member','sim_member','33 1','1986 2015','286 2015',X'034b3d216d5451744d736a534c4f646c576f7265594d3a636164656e63652e6d6f65405f6f6f79655f73746161663a636164656e63652e6d6f65'),
|
||||
('sim_member','sim_member','37 1','2027 2028','289 2028',X'034b4d216d616767455367755a427147425a74536e723a636164656e63652e6d6f65405f6f6f79655f2e7265616c2e706572736f6e2e3a636164656e63652e6d6f65'),
|
||||
('sim_member','sim_member','28 1','2117 2130','297 2130',X'034b3f216e4e595a794b6f4e70797859417a50466f733a636164656e63652e6d6f65405f6f6f79655f6a75746f6d693a636164656e63652e6d6f65'),
|
||||
('sim_member','sim_member','20 1','2230 2239','310 2239',X'034b41217046504c7270594879487a784e4c69594b413a636164656e63652e6d6f65405f6f6f79655f686578676f61743a636164656e63652e6d6f65'),
|
||||
('sim_member','sim_member','30 1','2381 2398','332 2398',X'034b3b21717a44626c4b6c69444c577a52524f6e465a3a636164656e63652e6d6f65405f6f6f79655f6d6e696b3a636164656e63652e6d6f65'),
|
||||
('sim_member','sim_member','38 1','2490 2518','344 2518',X'034b3d2173445250714549546e4f4e57474176496b423a636164656e63652e6d6f65405f6f6f79655f727974686d3a636164656e63652e6d6f65'),
|
||||
('sim_member','sim_member','11 1','2555 2559','358 2559',X'034b4321746751436d526b426e6474516362687150583a636164656e63652e6d6f65405f6f6f79655f6a6f7365707065793a636164656e63652e6d6f65'),
|
||||
('sim_member','sim_member','47 1','2633 2666','377 2666',X'034b4b217750454472596b77497a6f744e66706e57503a636164656e63652e6d6f65405f6f6f79655f6e61706f6c656f6e333038393a636164656e63652e6d6f65'),
|
||||
('sim_member','sim_member','52 1','2817 2837','414 2837',X'034b43217a66654e574d744b4f764f48766f727979563a636164656e63652e6d6f65405f6f6f79655f696e736f676e69613a636164656e63652e6d6f65'),
|
||||
('guild_space','guild_space','1','3','3',X'0231313132373630363639313738323431303234'),
|
||||
('guild_space','guild_space','1','7','7',X'023331313534383638343234373234343633363837'),
|
||||
('guild_space','guild_space','1','11','11',X'023331323139303338323637383430393235383138'),
|
||||
('guild_space','guild_space','1','15','15',X'023331323839353939363232353930383930313335'),
|
||||
('guild_space','guild_space','1','19','19',X'0231323733383737363437323234393935383431'),
|
||||
('guild_space','guild_space','1','23','23',X'0231353239313736313536333938363832313135'),
|
||||
('guild_space','guild_space','1','27','27',X'0231373535303134333534373334313533383138'),
|
||||
('guild_space','guild_space','1','31','31',X'0231393933383838313432343535323130303834'),
|
||||
('guild_active','guild_active','1','3','3',X'0231313132373630363639313738323431303234'),
|
||||
('guild_active','guild_active','1','7','7',X'023331313534383638343234373234343633363837'),
|
||||
('guild_active','guild_active','1','11','11',X'023331323139303338323637383430393235383138'),
|
||||
('guild_active','guild_active','1','15','15',X'023331323839353939363232353930383930313335'),
|
||||
('guild_active','guild_active','1','19','19',X'023331333333323139363936393333323038303934'),
|
||||
('guild_active','guild_active','1','23','23',X'0231343735353939303338353336373434393630'),
|
||||
('guild_active','guild_active','1','27','27',X'022f3636313932393535373737343836383438'),
|
||||
('guild_active','guild_active','1','31','31',X'0231383737303635303431393930353136373637'),
|
||||
('emoji','emoji','1','284','284',X'023331313132323031303430303637303339333332'),
|
||||
('emoji','emoji','1','569','569',X'023331323334393032303131393436383630363638'),
|
||||
('emoji','emoji','1','854','854',X'0231323735313734373438353034313935303732'),
|
||||
('emoji','emoji','1','1139','1139',X'0231333837343730383630303134393737303235'),
|
||||
('emoji','emoji','1','1424','1424',X'0231353435363639393734303130383838313932'),
|
||||
('emoji','emoji','1','1709','1709',X'0231363339313034333030393139393437323634'),
|
||||
('emoji','emoji','1','1994','1994',X'0231373532363932363230303638373832313231'),
|
||||
('emoji','emoji','1','2279','2279',X'0231383935343737353331303434363138323430'),
|
||||
('auto_emoji','auto_emoji','1','0','0',X'02114c31'),
|
||||
('auto_emoji','auto_emoji','1','1','1',X'02114c32'),
|
||||
('auto_emoji','auto_emoji','1','2','2',X'020f5f');
|
||||
|
||||
ANALYZE sqlite_schema;
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
BEGIN TRANSACTION;
|
||||
|
||||
ALTER TABLE guild_space ADD COLUMN webhook_profile INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
PRAGMA foreign_keys=OFF;
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
-- *** historical_channel_room ***
|
||||
|
||||
CREATE TABLE "historical_channel_room" (
|
||||
"historical_room_index" INTEGER NOT NULL,
|
||||
"reference_channel_id" TEXT NOT NULL,
|
||||
"room_id" TEXT NOT NULL UNIQUE,
|
||||
"upgraded_timestamp" INTEGER NOT NULL,
|
||||
PRIMARY KEY("historical_room_index" AUTOINCREMENT),
|
||||
FOREIGN KEY("reference_channel_id") REFERENCES "channel_room"("channel_id") ON DELETE CASCADE
|
||||
);
|
||||
|
||||
INSERT INTO historical_channel_room (reference_channel_id, room_id, upgraded_timestamp) SELECT channel_id, room_id, 0 FROM channel_room;
|
||||
|
||||
-- *** message_channel -> message_room ***
|
||||
|
||||
CREATE TABLE "message_room" (
|
||||
"message_id" TEXT NOT NULL,
|
||||
"historical_room_index" INTEGER NOT NULL,
|
||||
PRIMARY KEY("message_id"),
|
||||
FOREIGN KEY("historical_room_index") REFERENCES "historical_channel_room"("historical_room_index") ON DELETE CASCADE
|
||||
) WITHOUT ROWID;
|
||||
INSERT INTO message_room (message_id, historical_room_index) SELECT message_id, max(historical_room_index) as historical_room_index FROM message_channel INNER JOIN historical_channel_room ON historical_channel_room.reference_channel_id = message_channel.channel_id GROUP BY message_id;
|
||||
|
||||
-- *** event_message ***
|
||||
|
||||
CREATE TABLE "new_event_message" (
|
||||
"event_id" TEXT NOT NULL,
|
||||
"event_type" TEXT,
|
||||
"event_subtype" TEXT,
|
||||
"message_id" TEXT NOT NULL,
|
||||
"part" INTEGER NOT NULL,
|
||||
"reaction_part" INTEGER NOT NULL,
|
||||
"source" INTEGER NOT NULL,
|
||||
PRIMARY KEY("message_id","event_id"),
|
||||
FOREIGN KEY("message_id") REFERENCES "message_room"("message_id") ON DELETE CASCADE
|
||||
) WITHOUT ROWID;
|
||||
INSERT INTO new_event_message (event_id, event_type, event_subtype, message_id, part, reaction_part, source) SELECT event_id, event_type, event_subtype, message_id, part, reaction_part, source from event_message;
|
||||
DROP TABLE event_message;
|
||||
ALTER TABLE new_event_message RENAME TO event_message;
|
||||
|
||||
-- *** reaction ***
|
||||
|
||||
CREATE TABLE "new_reaction" (
|
||||
"hashed_event_id" INTEGER NOT NULL,
|
||||
"message_id" TEXT NOT NULL,
|
||||
"encoded_emoji" TEXT NOT NULL, original_encoding TEXT,
|
||||
PRIMARY KEY("hashed_event_id"),
|
||||
FOREIGN KEY("message_id") REFERENCES "message_room"("message_id") ON DELETE CASCADE
|
||||
) WITHOUT ROWID;
|
||||
INSERT INTO new_reaction (hashed_event_id, message_id, encoded_emoji) SELECT hashed_event_id, message_id, encoded_emoji FROM reaction;
|
||||
DROP TABLE reaction;
|
||||
ALTER TABLE new_reaction RENAME TO reaction;
|
||||
|
||||
-- ***
|
||||
|
||||
DROP TABLE message_channel;
|
||||
PRAGMA foreign_key_check;
|
||||
|
||||
COMMIT;
|
||||
PRAGMA foreign_keys=ON;
|
||||
|
|
@ -1,250 +0,0 @@
|
|||
-- https://www.sqlite.org/lang_analyze.html
|
||||
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
ANALYZE sqlite_schema;
|
||||
|
||||
DELETE FROM "sqlite_stat1";
|
||||
INSERT INTO "sqlite_stat1" ("tbl","idx","stat") VALUES ('reaction','reaction','4741 1'),
|
||||
('event_message','event_message','537386 1 1'),
|
||||
('message_room','message_room','510262 1'),
|
||||
('historical_channel_room','sqlite_autoindex_historical_channel_room_1','991 1'),
|
||||
('auto_emoji','auto_emoji','2 1'),
|
||||
('sim','sim','1075 1'),
|
||||
('webhook','webhook','205 1'),
|
||||
('channel_room','channel_room','992 1'),
|
||||
('channel_room','sqlite_autoindex_channel_room_1','992 1'),
|
||||
('guild_active','guild_active','45 1'),
|
||||
('media_proxy','media_proxy','19794 1'),
|
||||
('sim_member','sim_member','5504 6 1'),
|
||||
('emoji','emoji','3472 1'),
|
||||
('guild_space','guild_space','43 1'),
|
||||
('member_power','member_power','1 1 1'),
|
||||
('sim_proxy','sim_proxy','213 1'),
|
||||
('migration',NULL,'1'),
|
||||
('member_cache','member_cache','1117 3 1'),
|
||||
('file','file','36489 1'),
|
||||
('lottie','lottie','22 1');
|
||||
|
||||
DELETE FROM "sqlite_stat4";
|
||||
INSERT INTO "sqlite_stat4" ("tbl","idx","neq","nlt","ndlt","sample") VALUES ('reaction','reaction','1','526','526',X'02069c21bd28f26ae025'),
|
||||
('reaction','reaction','1','1053','1053',X'0206b8866f4c30c2e1aa'),
|
||||
('reaction','reaction','1','1580','1580',X'0206d43fceca129b040e'),
|
||||
('reaction','reaction','1','2107','2107',X'0206f121f9a4fe54b557'),
|
||||
('reaction','reaction','1','2634','2634',X'020610299199abbd0e9c'),
|
||||
('reaction','reaction','1','3161','3161',X'02062be99961e7716037'),
|
||||
('reaction','reaction','1','3688','3688',X'020647b48fa5ee5a415c'),
|
||||
('reaction','reaction','1','4215','4215',X'020664fdc2d88c77dda3'),
|
||||
('event_message','event_message','11 1','14790 14792','14356 14792',X'03336531313532303033373639343137303839303434244a616d4c6d732d4b77454c6b47766866344d524f385576535536336a574a5a4c4474524c4c57664f775873'),
|
||||
('event_message','event_message','11 1','33809 33816','32914 33816',X'0333653131353736303336383730353738363637363224544b7141734f58566c6e67506f546f4a427565514e664444756d494a6d38384f486a76766f7949496e7130'),
|
||||
('event_message','event_message','1 1','59709 59709','57896 59709',X'033365313136363930353332323132303637353336392442794756564f6767326a416845624267463941755056486178377a34314459514e4459316e34435a4a4455'),
|
||||
('event_message','event_message','11 1','116172 116182','111525 116182',X'0333653131393336383733373036313733323736353624786a385f70696e784f624f4349666c70556832305542345973664a547642694b4164675f473168562d5334'),
|
||||
('event_message','event_message','1 1','119419 119419','114559 119419',X'03336531313935323132333038393839383730313732244f556670664d5054576c364774734943484d725459556d6464656c636232663374494a662d425769554355'),
|
||||
('event_message','event_message','16 1','140286 140287','134379 140287',X'0333653132303933373536353437313834383034323524346b61796e4d68422d336d6967417571347255745f726639353454636b6f657636664c5f3675394f455030'),
|
||||
('event_message','event_message','11 1','162080 162086','154932 162086',X'0333653132323434383135393033313937373537373424674c77513179796e4b6d5859496b5a597a4a55627a66557a55552d714c4b5f524f454e4250325f6e44766b'),
|
||||
('event_message','event_message','11 1','178659 178659','170672 178659',X'03336531323333353238303533323338333337353537242d39304668552d36455373594b6435484d7237666d6a414a5f6a576149616e356c4776384e655436564959'),
|
||||
('event_message','event_message','1 1','179129 179129','171083 179129',X'03336531323333393533373032373637383836343636245a446e5f42385a6b41674c645939495649767445516e47373369706a555a55447943634768697851673859'),
|
||||
('event_message','event_message','10 1','180049 180052','171954 180052',X'03336531323334363237303030393333383130323637245171504b7357795254734a49695449744646716a686e506d48764a6e5932584a6c595a506b424e372d766f'),
|
||||
('event_message','event_message','11 1','215266 215271','205302 215271',X'03336531323533373435373636373337313231333632244b4936672d57724f5a5757533463534c4c4f353950555176425066754b5f5446504b443233583130504759'),
|
||||
('event_message','event_message','11 1','224498 224499','213831 224499',X'0333653132353932393835383232333036303137353824356a573361764d37626d643661756c7367635650506f5257417552476e30503477324939786b5675326f6b'),
|
||||
('event_message','event_message','10 1','224519 224523','213833 224523',X'033365313235393239383739353234323635353839352452696c715a6862347a32526b594c596958504375445975546f6b6430544e365a784638737842745670346b'),
|
||||
('event_message','event_message','10 1','224615 224616','213843 224616',X'0333653132353933303036363636373831383139323024425a69396d4c73323034344c674a6e56673761557a614467484b4b5545787334587a467954474245585573'),
|
||||
('event_message','event_message','1 1','238839 238839','227061 238839',X'0333653132363839343934383836303535393336343224374d3633546d416c526947553847795f416164576f4d4f4e4a334b363441326235385f6e72385961652d51'),
|
||||
('event_message','event_message','1 1','298549 298549','283096 298549',X'03336531333037303536353132313931303337343532245830424a3954514e544d3041687554736c7258744b5836383376723749524355747a4b47524a4374493555'),
|
||||
('event_message','event_message','11 1','304605 304605','288785 304605',X'03336531333131353731333337393331363537323737242d674e75657465765a426169587949335859717437325743695438396549573269514761416266384f6455'),
|
||||
('event_message','event_message','11 1','327028 327037','309699 327037',X'0333653133323736353831313733343439323336383024715055786a61394c36694e756548683046335962304b524b67665730414356394769367a4147464b714973'),
|
||||
('event_message','event_message','10 1','329549 329550','312055 329550',X'033365313332393331373735303931323435303537322430325a4779526f33656133786e5356706b52487047325459415464373971684834536632506f4e7a614773'),
|
||||
('event_message','event_message','1 1','358259 358259','339179 358259',X'03336531333436303136333531313138313634303539243364757343667558596a506f715a3642774851755a48496e5163504f4e70766c64387476654a4d45685a38'),
|
||||
('event_message','event_message','1 1','417969 417969','395237 417969',X'0333653133363831333832343230383333393336363724537a7775656948304b696130376d67304e51322d58627751352d6a7653507649464e645053396464416655'),
|
||||
('event_message','event_message','11 1','422263 422270','399248 422270',X'033365313336393833343930353236313034373831382456754f5872464d593547734350377467425f6a763348486f426264666b3859464c4b4f6e48583732497677'),
|
||||
('event_message','event_message','10 1','424260 424266','401135 424266',X'03336531333730353132353138353938303939303637246c7268447950715458362d45497a3637552d616a75453839614655394c4151556f5a356d7363725072466f'),
|
||||
('event_message','event_message','1 1','477679 477679','451062 477679',X'0333653134303434353430323035313234383133333324524f454b6b5f726b3373344b7451337a75344552774c4b5069484964757676575f514d4b4e66306c385630'),
|
||||
('message_room','message_room','1','56695','56695',X'023331313636313031373337333834343630333739'),
|
||||
('message_room','message_room','1','113391','113391',X'023331313932353935303036363435363132353434'),
|
||||
('message_room','message_room','1','170087','170087',X'023331323331393439393133373937393535363335'),
|
||||
('message_room','message_room','1','226783','226783',X'023331323636313430343634333733383239373532'),
|
||||
('message_room','message_room','1','283479','283479',X'023331333034303933383132373833373130323539'),
|
||||
('message_room','message_room','1','340175','340175',X'023331333434383431363637333537393730343332'),
|
||||
('message_room','message_room','1','396871','396871',X'023331333637353035313132313333363638393134'),
|
||||
('message_room','message_room','1','453567','453567',X'023331343032363934353234333439373134343833'),
|
||||
('historical_channel_room','sqlite_autoindex_historical_channel_room_1','1 1','0 0','0 0',X'034b0221414355774c616c64303030303030303030303a636164656e63652e6d6f650288'),
|
||||
('historical_channel_room','sqlite_autoindex_historical_channel_room_1','1 1','24 24','24 24',X'034b02214255635a694c7a57303030303030303030303a636164656e63652e6d6f6501ab'),
|
||||
('historical_channel_room','sqlite_autoindex_historical_channel_room_1','1 1','110 110','110 110',X'034b022147486e4d47697875303030303030303030303a636164656e63652e6d6f6500c6'),
|
||||
('historical_channel_room','sqlite_autoindex_historical_channel_room_1','1 1','193 193','193 193',X'034b02214b4b535575717666303030303030303030303a636164656e63652e6d6f650350'),
|
||||
('historical_channel_room','sqlite_autoindex_historical_channel_room_1','1 1','221 221','221 221',X'034b02214c51715351594b73303030303030303030303a636164656e63652e6d6f6503af'),
|
||||
('historical_channel_room','sqlite_autoindex_historical_channel_room_1','1 1','319 319','319 319',X'034b02215170676c734e587a303030303030303030303a636164656e63652e6d6f650366'),
|
||||
('historical_channel_room','sqlite_autoindex_historical_channel_room_1','1 1','332 332','332 332',X'034b0221525a585a7064554f303030303030303030303a636164656e63652e6d6f65009f'),
|
||||
('historical_channel_room','sqlite_autoindex_historical_channel_room_1','1 1','351 351','351 351',X'034b0221534b6f6c6f636b77303030303030303030303a636164656e63652e6d6f65035f'),
|
||||
('historical_channel_room','sqlite_autoindex_historical_channel_room_1','1 1','443 443','443 443',X'034b0221576374435a494d73303030303030303030303a636164656e63652e6d6f650084'),
|
||||
('historical_channel_room','sqlite_autoindex_historical_channel_room_1','1 1','551 551','551 551',X'034b0221637779454c6c6b55303030303030303030303a636164656e63652e6d6f6501b0'),
|
||||
('historical_channel_room','sqlite_autoindex_historical_channel_room_1','1 1','554 554','554 554',X'034b0221644965496d615167303030303030303030303a636164656e63652e6d6f6503a0'),
|
||||
('historical_channel_room','sqlite_autoindex_historical_channel_room_1','1 1','560 560','560 560',X'034b0221645568456f756a71303030303030303030303a636164656e63652e6d6f650090'),
|
||||
('historical_channel_room','sqlite_autoindex_historical_channel_room_1','1 1','573 573','573 573',X'034b02216552517465644b67303030303030303030303a636164656e63652e6d6f650099'),
|
||||
('historical_channel_room','sqlite_autoindex_historical_channel_room_1','1 1','593 593','593 593',X'034b0221666764594e526d4e303030303030303030303a636164656e63652e6d6f65016b'),
|
||||
('historical_channel_room','sqlite_autoindex_historical_channel_room_1','1 1','624 624','624 624',X'034b0221687078416c4c6f71303030303030303030303a636164656e63652e6d6f650297'),
|
||||
('historical_channel_room','sqlite_autoindex_historical_channel_room_1','1 1','625 625','625 625',X'034b02216873414570464e47303030303030303030303a636164656e63652e6d6f6500be'),
|
||||
('historical_channel_room','sqlite_autoindex_historical_channel_room_1','1 1','665 665','665 665',X'034b01216a71484b51424476303030303030303030303a636164656e63652e6d6f653b'),
|
||||
('historical_channel_room','sqlite_autoindex_historical_channel_room_1','1 1','758 758','758 758',X'034b02216f6251554d424b75303030303030303030303a636164656e63652e6d6f6500f7'),
|
||||
('historical_channel_room','sqlite_autoindex_historical_channel_room_1','1 1','776 776','776 776',X'034b022170566e596b5a4f46303030303030303030303a636164656e63652e6d6f650232'),
|
||||
('historical_channel_room','sqlite_autoindex_historical_channel_room_1','1 1','781 781','781 781',X'034b01217065766e6542516e303030303030303030303a636164656e63652e6d6f6518'),
|
||||
('historical_channel_room','sqlite_autoindex_historical_channel_room_1','1 1','857 857','857 857',X'034b02217446564c65724b78303030303030303030303a636164656e63652e6d6f65024e'),
|
||||
('historical_channel_room','sqlite_autoindex_historical_channel_room_1','1 1','866 866','866 866',X'034b0221745a61474145557a303030303030303030303a636164656e63652e6d6f6501f8'),
|
||||
('historical_channel_room','sqlite_autoindex_historical_channel_room_1','1 1','887 887','887 887',X'034b022175727a464b754d61303030303030303030303a636164656e63652e6d6f65033b'),
|
||||
('historical_channel_room','sqlite_autoindex_historical_channel_room_1','1 1','921 921','921 921',X'034b022177574a5548445a74303030303030303030303a636164656e63652e6d6f65025c'),
|
||||
('auto_emoji','auto_emoji','1','0','0',X'02114c31'),
|
||||
('auto_emoji','auto_emoji','1','1','1',X'02114c32'),
|
||||
('sim','sim','1','119','119',X'025531316564343731342d636635652d346333372d393331382d376136353266383732636634'),
|
||||
('sim','sim','1','239','239',X'0231313439363932303632313634333230323536'),
|
||||
('sim','sim','1','359','359',X'025532323533323035312d633335332d346638662d383835362d653137383831323435303763'),
|
||||
('sim','sim','1','479','479',X'0231333036373839323436333237353836383136'),
|
||||
('sim','sim','1','599','599',X'0231343132383438323830343635313738363235'),
|
||||
('sim','sim','1','719','719',X'0231353638323430303837363238373735343234'),
|
||||
('sim','sim','1','839','839',X'0231373234383037393132373233313835373534'),
|
||||
('sim','sim','1','959','959',X'0231393431303333313033353936353835303630'),
|
||||
('webhook','webhook','1','22','22',X'023331313630383933333337303239353836393536'),
|
||||
('webhook','webhook','1','45','45',X'023331323139343938393236343636363632343330'),
|
||||
('webhook','webhook','1','68','68',X'023331323432383939363632343734373131303630'),
|
||||
('webhook','webhook','1','91','91',X'023331323937323836383730393534323833313533'),
|
||||
('webhook','webhook','1','114','114',X'023331333430353438363133363931393332373133'),
|
||||
('webhook','webhook','1','137','137',X'023331343034313334383236303530383436393331'),
|
||||
('webhook','webhook','1','160','160',X'0231333639373535303430343638303431373238'),
|
||||
('webhook','webhook','1','183','183',X'0231363035353930343336333230333738383930'),
|
||||
('channel_room','channel_room','1','110','110',X'023331313939353030313137393834363733393133'),
|
||||
('channel_room','channel_room','1','221','221',X'023331323734313935333432323131393430353434'),
|
||||
('channel_room','channel_room','1','332','332',X'023331333437303036333637393639343433383430'),
|
||||
('channel_room','channel_room','1','443','443',X'023331343035323432323838343138303632333636'),
|
||||
('channel_room','channel_room','1','554','554',X'023331343036373736363630393936333935323830'),
|
||||
('channel_room','channel_room','1','665','665',X'023331343039363536363537383835323635393830'),
|
||||
('channel_room','channel_room','1','776','776',X'023331343139353132333134363234383638343632'),
|
||||
('channel_room','channel_room','1','887','887',X'0231333734383732393736313738343133353639'),
|
||||
('channel_room','sqlite_autoindex_channel_room_1','1 1','23 23','23 23',X'034b3121425167434a4d4c78303030303030303030303a636164656e63652e6d6f65393631373335333036303032393732373432'),
|
||||
('channel_room','sqlite_autoindex_channel_room_1','1 1','96 96','96 96',X'034b332146514f654f667747303030303030303030303a636164656e63652e6d6f6531323137393638383531303939313839323738'),
|
||||
('channel_room','sqlite_autoindex_channel_room_1','1 1','110 110','110 110',X'034b332147486e4d47697875303030303030303030303a636164656e63652e6d6f6531323432323436333730303938363739383838'),
|
||||
('channel_room','sqlite_autoindex_channel_room_1','1 1','138 138','138 138',X'034b3321484a79705a6b6863303030303030303030303a636164656e63652e6d6f6531303237323933303239313633333335373130'),
|
||||
('channel_room','sqlite_autoindex_channel_room_1','1 1','161 161','161 161',X'034b33214962646466626172303030303030303030303a636164656e63652e6d6f6531323937373538303931373331303039353536'),
|
||||
('channel_room','sqlite_autoindex_channel_room_1','1 1','221 221','221 221',X'034b31214c51715351594b73303030303030303030303a636164656e63652e6d6f65373039303431393733353332363838343235'),
|
||||
('channel_room','sqlite_autoindex_channel_room_1','1 1','240 240','240 240',X'034b33214d5071594e414a62303030303030303030303a636164656e63652e6d6f6531323139303338323638323835323539393037'),
|
||||
('channel_room','sqlite_autoindex_channel_room_1','1 1','250 250','250 250',X'034b33214e414f484c4e444c303030303030303030303a636164656e63652e6d6f6531343037323332343832313338333934363634'),
|
||||
('channel_room','sqlite_autoindex_channel_room_1','1 1','325 325','325 325',X'034b33215178576669464359303030303030303030303a636164656e63652e6d6f6531343034353739343736363837323934363434'),
|
||||
('channel_room','sqlite_autoindex_channel_room_1','1 1','332 332','332 332',X'034b33215254735654767542303030303030303030303a636164656e63652e6d6f6531343037323235393932313935333432343237'),
|
||||
('channel_room','sqlite_autoindex_channel_room_1','1 1','430 430','430 430',X'034b33215673656a6b6b5a71303030303030303030303a636164656e63652e6d6f6531323235323636343030363838333431303833'),
|
||||
('channel_room','sqlite_autoindex_channel_room_1','1 1','443 443','443 443',X'034b3321576241744a736c6b303030303030303030303a636164656e63652e6d6f6531343230323635333433363931313332393339'),
|
||||
('channel_room','sqlite_autoindex_channel_room_1','1 1','552 552','552 552',X'034b3321637779454c6c6b55303030303030303030303a636164656e63652e6d6f6531343034393538363332363830303939393931'),
|
||||
('channel_room','sqlite_autoindex_channel_room_1','1 1','554 554','554 554',X'034b332164484e5378484a47303030303030303030303a636164656e63652e6d6f6531343035363439333331343335393939323932'),
|
||||
('channel_room','sqlite_autoindex_channel_room_1','1 1','565 565','565 565',X'034b3321646c584f50766944303030303030303030303a636164656e63652e6d6f6531323735353037363433323231343039393033'),
|
||||
('channel_room','sqlite_autoindex_channel_room_1','1 1','579 579','579 579',X'034b332165656c6c7a6a5370303030303030303030303a636164656e63652e6d6f6531343036373736363630393936333935323830'),
|
||||
('channel_room','sqlite_autoindex_channel_room_1','1 1','619 619','619 619',X'034b332168525179596e6d4e303030303030303030303a636164656e63652e6d6f6531343237323832333338303035353136343233'),
|
||||
('channel_room','sqlite_autoindex_channel_room_1','1 1','664 664','664 664',X'034b33216a6c566e54585747303030303030303030303a636164656e63652e6d6f6531323139353034373636333137373536343238'),
|
||||
('channel_room','sqlite_autoindex_channel_room_1','1 1','665 665','665 665',X'034b33216a6c6c4479666d76303030303030303030303a636164656e63652e6d6f6531303835303935353736383731333837313936'),
|
||||
('channel_room','sqlite_autoindex_channel_room_1','1 1','776 776','776 776',X'034b332170555653686b7978303030303030303030303a636164656e63652e6d6f6531323139343939353736363137303738383735'),
|
||||
('channel_room','sqlite_autoindex_channel_room_1','1 1','813 813','813 813',X'034b33217179416246555961303030303030303030303a636164656e63652e6d6f6531343035393130313436363731393732333833'),
|
||||
('channel_room','sqlite_autoindex_channel_room_1','1 1','887 887','887 887',X'034b33217571697357484575303030303030303030303a636164656e63652e6d6f6531323331383036353337373032353736313938'),
|
||||
('channel_room','sqlite_autoindex_channel_room_1','1 1','924 924','924 924',X'034b332177585242634d4851303030303030303030303a636164656e63652e6d6f6531343233373338343430363833363232353332'),
|
||||
('channel_room','sqlite_autoindex_channel_room_1','1 1','953 953','953 953',X'034b33217954757a6749556f303030303030303030303a636164656e63652e6d6f6531333338353537373531373232323530333130'),
|
||||
('guild_active','guild_active','1','5','5',X'023331313433333336323438373631363437313534'),
|
||||
('guild_active','guild_active','1','11','11',X'023331313630383933333336333234393331353834'),
|
||||
('guild_active','guild_active','1','17','17',X'023331323839353936343835343631323137333430'),
|
||||
('guild_active','guild_active','1','23','23',X'023331333338363530383035363233393834333030'),
|
||||
('guild_active','guild_active','1','29','29',X'023331343338363132393630393137353836313233'),
|
||||
('guild_active','guild_active','1','35','35',X'0231343937313539373236343535343535373534'),
|
||||
('guild_active','guild_active','1','41','41',X'0231383730313138363530373638363730373530'),
|
||||
('media_proxy','media_proxy','1','2199','2199',X'02069cb7709d83b92e22'),
|
||||
('media_proxy','media_proxy','1','4399','4399',X'0206b953cc685f0b68d2'),
|
||||
('media_proxy','media_proxy','1','6599','6599',X'0206d546a2d00310b6cc'),
|
||||
('media_proxy','media_proxy','1','8799','8799',X'0206f0d029ff71e1dae5'),
|
||||
('media_proxy','media_proxy','1','10999','10999',X'02060e4626697605710f'),
|
||||
('media_proxy','media_proxy','1','13199','13199',X'02062adc53c43825bc39'),
|
||||
('media_proxy','media_proxy','1','15399','15399',X'02064704c4b0f76fa5ff'),
|
||||
('media_proxy','media_proxy','1','17599','17599',X'02066338ce2423770613'),
|
||||
('sim_member','sim_member','225 1','14 80','4 80',X'034b4721414956694e775a64303030303030303030303a636164656e63652e6d6f65405f6f6f79655f66726f73745f313139323a636164656e63652e6d6f65'),
|
||||
('sim_member','sim_member','32 1','483 488','68 488',X'034b3b21455450534d664d69303030303030303030303a636164656e63652e6d6f65405f6f6f79655f653372613a636164656e63652e6d6f65'),
|
||||
('sim_member','sim_member','125 1','598 611','85 611',X'034b4921457a54624a496c49303030303030303030303a636164656e63652e6d6f65405f6f6f79655f61726a756e3034323236393a636164656e63652e6d6f65'),
|
||||
('sim_member','sim_member','35 1','818 851','107 851',X'034b472147486e4d47697875303030303030303030303a636164656e63652e6d6f65405f6f6f79655f76616e746164656c69613a636164656e63652e6d6f65'),
|
||||
('sim_member','sim_member','63 1','945 1005','141 1005',X'034b412148725979716b6f79303030303030303030303a636164656e63652e6d6f65405f6f6f79655f7669686f776c733a636164656e63652e6d6f65'),
|
||||
('sim_member','sim_member','48 1','1024 1025','149 1025',X'034b47214943566475566c64303030303030303030303a636164656e63652e6d6f65405f6f6f79655f5f706b5f6172656866723a636164656e63652e6d6f65'),
|
||||
('sim_member','sim_member','39 1','1205 1223','175 1223',X'034b41214a48614a71425870303030303030303030303a636164656e63652e6d6f65405f6f6f79655f6c6f6f6e656c613a636164656e63652e6d6f65'),
|
||||
('sim_member','sim_member','48 1','1734 1768','289 1768',X'034b47215074796952785161303030303030303030303a636164656e63652e6d6f65405f6f6f79655f6d6f6d6f7473756b692e3a636164656e63652e6d6f65'),
|
||||
('sim_member','sim_member','5 1','1832 1835','299 1835',X'034b4b2151544372636e6953303030303030303030303a636164656e63652e6d6f65405f6f6f79655f72616e646f6d6974796775793a636164656e63652e6d6f65'),
|
||||
('sim_member','sim_member','64 1','2097 2100','353 2100',X'034b4521536c7664497a734f303030303030303030303a636164656e63652e6d6f65405f6f6f79655f5f706b5f626369736c3a636164656e63652e6d6f65'),
|
||||
('sim_member','sim_member','81 1','2213 2240','361 2240',X'034b4721544f61794476734c303030303030303030303a636164656e63652e6d6f65405f6f6f79655f5f706b5f6f7a707a79633a636164656e63652e6d6f65'),
|
||||
('sim_member','sim_member','42 1','2368 2409','373 2409',X'034b49215468436b4b585743303030303030303030303a636164656e63652e6d6f65405f6f6f79655f776172736d6974686c69763a636164656e63652e6d6f65'),
|
||||
('sim_member','sim_member','36 1','2422 2447','380 2447',X'034b4b2154716c79516d6966303030303030303030303a636164656e63652e6d6f65405f6f6f79655f6a6f6b65726765726d616e793a636164656e63652e6d6f65'),
|
||||
('sim_member','sim_member','65 1','2689 2721','438 2721',X'034b472157755a5549494e74303030303030303030303a636164656e63652e6d6f65405f6f6f79655f5f706b5f77797a63686a3a636164656e63652e6d6f65'),
|
||||
('sim_member','sim_member','2 1','3058 3059','497 3059',X'034b3921616f764c6d776a67303030303030303030303a636164656e63652e6d6f65405f6f6f79655f726e6c3a636164656e63652e6d6f65'),
|
||||
('sim_member','sim_member','8 1','3666 3671','630 3671',X'034b39216966636d75794e6e303030303030303030303a636164656e63652e6d6f65405f6f6f79655f726e6c3a636164656e63652e6d6f65'),
|
||||
('sim_member','sim_member','43 1','3849 3874','668 3874',X'034b4f216b6b4b714249664c303030303030303030303a636164656e63652e6d6f65405f6f6f79655f656c656374726f6e6963353339313a636164656e63652e6d6f65'),
|
||||
('sim_member','sim_member','8 1','4280 4283','746 4283',X'034b3f216f705748554e6b46303030303030303030303a636164656e63652e6d6f65405f6f6f79655f636f6f6b69653a636164656e63652e6d6f65'),
|
||||
('sim_member','sim_member','158 1','4424 4465','770 4465',X'034b452170757146464b5948303030303030303030303a636164656e63652e6d6f65405f6f6f79655f5f706b5f6a666e747a3a636164656e63652e6d6f65'),
|
||||
('sim_member','sim_member','44 1','4810 4810','824 4810',X'034b4121734b4c6f784a4e62303030303030303030303a636164656e63652e6d6f65405f6f6f79655f313030626563733a636164656e63652e6d6f65'),
|
||||
('sim_member','sim_member','11 1','4892 4895','841 4895',X'034b45217443744769524448303030303030303030303a636164656e63652e6d6f65405f6f6f79655f646f6f74736b7972653a636164656e63652e6d6f65'),
|
||||
('sim_member','sim_member','73 1','5072 5089','888 5089',X'034b47217665764462756174303030303030303030303a636164656e63652e6d6f65405f6f6f79655f5f706b5f6b73706a75653a636164656e63652e6d6f65'),
|
||||
('sim_member','sim_member','59 1','5182 5236','903 5236',X'034b43217750454472596b77303030303030303030303a636164656e63652e6d6f65405f6f6f79655f74656368323334613a636164656e63652e6d6f65'),
|
||||
('sim_member','sim_member','52 1','5441 5469','968 5469',X'034b41217a66654e574d744b303030303030303030303a636164656e63652e6d6f65405f6f6f79655f6e6f766574746f3a636164656e63652e6d6f65'),
|
||||
('emoji','emoji','1','385','385',X'023331313035373039393137313237353737363733'),
|
||||
('emoji','emoji','1','771','771',X'023331323230353735323436303531303533353638'),
|
||||
('emoji','emoji','1','1157','1157',X'023331333530383339313335363836313033303730'),
|
||||
('emoji','emoji','1','1543','1543',X'0231333439373232393637383636393938373834'),
|
||||
('emoji','emoji','1','1929','1929',X'0231343933383437383237313138353535313436'),
|
||||
('emoji','emoji','1','2315','2315',X'0231363432353731303038313337373536373032'),
|
||||
('emoji','emoji','1','2701','2701',X'0231373738313036343330333034393434313238'),
|
||||
('emoji','emoji','1','3087','3087',X'0231393030383733373535343037303336343738'),
|
||||
('guild_space','guild_space','1','4','4',X'023331313333333135333632353636343535333336'),
|
||||
('guild_space','guild_space','1','9','9',X'023331313534383638343234373234343633363837'),
|
||||
('guild_space','guild_space','1','14','14',X'023331323139303338323637383430393235383138'),
|
||||
('guild_space','guild_space','1','19','19',X'023331323839363030383537323437303535383733'),
|
||||
('guild_space','guild_space','1','24','24',X'023331333435363431323031393032323838393837'),
|
||||
('guild_space','guild_space','1','29','29',X'0231323733383737363437323234393935383431'),
|
||||
('guild_space','guild_space','1','34','34',X'0231353239313736313536333938363832313135'),
|
||||
('guild_space','guild_space','1','39','39',X'0231383730313138363530373638363730373530'),
|
||||
('member_power','member_power','1 1','0 0','0 0',X'03350f40636164656e63653a636164656e63652e6d6f652a'),
|
||||
('sim_proxy','sim_proxy','1','23','23',X'025531363733363165392d656137652d343530392d623533302d356531613863613735336237'),
|
||||
('sim_proxy','sim_proxy','1','47','47',X'025532653561626332312d326332622d346133352d386237642d366432383162363036653932'),
|
||||
('sim_proxy','sim_proxy','1','71','71',X'025534383131393165322d393462302d346534632d623934352d336330323932623135356238'),
|
||||
('sim_proxy','sim_proxy','1','95','95',X'025536346331346631642d663834342d346535622d386665332d336162336163363239616230'),
|
||||
('sim_proxy','sim_proxy','1','119','119',X'025538376562363463322d363763352d346432352d383161642d666664333235663266303639'),
|
||||
('sim_proxy','sim_proxy','1','143','143',X'025561616630313539652d623165312d343231342d396266652d313334613536303738323231'),
|
||||
('sim_proxy','sim_proxy','1','167','167',X'025563396534393633372d663061352d343566352d383234382d366436393565643861316434'),
|
||||
('sim_proxy','sim_proxy','1','191','191',X'025565333734613634362d386231332d343365392d393635392d653233326366653866626265'),
|
||||
('member_cache','member_cache','4 1','98 99','66 99',X'034b35214a48614a71425870303030303030303030303a636164656e63652e6d6f6540657a7261637574653a6d61747269782e6f7267'),
|
||||
('member_cache','member_cache','4 1','119 122','80 122',X'034b43214c684978654c4d54303030303030303030303a636164656e63652e6d6f6540737461727368696e656c756e6163793a6d61747269782e6f7267'),
|
||||
('member_cache','member_cache','1 1','124 124','82 124',X'034b2d214d5071594e414a62303030303030303030303a636164656e63652e6d6f6540726e6c3a636164656e63652e6d6f65'),
|
||||
('member_cache','member_cache','5 1','128 131','85 131',X'034b3b214e446249714e704a303030303030303030303a636164656e63652e6d6f65406761627269656c766f6e643a6d61747269782e6f7267'),
|
||||
('member_cache','member_cache','4 1','138 139','90 139',X'034b3d214f48584445737062303030303030303030303a636164656e63652e6d6f6540616d693a7468652d61706f746865636172792e636c7562'),
|
||||
('member_cache','member_cache','5 1','207 209','135 209',X'034b51215450616f6a545444303030303030303030303a636164656e63652e6d6f65406a61636b736f6e6368656e3636363a6a61636b736f6e6368656e3636362e636f6d'),
|
||||
('member_cache','member_cache','76 1','216 249','140 249',X'034b2d2154716c79516d6966303030303030303030303a636164656e63652e6d6f65406963656d616e3a656e76732e6e6574'),
|
||||
('member_cache','member_cache','4 1','345 345','171 345',X'034b3521586f4c466b65786a303030303030303030303a636164656e63652e6d6f6540636164656e63653a636164656e63652e6d6f65'),
|
||||
('member_cache','member_cache','10 1','351 354','174 354',X'034b3521594b46454e797166303030303030303030303a636164656e63652e6d6f654066617269656c6c653a6d61747269782e6f7267'),
|
||||
('member_cache','member_cache','1 1','374 374','183 374',X'034b2d21596f54644f55766a303030303030303030303a636164656e63652e6d6f6540726e6c3a636164656e63652e6d6f65'),
|
||||
('member_cache','member_cache','152 1','405 499','205 499',X'034b45216342787456527844303030303030303030303a636164656e63652e6d6f65406d61726975733835313030303a6d617269757364617669642e6672'),
|
||||
('member_cache','member_cache','4 1','562 563','209 563',X'034b35216356514d45455158303030303030303030303a636164656e63652e6d6f6540657a7261637574653a6d61747269782e6f7267'),
|
||||
('member_cache','member_cache','8 1','582 586','223 586',X'034b3721654856655270706e303030303030303030303a636164656e63652e6d6f65406563686f3a66757272797265667567652e636f6d'),
|
||||
('member_cache','member_cache','7 1','594 600','227 600',X'034b3b2165724f7079584e46303030303030303030303a636164656e63652e6d6f6540766962656973766572796f3a6d61747269782e6f7267'),
|
||||
('member_cache','member_cache','165 1','613 624','235 624',X'034b4921676865544b5a7451303030303030303030303a636164656e63652e6d6f6540616d70666c6f7765723a7468652d61706f746865636172792e636c7562'),
|
||||
('member_cache','member_cache','165 1','613 749','235 749',X'034b4521676865544b5a7451303030303030303030303a636164656e63652e6d6f654073706c617473756e653a636861742e6e6575726172696f2e636f6d'),
|
||||
('member_cache','member_cache','6 1','778 782','236 782',X'034b3321676b6b686f756d42303030303030303030303a636164656e63652e6d6f65406b6162693a6361746769726c2e776f726b73'),
|
||||
('member_cache','member_cache','10 1','786 794','239 794',X'034b332168424a766e654e4f303030303030303030303a636164656e63652e6d6f65406d65636879613a636164656e63652e6d6f65'),
|
||||
('member_cache','member_cache','13 1','809 819','249 819',X'034b2b2169537958674e7851303030303030303030303a636164656e63652e6d6f65406d69646f753a656e76732e6e6574'),
|
||||
('member_cache','member_cache','4 1','856 858','273 858',X'034b3b216b73724f45554666303030303030303030303a636164656e63652e6d6f65406761627269656c766f6e643a6d61747269782e6f7267'),
|
||||
('member_cache','member_cache','12 1','865 874','279 874',X'034b35216c7570486a715444303030303030303030303a636164656e63652e6d6f654068656c6c63703a6f70656e737573652e6f7267'),
|
||||
('member_cache','member_cache','4 1','886 887','285 887',X'034b2d216d61676745536775303030303030303030303a636164656e63652e6d6f65406361743a6d61756e69756d2e6e6574'),
|
||||
('member_cache','member_cache','71 1','999 999','357 999',X'034b2d2177574f6673767573303030303030303030303a636164656e63652e6d6f654061613a6361747669626572732e6d65'),
|
||||
('member_cache','member_cache','14 1','1074 1085','361 1085',X'034b3721776c534544496a44303030303030303030303a636164656e63652e6d6f654073646f6d693a6861636b657273706163652e706c'),
|
||||
('file','file','1','4054','4054',X'03816568747470733a2f2f63646e2e646973636f72646170702e636f6d2f6174746163686d656e74732f3131323736303636393137383234313032342f313135313733303032323332383035373930372f53637265656e73686f745f32303233303931345f3036303333352e6a7067'),
|
||||
('file','file','1','8109','8109',X'03814168747470733a2f2f63646e2e646973636f72646170702e636f6d2f6174746163686d656e74732f3131393239333936393731353639313532322f313231383430393538323539343838373638302f494d475f343738322e6a7067'),
|
||||
('file','file','1','12164','12164',X'03813b68747470733a2f2f63646e2e646973636f72646170702e636f6d2f6174746163686d656e74732f3133343037373735333438353033333437322f313139393131323831343931373333333133332f696d6167652e706e67'),
|
||||
('file','file','1','16219','16219',X'03814168747470733a2f2f63646e2e646973636f72646170702e636f6d2f6174746163686d656e74732f313337363732343737393830353034383838322f313339323938363435323538343936303032312f707265766965772e706e67'),
|
||||
('file','file','1','20274','20274',X'03816568747470733a2f2f63646e2e646973636f72646170702e636f6d2f6174746163686d656e74732f3135393136353731343139343735393638302f313236353735383536303531323338313030392f53637265656e73686f745f32303234303732342d3135353232382e706e67'),
|
||||
('file','file','1','24329','24329',X'03813b68747470733a2f2f63646e2e646973636f72646170702e636f6d2f6174746163686d656e74732f3238383838323935333331343839333832352f313134373538383535363839343738313539332f696d6167652e706e67'),
|
||||
('file','file','1','28384','28384',X'03817168747470733a2f2f63646e2e646973636f72646170702e636f6d2f6174746163686d656e74732f3635353231363137333639363238363734362f313330383239363937343136333737353530392f31373331393932363837323838343136383737323137393036333831303733392e6a7067'),
|
||||
('file','file','1','32439','32439',X'027f68747470733a2f2f63646e2e646973636f72646170702e636f6d2f656d6f6a69732f313034323532383239323539363632313434322e706e67'),
|
||||
('lottie','lottie','1','2','2',X'0231373439303532393434363832353832303336'),
|
||||
('lottie','lottie','1','5','5',X'0231373531363036333739333430333635383634'),
|
||||
('lottie','lottie','1','8','8',X'0231373534313038373731383532323232353634'),
|
||||
('lottie','lottie','1','11','11',X'0231373936313430363338303933343433303932'),
|
||||
('lottie','lottie','1','14','14',X'0231373936313431373032363935343835353030'),
|
||||
('lottie','lottie','1','17','17',X'0231383136303837373932323931323832393434'),
|
||||
('lottie','lottie','1','20','20',X'0231383233393736313032393736323930383636');
|
||||
|
||||
ANALYZE sqlite_schema;
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
BEGIN TRANSACTION;
|
||||
|
||||
CREATE TABLE room_upgrade_pending (
|
||||
new_room_id TEXT NOT NULL,
|
||||
old_room_id TEXT NOT NULL UNIQUE,
|
||||
PRIMARY KEY (new_room_id),
|
||||
FOREIGN KEY (old_room_id) REFERENCES channel_room (room_id) ON DELETE CASCADE
|
||||
) WITHOUT ROWID;
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
/*
|
||||
a. If the bridge bot sim already has the correct ID:
|
||||
- No rows updated.
|
||||
|
||||
b. If the bridge bot sim has the wrong ID but there's no duplicate:
|
||||
- One row updated.
|
||||
|
||||
c. If the bridge bot sim has the wrong ID and there's a duplicate:
|
||||
- One row updated (replaces an existing row).
|
||||
*/
|
||||
|
||||
const {discord} = require("../../passthrough")
|
||||
|
||||
const ones = "₀₁₂₃₄₅₆₇₈₉"
|
||||
const tens = "0123456789"
|
||||
|
||||
/* c8 ignore start */
|
||||
|
||||
module.exports = async function(db) {
|
||||
/** @type {{name: string, channel_id: string, thread_parent: string | null}[]} */
|
||||
const rows = db.prepare("SELECT name, channel_id, thread_parent FROM channel_room WHERE guild_id IS NULL").all()
|
||||
|
||||
/** @type {Map<string, string>} channel or thread ID -> guild ID */
|
||||
const cache = new Map()
|
||||
|
||||
// Process channels
|
||||
process.stdout.write(` loading metadata for ${rows.length} channels/threads... `)
|
||||
for (let counter = 1; counter <= rows.length; counter++) {
|
||||
process.stdout.write(String(counter).at(-1) === "0" ? tens[(counter/10)%10] : ones[counter%10])
|
||||
const row = rows[counter-1]
|
||||
const id = row.thread_parent || row.channel_id
|
||||
if (cache.has(id)) continue
|
||||
|
||||
try {
|
||||
var channel = await discord.snow.channel.getChannel(id)
|
||||
} catch (e) {
|
||||
continue
|
||||
}
|
||||
|
||||
const guildID = channel.guild_id
|
||||
const channels = await discord.snow.guild.getGuildChannels(guildID)
|
||||
for (const channel of channels) {
|
||||
cache.set(channel.id, guildID)
|
||||
}
|
||||
}
|
||||
|
||||
// Update channels and threads
|
||||
process.stdout.write("\n")
|
||||
db.transaction(() => {
|
||||
// Fill in missing data
|
||||
for (const row of rows) {
|
||||
const guildID = cache.get(row.thread_parent) || cache.get(row.channel_id)
|
||||
if (guildID) {
|
||||
db.prepare("UPDATE channel_room SET guild_id = ? WHERE channel_id = ?").run(guildID, row.channel_id)
|
||||
} else {
|
||||
db.prepare("DELETE FROM webhook WHERE channel_id = ?").run(row.channel_id)
|
||||
db.prepare("DELETE FROM channel_room WHERE channel_id = ?").run(row.channel_id)
|
||||
}
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
-- https://sqlite.org/lang_altertable.html
|
||||
|
||||
-- 1
|
||||
PRAGMA foreign_keys=OFF;
|
||||
-- 2
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
-- 4
|
||||
CREATE TABLE "new_channel_room" (
|
||||
"channel_id" TEXT NOT NULL,
|
||||
"room_id" TEXT NOT NULL UNIQUE,
|
||||
"name" TEXT NOT NULL,
|
||||
"nick" TEXT,
|
||||
"thread_parent" TEXT,
|
||||
"custom_avatar" TEXT,
|
||||
"last_bridged_pin_timestamp" INTEGER,
|
||||
"speedbump_id" TEXT,
|
||||
"speedbump_checked" INTEGER,
|
||||
"speedbump_webhook_id" TEXT,
|
||||
"guild_id" TEXT NOT NULL,
|
||||
"custom_topic" INTEGER DEFAULT 0,
|
||||
PRIMARY KEY("channel_id"),
|
||||
FOREIGN KEY("guild_id") REFERENCES "guild_active"("guild_id") ON DELETE CASCADE
|
||||
) WITHOUT ROWID;
|
||||
|
||||
-- 5
|
||||
INSERT INTO new_channel_room
|
||||
(channel_id, room_id, name, nick, thread_parent, custom_avatar, last_bridged_pin_timestamp, speedbump_id, speedbump_checked, speedbump_webhook_id, guild_id, custom_topic)
|
||||
SELECT channel_id, room_id, name, nick, thread_parent, custom_avatar, last_bridged_pin_timestamp, speedbump_id, speedbump_checked, speedbump_webhook_id, guild_id, custom_topic
|
||||
FROM channel_room;
|
||||
|
||||
-- 6
|
||||
DROP TABLE channel_room;
|
||||
|
||||
-- 7
|
||||
ALTER TABLE new_channel_room RENAME TO channel_room;
|
||||
|
||||
-- 10
|
||||
PRAGMA foreign_key_check;
|
||||
|
||||
-- 11
|
||||
COMMIT;
|
||||
-- 12
|
||||
PRAGMA foreign_keys=ON;
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
BEGIN TRANSACTION;
|
||||
|
||||
DROP TABLE IF EXISTS "poll";
|
||||
DROP TABLE IF EXISTS "poll_option";
|
||||
DROP TABLE IF EXISTS "poll_vote";
|
||||
|
||||
CREATE TABLE "poll" (
|
||||
"message_id" TEXT NOT NULL,
|
||||
"max_selections" INTEGER NOT NULL,
|
||||
"question_text" TEXT NOT NULL,
|
||||
"is_closed" INTEGER NOT NULL,
|
||||
PRIMARY KEY ("message_id"),
|
||||
FOREIGN KEY ("message_id") REFERENCES "message_room" ("message_id") ON DELETE CASCADE
|
||||
) WITHOUT ROWID;
|
||||
|
||||
CREATE TABLE "poll_option" (
|
||||
"message_id" TEXT NOT NULL,
|
||||
"matrix_option" TEXT NOT NULL,
|
||||
"discord_option" TEXT,
|
||||
"option_text" TEXT NOT NULL,
|
||||
"seq" INTEGER NOT NULL,
|
||||
PRIMARY KEY ("message_id", "matrix_option"),
|
||||
FOREIGN KEY ("message_id") REFERENCES "poll" ("message_id") ON DELETE CASCADE
|
||||
) WITHOUT ROWID;
|
||||
|
||||
CREATE TABLE "poll_vote" (
|
||||
"message_id" TEXT NOT NULL,
|
||||
"matrix_option" TEXT NOT NULL,
|
||||
"discord_or_matrix_user_id" TEXT NOT NULL,
|
||||
PRIMARY KEY ("message_id", "matrix_option", "discord_or_matrix_user_id"),
|
||||
FOREIGN KEY ("message_id", "matrix_option") REFERENCES "poll_option" ("message_id", "matrix_option") ON DELETE CASCADE
|
||||
) WITHOUT ROWID;
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
BEGIN TRANSACTION;
|
||||
|
||||
ALTER TABLE member_cache ADD COLUMN missing_profile INTEGER;
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
BEGIN TRANSACTION;
|
||||
|
||||
DELETE FROM sim WHERE sim_name like '%/%';
|
||||
|
||||
COMMIT;
|
||||
41
src/db/orm-defs.d.ts
vendored
41
src/db/orm-defs.d.ts
vendored
|
|
@ -52,7 +52,6 @@ export type Models = {
|
|||
privacy_level: number
|
||||
presence: 0 | 1
|
||||
url_preview: 0 | 1
|
||||
webhook_profile: 0 | 1
|
||||
}
|
||||
|
||||
guild_active: {
|
||||
|
|
@ -60,13 +59,6 @@ export type Models = {
|
|||
autocreate: 0 | 1
|
||||
}
|
||||
|
||||
historical_channel_room: {
|
||||
historical_room_index: number
|
||||
reference_channel_id: string
|
||||
room_id: string
|
||||
upgraded_timestamp: number
|
||||
}
|
||||
|
||||
invite: {
|
||||
mxid: string
|
||||
room_id: string
|
||||
|
|
@ -90,7 +82,6 @@ export type Models = {
|
|||
displayname: string | null
|
||||
avatar_url: string | null,
|
||||
power_level: number
|
||||
missing_profile: number | null
|
||||
}
|
||||
|
||||
member_power: {
|
||||
|
|
@ -99,20 +90,15 @@ export type Models = {
|
|||
power_level: number
|
||||
}
|
||||
|
||||
message_room: {
|
||||
message_channel: {
|
||||
message_id: string
|
||||
historical_room_index: number
|
||||
}
|
||||
|
||||
room_upgrade_pending: {
|
||||
new_room_id: string
|
||||
old_room_id: string
|
||||
channel_id: string
|
||||
}
|
||||
|
||||
sim: {
|
||||
user_id: string
|
||||
username: string
|
||||
sim_name: string
|
||||
localpart: string
|
||||
mxid: string
|
||||
}
|
||||
|
||||
|
|
@ -140,27 +126,6 @@ export type Models = {
|
|||
encoded_emoji: string
|
||||
original_encoding: string | null
|
||||
}
|
||||
|
||||
poll: { // not actually in database yet
|
||||
message_id: string
|
||||
max_selections: number
|
||||
question_text: string
|
||||
is_closed: number
|
||||
}
|
||||
|
||||
poll_option: {
|
||||
message_id: string
|
||||
matrix_option: string
|
||||
discord_option: string | null
|
||||
option_text: string // not actually in database yet
|
||||
seq: number // not actually in database yet
|
||||
}
|
||||
|
||||
poll_vote: {
|
||||
message_id: string
|
||||
matrix_option: string
|
||||
discord_or_matrix_user_id: string
|
||||
}
|
||||
}
|
||||
|
||||
export type Prepared<Row> = {
|
||||
|
|
|
|||
|
|
@ -21,8 +21,8 @@ test("orm: select: get pluck works", t => {
|
|||
})
|
||||
|
||||
test("orm: select: get, where and pluck works", t => {
|
||||
const emojiName = select("emoji", "name", {emoji_id: "230201364309868544"}).pluck().get()
|
||||
t.equal(emojiName, "hippo")
|
||||
const channelID = select("message_channel", "channel_id", {message_id: "1128118177155526666"}).pluck().get()
|
||||
t.equal(channelID, "112760669178241024")
|
||||
})
|
||||
|
||||
test("orm: select: all, where and pluck works on multiple columns", t => {
|
||||
|
|
@ -66,5 +66,5 @@ test("orm: select unsafe works (to select complex column names that can't be typ
|
|||
.and("where member_power.room_id = '*' and member_cache.power_level != member_power.power_level")
|
||||
.selectUnsafe("mxid", "member_cache.room_id", "member_power.power_level")
|
||||
.all()
|
||||
t.equal(results[0].power_level, 150)
|
||||
t.equal(results[0].power_level, 100)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,26 +1,19 @@
|
|||
// @ts-check
|
||||
|
||||
const DiscordTypes = require("discord-api-types/v10")
|
||||
const {discord, sync, select, from} = require("../../passthrough")
|
||||
const assert = require("assert").strict
|
||||
const {discord, sync, from} = require("../../passthrough")
|
||||
|
||||
/** @type {import("../../matrix/api")} */
|
||||
const api = sync.require("../../matrix/api")
|
||||
|
||||
/** @type {import("../../matrix/utils")} */
|
||||
const utils = sync.require("../../matrix/utils")
|
||||
|
||||
/** @type {import("../../web/routes/guild")} */
|
||||
const webGuild = sync.require("../../web/routes/guild")
|
||||
|
||||
/**
|
||||
* @param {DiscordTypes.APIMessageApplicationCommandGuildInteraction} interaction
|
||||
* @param {{api: typeof api}} di
|
||||
* @returns {Promise<DiscordTypes.APIInteractionResponse>}
|
||||
*/
|
||||
async function _interact({guild_id, data}, {api}) {
|
||||
const message = from("event_message").join("message_room", "message_id").join("historical_channel_room", "historical_room_index")
|
||||
.select("source", "reference_channel_id", "room_id", "event_id").where({message_id: data.target_id}).and("ORDER BY part").get()
|
||||
const message = from("event_message").join("message_channel", "message_id").join("channel_room", "channel_id")
|
||||
.select("name", "nick", "source", "channel_id", "room_id", "event_id").where({message_id: data.target_id, part: 0}).get()
|
||||
|
||||
if (!message) {
|
||||
return {
|
||||
|
|
@ -32,19 +25,15 @@ async function _interact({guild_id, data}, {api}) {
|
|||
}
|
||||
}
|
||||
|
||||
const channel_id = message.reference_channel_id
|
||||
const room = select("channel_room", ["name", "nick"], {channel_id}).get()
|
||||
assert(room)
|
||||
|
||||
const idInfo = `\n-# Room ID: \`${message.room_id}\`\n-# Event ID: \`${message.event_id}\``
|
||||
const roomName = room.nick || room.name
|
||||
const roomName = message.nick || message.name
|
||||
|
||||
if (message.source === 1) { // from Discord
|
||||
const userID = data.resolved.messages[data.target_id].author.id
|
||||
return {
|
||||
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
|
||||
data: {
|
||||
content: `Bridged <@${userID}> https://discord.com/channels/${guild_id}/${channel_id}/${data.target_id} on Discord to [${roomName}](<https://matrix.to/#/${message.room_id}/${message.event_id}>) on Matrix.`
|
||||
content: `Bridged <@${userID}> https://discord.com/channels/${guild_id}/${message.channel_id}/${data.target_id} on Discord to [${roomName}](<https://matrix.to/#/${message.room_id}/${message.event_id}>) on Matrix.`
|
||||
+ idInfo,
|
||||
flags: DiscordTypes.MessageFlags.Ephemeral
|
||||
}
|
||||
|
|
@ -53,35 +42,11 @@ 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
|
||||
// @ts-ignore
|
||||
.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())
|
||||
const matrixMember = select("member_cache", ["displayname", "avatar_url"], {room_id: message.room_id, mxid: event.sender}).get()
|
||||
const name = matrixMember?.displayname || event.sender
|
||||
return {
|
||||
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
|
||||
data: {
|
||||
embeds: [{
|
||||
author: {
|
||||
name,
|
||||
url: `https://matrix.to/#/${event.sender}`,
|
||||
icon_url: utils.getPublicUrlForMxc(matrixMember?.avatar_url)
|
||||
},
|
||||
description: `This Matrix message was delivered to Discord by **Out Of Your Element**.\n[View on Matrix →](<https://matrix.to/#/${message.room_id}/${message.event_id}?${via}>)\n\n**User ID**: [${event.sender}](<https://matrix.to/#/${event.sender}>)`,
|
||||
color: 0x0dbd8b,
|
||||
fields: [{
|
||||
name: "In Channels",
|
||||
value: inChannels.map(c => `<#${c.id}>`).join(" • ")
|
||||
}, {
|
||||
name: "\u200b",
|
||||
value: idInfo
|
||||
}]
|
||||
}],
|
||||
content: `Bridged [${event.sender}](<https://matrix.to/#/${event.sender}>)'s message in [${roomName}](<https://matrix.to/#/${message.room_id}/${message.event_id}>) on Matrix to https://discord.com/channels/${guild_id}/${message.channel_id}/${data.target_id} on Discord.`
|
||||
+ idInfo,
|
||||
flags: DiscordTypes.MessageFlags.Ephemeral
|
||||
}
|
||||
}
|
||||
|
|
@ -94,15 +59,5 @@ async function interact(interaction) {
|
|||
await discord.snow.interaction.createInteractionResponse(interaction.id, interaction.token, await _interact(interaction, {api}))
|
||||
}
|
||||
|
||||
/** @param {DiscordTypes.APIMessageApplicationCommandGuildInteraction} interaction */
|
||||
async function dm(interaction) {
|
||||
const channel = await discord.snow.user.createDirectMessageChannel(interaction.member.user.id)
|
||||
const response = await _interact(interaction, {api})
|
||||
assert(response.type === DiscordTypes.InteractionResponseType.ChannelMessageWithSource)
|
||||
response.data.flags = 0 & 0 // not ephemeral
|
||||
await discord.snow.channel.createMessage(channel.id, response.data)
|
||||
}
|
||||
|
||||
module.exports.interact = interact
|
||||
module.exports._interact = _interact
|
||||
module.exports.dm = dm
|
||||
|
|
|
|||
|
|
@ -60,27 +60,13 @@ test("matrix info: shows info for matrix source message", async t => {
|
|||
},
|
||||
sender: "@cadence:cadence.moe"
|
||||
}
|
||||
},
|
||||
async getJoinedMembers(roomID) {
|
||||
return {
|
||||
joined: {}
|
||||
}
|
||||
},
|
||||
async getStateEventOuter(roomID, type, key) {
|
||||
return {
|
||||
content: {
|
||||
room_version: "11"
|
||||
}
|
||||
}
|
||||
},
|
||||
async getStateEvent(roomID, type, key) {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
})
|
||||
t.equal(
|
||||
msg.data.embeds[0].fields[1].value,
|
||||
"\n-# Room ID: `!kLRqKKUQXcibIMtOpl:cadence.moe`"
|
||||
msg.data.content,
|
||||
"Bridged [@cadence:cadence.moe](<https://matrix.to/#/@cadence:cadence.moe>)'s message in [main](<https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4>) on Matrix to https://discord.com/channels/112760669178241024/112760669178241024/1128118177155526666 on Discord."
|
||||
+ "\n-# Room ID: `!kLRqKKUQXcibIMtOpl:cadence.moe`"
|
||||
+ "\n-# Event ID: `$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4`"
|
||||
)
|
||||
t.equal(called, 1)
|
||||
|
|
|
|||
|
|
@ -9,19 +9,17 @@ const {InteractionMethods} = require("snowtransfer")
|
|||
|
||||
/** @type {import("../../matrix/api")} */
|
||||
const api = sync.require("../../matrix/api")
|
||||
/** @type {import("../../matrix/utils")} */
|
||||
const utils = sync.require("../../matrix/utils")
|
||||
|
||||
/**
|
||||
* @param {DiscordTypes.APIContextMenuGuildInteraction} interaction
|
||||
* @param {{api: typeof api, utils: typeof utils}} di
|
||||
* @param {{api: typeof api}} di
|
||||
* @returns {AsyncGenerator<{[k in keyof InteractionMethods]?: Parameters<InteractionMethods[k]>[2]}>}
|
||||
*/
|
||||
async function* _interact({data, guild_id}, {api, utils}) {
|
||||
async function* _interact({data, guild_id}, {api}) {
|
||||
// Get message info
|
||||
const row = from("event_message")
|
||||
.join("message_room", "message_id").join("historical_channel_room", "historical_room_index")
|
||||
.select("event_id", "source", "room_id", "reference_channel_id")
|
||||
.join("message_channel", "message_id")
|
||||
.select("event_id", "source", "channel_id")
|
||||
.where({message_id: data.target_id})
|
||||
.get()
|
||||
|
||||
|
|
@ -37,9 +35,10 @@ async function* _interact({data, guild_id}, {api, utils}) {
|
|||
}
|
||||
|
||||
// Get the message sender, the person that will be inspected/edited
|
||||
const roomID = select("channel_room", "room_id", {channel_id: row.reference_channel_id}).pluck().get()
|
||||
const eventID = row.event_id
|
||||
const roomID = select("channel_room", "room_id", {channel_id: row.channel_id}).pluck().get()
|
||||
assert(roomID)
|
||||
const event = await api.getEvent(row.room_id, row.event_id)
|
||||
const event = await api.getEvent(roomID, eventID)
|
||||
const sender = event.sender
|
||||
|
||||
// Get the space, where the power levels will be inspected/edited
|
||||
|
|
@ -47,10 +46,12 @@ async function* _interact({data, guild_id}, {api, utils}) {
|
|||
assert(spaceID)
|
||||
|
||||
// Get the power level
|
||||
const {powers: {[event.sender]: userPower, [utils.bot]: botPower}} = await utils.getEffectivePower(spaceID, [event.sender, utils.bot], api)
|
||||
/** @type {Ty.Event.M_Power_Levels} */
|
||||
const powerLevelsContent = await api.getStateEvent(spaceID, "m.room.power_levels", "")
|
||||
const userPower = powerLevelsContent.users?.[event.sender] || 0
|
||||
|
||||
// Administrators/founders equal to the bot cannot be demoted
|
||||
if (userPower >= botPower) {
|
||||
// Administrators equal to the bot cannot be demoted
|
||||
if (userPower >= 100) {
|
||||
return yield {createInteractionResponse: {
|
||||
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
|
||||
data: {
|
||||
|
|
@ -60,8 +61,6 @@ async function* _interact({data, guild_id}, {api, utils}) {
|
|||
}}
|
||||
}
|
||||
|
||||
const adminLabel = botPower === 100 ? "Admin (you cannot undo this!)" : "Admin"
|
||||
|
||||
yield {createInteractionResponse: {
|
||||
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
|
||||
data: {
|
||||
|
|
@ -84,9 +83,9 @@ async function* _interact({data, guild_id}, {api, utils}) {
|
|||
value: "moderator",
|
||||
default: userPower >= 50 && userPower < 100
|
||||
}, {
|
||||
label: adminLabel,
|
||||
label: "Admin (you cannot undo this!)",
|
||||
value: "admin",
|
||||
default: userPower >= 100
|
||||
default: userPower === 100
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -126,7 +125,7 @@ async function* _interactEdit({data, guild_id, message}, {api}) {
|
|||
assert(spaceID)
|
||||
|
||||
// Do it
|
||||
await utils.setUserPowerCascade(spaceID, mxid, power, api)
|
||||
await api.setUserPowerCascade(spaceID, mxid, power)
|
||||
|
||||
// ACK
|
||||
yield {editOriginalInteractionResponse: {
|
||||
|
|
@ -140,7 +139,7 @@ async function* _interactEdit({data, guild_id, message}, {api}) {
|
|||
|
||||
/** @param {DiscordTypes.APIContextMenuGuildInteraction} interaction */
|
||||
async function interact(interaction) {
|
||||
for await (const response of _interact(interaction, {api, utils})) {
|
||||
for await (const response of _interact(interaction, {api})) {
|
||||
if (response.createInteractionResponse) {
|
||||
// TODO: Test if it is reasonable to remove `await` from these calls. Or zip these calls with the next interaction iteration and use Promise.all.
|
||||
await discord.snow.interaction.createInteractionResponse(interaction.id, interaction.token, response.createInteractionResponse)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ const {test} = require("supertape")
|
|||
const DiscordTypes = require("discord-api-types/v10")
|
||||
const {select, db} = require("../../passthrough")
|
||||
const {_interact, _interactEdit} = require("./permissions")
|
||||
const {mockGetEffectivePower} = require("../../matrix/utils.test")
|
||||
|
||||
/**
|
||||
* @template T
|
||||
|
|
@ -47,10 +46,6 @@ test("permissions: reports permissions of selected matrix user (implicit default
|
|||
},
|
||||
guild_id: "112760669178241024"
|
||||
}, {
|
||||
utils: {
|
||||
bot: "@_ooye_bot:cadence.moe",
|
||||
getEffectivePower: mockGetEffectivePower()
|
||||
},
|
||||
api: {
|
||||
async getEvent(roomID, eventID) {
|
||||
called++
|
||||
|
|
@ -59,13 +54,22 @@ test("permissions: reports permissions of selected matrix user (implicit default
|
|||
return {
|
||||
sender: "@cadence:cadence.moe"
|
||||
}
|
||||
},
|
||||
async getStateEvent(roomID, type, key) {
|
||||
called++
|
||||
t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe") // space ID
|
||||
t.equal(type, "m.room.power_levels")
|
||||
t.equal(key, "")
|
||||
return {
|
||||
users: {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
t.equal(msgs.length, 1)
|
||||
t.equal(msgs[0].createInteractionResponse.data.content, "Showing permissions for `@cadence:cadence.moe`. Click to edit.")
|
||||
t.deepEqual(msgs[0].createInteractionResponse.data.components[0].components[0].options[0], {label: "Default", value: "default", default: true})
|
||||
t.equal(called, 1)
|
||||
t.equal(called, 2)
|
||||
})
|
||||
|
||||
test("permissions: reports permissions of selected matrix user (moderator)", async t => {
|
||||
|
|
@ -76,10 +80,6 @@ test("permissions: reports permissions of selected matrix user (moderator)", asy
|
|||
},
|
||||
guild_id: "112760669178241024"
|
||||
}, {
|
||||
utils: {
|
||||
bot: "@_ooye_bot:cadence.moe",
|
||||
getEffectivePower: mockGetEffectivePower(["@_ooye_bot:cadence.moe"], {"@cadence:cadence.moe": 50})
|
||||
},
|
||||
api: {
|
||||
async getEvent(roomID, eventID) {
|
||||
called++
|
||||
|
|
@ -88,16 +88,27 @@ test("permissions: reports permissions of selected matrix user (moderator)", asy
|
|||
return {
|
||||
sender: "@cadence:cadence.moe"
|
||||
}
|
||||
},
|
||||
async getStateEvent(roomID, type, key) {
|
||||
called++
|
||||
t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe") // space ID
|
||||
t.equal(type, "m.room.power_levels")
|
||||
t.equal(key, "")
|
||||
return {
|
||||
users: {
|
||||
"@cadence:cadence.moe": 50
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
t.equal(msgs.length, 1)
|
||||
t.equal(msgs[0].createInteractionResponse.data.content, "Showing permissions for `@cadence:cadence.moe`. Click to edit.")
|
||||
t.deepEqual(msgs[0].createInteractionResponse.data.components[0].components[0].options[1], {label: "Moderator", value: "moderator", default: true})
|
||||
t.equal(called, 1)
|
||||
t.equal(called, 2)
|
||||
})
|
||||
|
||||
test("permissions: reports permissions of selected matrix user (admin v12 can be demoted)", async t => {
|
||||
test("permissions: reports permissions of selected matrix user (admin)", async t => {
|
||||
let called = 0
|
||||
const msgs = await fromAsync(_interact({
|
||||
data: {
|
||||
|
|
@ -105,10 +116,6 @@ test("permissions: reports permissions of selected matrix user (admin v12 can be
|
|||
},
|
||||
guild_id: "112760669178241024"
|
||||
}, {
|
||||
utils: {
|
||||
bot: "@_ooye_bot:cadence.moe",
|
||||
getEffectivePower: mockGetEffectivePower(["@_ooye_bot:cadence.moe"], {"@cadence:cadence.moe": 100})
|
||||
},
|
||||
api: {
|
||||
async getEvent(roomID, eventID) {
|
||||
called++
|
||||
|
|
@ -117,34 +124,16 @@ test("permissions: reports permissions of selected matrix user (admin v12 can be
|
|||
return {
|
||||
sender: "@cadence:cadence.moe"
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
t.equal(msgs.length, 1)
|
||||
t.equal(msgs[0].createInteractionResponse.data.content, "Showing permissions for `@cadence:cadence.moe`. Click to edit.")
|
||||
t.deepEqual(msgs[0].createInteractionResponse.data.components[0].components[0].options[2], {label: "Admin", value: "admin", default: true})
|
||||
t.equal(called, 1)
|
||||
})
|
||||
|
||||
test("permissions: reports permissions of selected matrix user (admin v11 cannot be demoted)", async t => {
|
||||
let called = 0
|
||||
const msgs = await fromAsync(_interact({
|
||||
data: {
|
||||
target_id: "1128118177155526666"
|
||||
},
|
||||
guild_id: "112760669178241024"
|
||||
}, {
|
||||
utils: {
|
||||
bot: "@_ooye_bot:cadence.moe",
|
||||
getEffectivePower: mockGetEffectivePower(["@_ooye_bot:cadence.moe"], {"@cadence:cadence.moe": 100, "@_ooye_bot:cadence.moe": 100}, "11")
|
||||
},
|
||||
api: {
|
||||
async getEvent(roomID, eventID) {
|
||||
},
|
||||
async getStateEvent(roomID, type, key) {
|
||||
called++
|
||||
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") // room ID
|
||||
t.equal(eventID, "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4")
|
||||
t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe") // space ID
|
||||
t.equal(type, "m.room.power_levels")
|
||||
t.equal(key, "")
|
||||
return {
|
||||
sender: "@cadence:cadence.moe"
|
||||
users: {
|
||||
"@cadence:cadence.moe": 100
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -152,11 +141,11 @@ test("permissions: reports permissions of selected matrix user (admin v11 cannot
|
|||
t.equal(msgs.length, 1)
|
||||
t.equal(msgs[0].createInteractionResponse.data.content, "`@cadence:cadence.moe` has administrator permissions. This cannot be edited.")
|
||||
t.notOk(msgs[0].createInteractionResponse.data.components)
|
||||
t.equal(called, 1)
|
||||
t.equal(called, 2)
|
||||
})
|
||||
|
||||
test("permissions: can update user to moderator", async t => {
|
||||
let called = []
|
||||
let called = 0
|
||||
const msgs = await fromAsync(_interactEdit({
|
||||
data: {
|
||||
target_id: "1128118177155526666",
|
||||
|
|
@ -168,48 +157,22 @@ test("permissions: can update user to moderator", async t => {
|
|||
guild_id: "112760669178241024"
|
||||
}, {
|
||||
api: {
|
||||
async getStateEvent(roomID, type, key) {
|
||||
called.push("get power levels")
|
||||
t.equal(type, "m.room.power_levels")
|
||||
return {}
|
||||
},
|
||||
async getStateEventOuter(roomID, type, key) {
|
||||
called.push("get room create")
|
||||
return {
|
||||
type: "m.room.create",
|
||||
state_key: "",
|
||||
sender: "@_ooye_bot:cadence.moe",
|
||||
event_id: "$create",
|
||||
origin_server_ts: 0,
|
||||
room_id: roomID,
|
||||
content: {
|
||||
room_version: "11"
|
||||
}
|
||||
}
|
||||
},
|
||||
async *generateFullHierarchy(spaceID) {
|
||||
called.push("generate full hierarchy")
|
||||
},
|
||||
async sendState(roomID, type, key, content) {
|
||||
called.push("set power levels")
|
||||
t.ok(["!hierarchy", "!jjmvBegULiLucuWEHU:cadence.moe"].includes(roomID), `expected room ID to be in hierarchy, but was ${roomID}`)
|
||||
t.equal(type, "m.room.power_levels")
|
||||
t.equal(key, "")
|
||||
t.deepEqual(content, {
|
||||
users: {"@cadence:cadence.moe": 50}
|
||||
})
|
||||
return "$updated"
|
||||
async setUserPowerCascade(roomID, mxid, power) {
|
||||
called++
|
||||
t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe") // space ID
|
||||
t.equal(mxid, "@cadence:cadence.moe")
|
||||
t.equal(power, 50)
|
||||
}
|
||||
}
|
||||
}))
|
||||
t.equal(msgs.length, 2)
|
||||
t.equal(msgs[0].createInteractionResponse.data.content, "Updating `@cadence:cadence.moe` to **moderator**, please wait...")
|
||||
t.equal(msgs[1].editOriginalInteractionResponse.content, "Updated `@cadence:cadence.moe` to **moderator**.")
|
||||
t.deepEqual(called, ["generate full hierarchy", "get room create", "get power levels", "set power levels"])
|
||||
t.equal(called, 1)
|
||||
})
|
||||
|
||||
test("permissions: can update user to default", async t => {
|
||||
let called = []
|
||||
let called = 0
|
||||
const msgs = await fromAsync(_interactEdit({
|
||||
data: {
|
||||
target_id: "1128118177155526666",
|
||||
|
|
@ -221,44 +184,16 @@ test("permissions: can update user to default", async t => {
|
|||
guild_id: "112760669178241024"
|
||||
}, {
|
||||
api: {
|
||||
async getStateEvent(roomID, type, key) {
|
||||
called.push("get power levels")
|
||||
t.equal(type, "m.room.power_levels")
|
||||
return {
|
||||
users: {"@cadence:cadence.moe": 50}
|
||||
}
|
||||
},
|
||||
async getStateEventOuter(roomID, type, key) {
|
||||
called.push("get room create")
|
||||
return {
|
||||
type: "m.room.create",
|
||||
state_key: "",
|
||||
sender: "@_ooye_bot:cadence.moe",
|
||||
event_id: "$create",
|
||||
origin_server_ts: 0,
|
||||
room_id: roomID,
|
||||
content: {
|
||||
room_version: "11"
|
||||
}
|
||||
}
|
||||
},
|
||||
async *generateFullHierarchy(spaceID) {
|
||||
called.push("generate full hierarchy")
|
||||
},
|
||||
async sendState(roomID, type, key, content) {
|
||||
called.push("set power levels")
|
||||
t.ok(["!hierarchy", "!jjmvBegULiLucuWEHU:cadence.moe"].includes(roomID), `expected room ID to be in hierarchy, but was ${roomID}`)
|
||||
t.equal(type, "m.room.power_levels")
|
||||
t.equal(key, "")
|
||||
t.deepEqual(content, {
|
||||
users: {}
|
||||
})
|
||||
return "$updated"
|
||||
async setUserPowerCascade(roomID, mxid, power) {
|
||||
called++
|
||||
t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe") // space ID
|
||||
t.equal(mxid, "@cadence:cadence.moe")
|
||||
t.equal(power, 0)
|
||||
}
|
||||
}
|
||||
}))
|
||||
t.equal(msgs.length, 2)
|
||||
t.equal(msgs[0].createInteractionResponse.data.content, "Updating `@cadence:cadence.moe` to **default**, please wait...")
|
||||
t.equal(msgs[1].editOriginalInteractionResponse.content, "Updated `@cadence:cadence.moe` to **default**.")
|
||||
t.deepEqual(called, ["generate full hierarchy", "get room create", "get power levels", "set power levels"])
|
||||
t.equal(called, 1)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,199 +0,0 @@
|
|||
// @ts-check
|
||||
|
||||
const assert = require("assert").strict
|
||||
const Ty = require("../../types")
|
||||
const DiscordTypes = require("discord-api-types/v10")
|
||||
const {discord, sync, select, from} = require("../../passthrough")
|
||||
const {id: botID} = require("../../../addbot")
|
||||
const {InteractionMethods} = require("snowtransfer")
|
||||
|
||||
/** @type {import("../../matrix/api")} */
|
||||
const api = sync.require("../../matrix/api")
|
||||
/** @type {import("../../matrix/utils")} */
|
||||
const utils = sync.require("../../matrix/utils")
|
||||
/** @type {import("../../web/routes/guild")} */
|
||||
const webGuild = sync.require("../../web/routes/guild")
|
||||
|
||||
/**
|
||||
* @param {DiscordTypes.APIApplicationCommandAutocompleteGuildInteraction} interaction
|
||||
* @param {{api: typeof api}} di
|
||||
* @returns {AsyncGenerator<{[k in keyof InteractionMethods]?: Parameters<InteractionMethods[k]>[2]}>}
|
||||
*/
|
||||
async function* _interactAutocomplete({data, channel}, {api}) {
|
||||
function exit() {
|
||||
return {createInteractionResponse: {
|
||||
/** @type {DiscordTypes.InteractionResponseType.ApplicationCommandAutocompleteResult} */
|
||||
type: DiscordTypes.InteractionResponseType.ApplicationCommandAutocompleteResult,
|
||||
data: {
|
||||
choices: []
|
||||
}
|
||||
}}
|
||||
}
|
||||
|
||||
// Check it was used in a bridged channel
|
||||
const roomID = select("channel_room", "room_id", {channel_id: channel?.id}).pluck().get()
|
||||
if (!roomID) return yield exit()
|
||||
|
||||
// Check we are in fact autocompleting the first option, the user
|
||||
if (!data.options?.[0] || data.options[0].type !== DiscordTypes.ApplicationCommandOptionType.String || !data.options[0].focused) {
|
||||
return yield exit()
|
||||
}
|
||||
|
||||
/** @type {{displayname: string | null, mxid: string}[][]} */
|
||||
const providedMatches = []
|
||||
|
||||
const input = data.options[0].value
|
||||
if (input === "") {
|
||||
const events = await api.getEvents(roomID, "b", {limit: 40})
|
||||
const recents = new Set(events.chunk.map(e => e.sender))
|
||||
const matches = select("member_cache", ["mxid", "displayname"], {room_id: roomID}, "AND displayname IS NOT NULL LIMIT 25").all()
|
||||
matches.sort((a, b) => +recents.has(b.mxid) - +recents.has(a.mxid))
|
||||
providedMatches.push(matches)
|
||||
} else if (input.startsWith("@")) { // only autocomplete mxids
|
||||
const query = input.replaceAll(/[%_$]/g, char => `$${char}`) + "%"
|
||||
const matches = select("member_cache", ["mxid", "displayname"], {room_id: roomID}, "AND mxid LIKE ? ESCAPE '$' LIMIT 25").all(query)
|
||||
providedMatches.push(matches)
|
||||
} else {
|
||||
const query = "%" + input.replaceAll(/[%_$]/g, char => `$${char}`) + "%"
|
||||
const displaynameMatches = select("member_cache", ["mxid", "displayname"], {room_id: roomID}, "AND displayname IS NOT NULL AND displayname LIKE ? ESCAPE '$' LIMIT 25").all(query)
|
||||
// prioritise matches closer to the start
|
||||
displaynameMatches.sort((a, b) => {
|
||||
let ai = a.displayname?.toLowerCase().indexOf(input.toLowerCase()) ?? -1
|
||||
if (ai === -1) ai = 999
|
||||
let bi = b.displayname?.toLowerCase().indexOf(input.toLowerCase()) ?? -1
|
||||
if (bi === -1) bi = 999
|
||||
return ai - bi
|
||||
})
|
||||
providedMatches.push(displaynameMatches)
|
||||
let mxidMatches = select("member_cache", ["mxid", "displayname"], {room_id: roomID}, "AND displayname IS NOT NULL AND mxid LIKE ? ESCAPE '$' LIMIT 25").all(query)
|
||||
mxidMatches = mxidMatches.filter(match => {
|
||||
// don't include matches in domain part of mxid
|
||||
if (!match.mxid.match(/^[^:]*/)?.includes(query)) return false
|
||||
if (displaynameMatches.some(m => m.mxid === match.mxid)) return false
|
||||
return true
|
||||
})
|
||||
providedMatches.push(mxidMatches)
|
||||
}
|
||||
|
||||
// merge together
|
||||
let matches = providedMatches.flat()
|
||||
|
||||
// don't include bot
|
||||
matches = matches.filter(m => m.mxid !== utils.bot)
|
||||
|
||||
// remove duplicates and count up to 25
|
||||
const limitedMatches = []
|
||||
const seen = new Set()
|
||||
for (const match of matches) {
|
||||
if (limitedMatches.length >= 25) break
|
||||
if (seen.has(match.mxid)) continue
|
||||
limitedMatches.push(match)
|
||||
seen.add(match.mxid)
|
||||
}
|
||||
|
||||
yield {createInteractionResponse: {
|
||||
type: DiscordTypes.InteractionResponseType.ApplicationCommandAutocompleteResult,
|
||||
data: {
|
||||
choices: limitedMatches.map(row => ({name: (row.displayname || row.mxid).slice(0, 100), value: row.mxid.slice(0, 100)}))
|
||||
}
|
||||
}}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DiscordTypes.APIChatInputApplicationCommandGuildInteraction & {channel: DiscordTypes.APIGuildTextChannel}} interaction
|
||||
* @param {{api: typeof api}} di
|
||||
* @returns {AsyncGenerator<{[k in keyof InteractionMethods]?: Parameters<InteractionMethods[k]>[2]}>}
|
||||
*/
|
||||
async function* _interactCommand({data, channel, guild_id}, {api}) {
|
||||
const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get()
|
||||
if (!roomID) {
|
||||
return yield {createInteractionResponse: {
|
||||
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
|
||||
data: {
|
||||
flags: DiscordTypes.MessageFlags.Ephemeral,
|
||||
content: "This channel isn't bridged to Matrix."
|
||||
}
|
||||
}}
|
||||
}
|
||||
|
||||
assert(data.options?.[0]?.type === DiscordTypes.ApplicationCommandOptionType.String)
|
||||
const mxid = data.options[0].value
|
||||
if (!mxid.match(/^@[^:]*:./)) {
|
||||
return yield {createInteractionResponse: {
|
||||
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
|
||||
data: {
|
||||
flags: DiscordTypes.MessageFlags.Ephemeral,
|
||||
content: "⚠️ To use `/ping`, you must select an option from autocomplete, or type a full Matrix ID.\n> Tip: This command is not necessary. You can also ping Matrix users just by typing @their name in your message. It won't look like anything, but it does go through."
|
||||
}
|
||||
}}
|
||||
}
|
||||
|
||||
yield {createInteractionResponse: {
|
||||
type: DiscordTypes.InteractionResponseType.DeferredChannelMessageWithSource
|
||||
}}
|
||||
|
||||
let member
|
||||
try {
|
||||
/** @type {Ty.Event.M_Room_Member} */
|
||||
member = await api.getStateEvent(roomID, "m.room.member", mxid)
|
||||
} catch (e) {}
|
||||
|
||||
if (!member || member.membership !== "join") {
|
||||
const channelsInGuild = discord.guildChannelMap.get(guild_id)
|
||||
assert(channelsInGuild)
|
||||
const inChannels = channelsInGuild
|
||||
// @ts-ignore
|
||||
.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}).get())
|
||||
if (inChannels.length) {
|
||||
return yield {editOriginalInteractionResponse: {
|
||||
content: `That person isn't in this channel. They have only joined the following channels:\n${inChannels.map(c => `<#${c.id}>`).join(" • ")}\nYou can ask them to join this channel with \`/invite\`.`,
|
||||
}}
|
||||
} else {
|
||||
return yield {editOriginalInteractionResponse: {
|
||||
content: "That person isn't in this channel. You can invite them with `/invite`."
|
||||
}}
|
||||
}
|
||||
}
|
||||
|
||||
yield {editOriginalInteractionResponse: {
|
||||
content: "@" + (member.displayname || mxid)
|
||||
}}
|
||||
|
||||
yield {createFollowupMessage: {
|
||||
flags: DiscordTypes.MessageFlags.Ephemeral | DiscordTypes.MessageFlags.IsComponentsV2,
|
||||
components: [{
|
||||
type: DiscordTypes.ComponentType.Container,
|
||||
components: [{
|
||||
type: DiscordTypes.ComponentType.TextDisplay,
|
||||
content: "Tip: This command is not necessary. You can also ping Matrix users just by typing @their name in your message. It won't look like anything, but it does go through."
|
||||
}]
|
||||
}]
|
||||
}}
|
||||
}
|
||||
|
||||
/* c8 ignore start */
|
||||
|
||||
/** @param {(DiscordTypes.APIChatInputApplicationCommandGuildInteraction & {channel: DiscordTypes.APIGuildTextChannel}) | DiscordTypes.APIApplicationCommandAutocompleteGuildInteraction} interaction */
|
||||
async function interact(interaction) {
|
||||
if (interaction.type === DiscordTypes.InteractionType.ApplicationCommandAutocomplete) {
|
||||
for await (const response of _interactAutocomplete(interaction, {api})) {
|
||||
if (response.createInteractionResponse) {
|
||||
await discord.snow.interaction.createInteractionResponse(interaction.id, interaction.token, response.createInteractionResponse)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for await (const response of _interactCommand(interaction, {api})) {
|
||||
if (response.createInteractionResponse) {
|
||||
await discord.snow.interaction.createInteractionResponse(interaction.id, interaction.token, response.createInteractionResponse)
|
||||
} else if (response.editOriginalInteractionResponse) {
|
||||
await discord.snow.interaction.editOriginalInteractionResponse(botID, interaction.token, response.editOriginalInteractionResponse)
|
||||
} else if (response.createFollowupMessage) {
|
||||
await discord.snow.interaction.createFollowupMessage(botID, interaction.token, response.createFollowupMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports.interact = interact
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
// @ts-check
|
||||
|
||||
const DiscordTypes = require("discord-api-types/v10")
|
||||
const {discord, sync, db, select, from} = require("../../passthrough")
|
||||
const {id: botID} = require("../../../addbot")
|
||||
const {InteractionMethods} = require("snowtransfer")
|
||||
|
||||
/** @type {import("../../matrix/api")} */
|
||||
const api = sync.require("../../matrix/api")
|
||||
/** @type {import("../../m2d/converters/poll-components")} */
|
||||
const pollComponents = sync.require("../../m2d/converters/poll-components")
|
||||
const {reg} = require("../../matrix/read-registration")
|
||||
|
||||
/**
|
||||
* @param {number} percent
|
||||
*/
|
||||
function barChart(percent) {
|
||||
const width = 12
|
||||
const bars = Math.floor(percent*width)
|
||||
return "█".repeat(bars) + "▒".repeat(width-bars)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} pollMessageID
|
||||
* @param {boolean} isClosed
|
||||
*/
|
||||
function getCombinedResults(pollMessageID, isClosed) {
|
||||
/** @type {{matrix_option: string, option_text: string, count: number}[]} */
|
||||
const pollResults = db.prepare("SELECT matrix_option, option_text, seq, count(discord_or_matrix_user_id) as count FROM poll_option LEFT JOIN poll_vote USING (message_id, matrix_option) WHERE message_id = ? GROUP BY matrix_option ORDER BY seq").all(pollMessageID)
|
||||
const combinedVotes = pollResults.reduce((a, c) => a + c.count, 0)
|
||||
const totalVoters = db.prepare("SELECT count(DISTINCT discord_or_matrix_user_id) as count FROM poll_vote WHERE message_id = ?").pluck().get(pollMessageID)
|
||||
const topAnswers = pollResults.toSorted((a, b) => b.count - a.count)
|
||||
|
||||
let messageString = ""
|
||||
for (const option of pollResults) {
|
||||
const medal = isClosed ? pollComponents.getMedal(topAnswers, option.count) : ""
|
||||
const countString = `${String(option.count).padStart(String(topAnswers[0].count).length)}`
|
||||
const votesString = option.count === 1 ? "vote " : "votes"
|
||||
const label = medal === "🥇" ? `**${option.option_text}**` : option.option_text
|
||||
messageString += `\`\u200b${countString} ${votesString}\u200b\` ${barChart(option.count/totalVoters)} ${label} ${medal}\n`
|
||||
}
|
||||
|
||||
return {messageString, combinedVotes, totalVoters}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DiscordTypes.APIMessageApplicationCommandGuildInteraction} interaction
|
||||
* @param {{api: typeof api}} di
|
||||
* @returns {AsyncGenerator<{[k in keyof InteractionMethods]?: Parameters<InteractionMethods[k]>[2]}>}
|
||||
*/
|
||||
async function* _interact({data}, {api}) {
|
||||
const row = select("poll", "is_closed", {message_id: data.target_id}).get()
|
||||
|
||||
if (!row) {
|
||||
return yield {createInteractionResponse: {
|
||||
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
|
||||
data: {
|
||||
content: "This poll hasn't been bridged to Matrix.",
|
||||
flags: DiscordTypes.MessageFlags.Ephemeral
|
||||
}
|
||||
}}
|
||||
}
|
||||
|
||||
const {messageString} = getCombinedResults(data.target_id, !!row.is_closed)
|
||||
|
||||
return yield {createInteractionResponse: {
|
||||
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
|
||||
data: {
|
||||
embeds: [{
|
||||
author: {
|
||||
name: "Current results including Matrix votes",
|
||||
icon_url: `${reg.ooye.bridge_origin}/download/file/poll-star-avatar.png`
|
||||
},
|
||||
description: messageString
|
||||
}],
|
||||
flags: DiscordTypes.MessageFlags.Ephemeral
|
||||
}
|
||||
}}
|
||||
}
|
||||
|
||||
/* c8 ignore start */
|
||||
|
||||
/** @param {DiscordTypes.APIMessageApplicationCommandGuildInteraction} interaction */
|
||||
async function interact(interaction) {
|
||||
for await (const response of _interact(interaction, {api})) {
|
||||
if (response.createInteractionResponse) {
|
||||
await discord.snow.interaction.createInteractionResponse(interaction.id, interaction.token, response.createInteractionResponse)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports.interact = interact
|
||||
module.exports._interact = _interact
|
||||
module.exports.getCombinedResults = getCombinedResults
|
||||
|
|
@ -1,144 +0,0 @@
|
|||
// @ts-check
|
||||
|
||||
const DiscordTypes = require("discord-api-types/v10")
|
||||
const {discord, sync, select, from, db} = require("../../passthrough")
|
||||
const assert = require("assert/strict")
|
||||
const {id: botID} = require("../../../addbot")
|
||||
const {InteractionMethods} = require("snowtransfer")
|
||||
|
||||
/** @type {import("../../matrix/api")} */
|
||||
const api = sync.require("../../matrix/api")
|
||||
/** @type {import("../../matrix/utils")} */
|
||||
const utils = sync.require("../../matrix/utils")
|
||||
/** @type {import("../../m2d/converters/poll-components")} */
|
||||
const pollComponents = sync.require("../../m2d/converters/poll-components")
|
||||
/** @type {import("../../d2m/actions/poll-vote")} */
|
||||
const vote = sync.require("../../d2m/actions/poll-vote")
|
||||
|
||||
/**
|
||||
* @param {DiscordTypes.APIMessageComponentButtonInteraction} interaction
|
||||
* @param {{api: typeof api}} di
|
||||
* @returns {AsyncGenerator<{[k in keyof InteractionMethods]?: Parameters<InteractionMethods[k]>[2]}>}
|
||||
*/
|
||||
async function* _interact({data, message, member, user}, {api}) {
|
||||
if (!member?.user) return
|
||||
const userID = member.user.id
|
||||
|
||||
const pollRow = select("poll", ["question_text", "max_selections"], {message_id: message.id}).get()
|
||||
if (!pollRow) return
|
||||
|
||||
// Definitely supposed to be a poll button click. We can use assertions now.
|
||||
|
||||
const matrixPollEvent = select("event_message", "event_id", {message_id: message.id}).pluck().get()
|
||||
assert(matrixPollEvent)
|
||||
|
||||
const maxSelections = pollRow.max_selections
|
||||
const alreadySelected = select("poll_vote", "matrix_option", {discord_or_matrix_user_id: userID, message_id: message.id}).pluck().all()
|
||||
|
||||
// Show modal (if no capacity or if requested)
|
||||
if (data.custom_id === "POLL_VOTE" || (maxSelections > 1 && alreadySelected.length === maxSelections)) {
|
||||
const options = select("poll_option", ["matrix_option", "option_text", "seq"], {message_id: message.id}, "ORDER BY seq").all().map(option => ({
|
||||
value: option.matrix_option,
|
||||
label: option.option_text,
|
||||
default: alreadySelected.includes(option.matrix_option)
|
||||
}))
|
||||
const checkboxGroupExtras = maxSelections === 1 && options.length > 1 ? {} : {
|
||||
/** @type {DiscordTypes.ComponentType.CheckboxGroup} */
|
||||
type: DiscordTypes.ComponentType.CheckboxGroup,
|
||||
min_values: 0,
|
||||
max_values: maxSelections
|
||||
}
|
||||
return yield {createInteractionResponse: {
|
||||
type: DiscordTypes.InteractionResponseType.Modal,
|
||||
data: {
|
||||
custom_id: "POLL_MODAL",
|
||||
title: "Poll",
|
||||
components: [{
|
||||
type: DiscordTypes.ComponentType.TextDisplay,
|
||||
content: `-# ${pollComponents.getMultiSelectString(pollRow.max_selections, options.length)}`
|
||||
}, {
|
||||
type: DiscordTypes.ComponentType.Label,
|
||||
label: pollRow.question_text,
|
||||
component: {
|
||||
type: DiscordTypes.ComponentType.RadioGroup,
|
||||
custom_id: "POLL_MODAL_SELECTION",
|
||||
options,
|
||||
required: false,
|
||||
...checkboxGroupExtras
|
||||
}
|
||||
}]
|
||||
}
|
||||
}}
|
||||
}
|
||||
|
||||
if (data.custom_id === "POLL_MODAL") {
|
||||
// Clicked options via modal
|
||||
/** @type {DiscordTypes.APIModalSubmitRadioGroupComponent | DiscordTypes.APIModalSubmitCheckboxGroupComponent} */ // @ts-ignore - close enough to the real thing
|
||||
const component = data.components[1].component
|
||||
assert.equal(component.custom_id, "POLL_MODAL_SELECTION")
|
||||
const values = "values" in component ? component.values : [component.value]
|
||||
|
||||
// Replace votes with selection
|
||||
db.transaction(() => {
|
||||
db.prepare("DELETE FROM poll_vote WHERE message_id = ? AND discord_or_matrix_user_id = ?").run(message.id, userID)
|
||||
for (const option of values) {
|
||||
db.prepare("INSERT OR IGNORE INTO poll_vote (discord_or_matrix_user_id, message_id, matrix_option) VALUES (?, ?, ?)").run(userID, message.id, option)
|
||||
}
|
||||
})()
|
||||
|
||||
// Update counts on message
|
||||
yield {createInteractionResponse: {
|
||||
type: DiscordTypes.InteractionResponseType.UpdateMessage,
|
||||
data: pollComponents.getPollComponentsFromDatabase(message.id)
|
||||
}}
|
||||
|
||||
// Sync changes to Matrix
|
||||
await vote.sendVotes(member.user, message.channel_id, message.id, matrixPollEvent)
|
||||
} else {
|
||||
// Clicked buttons on message
|
||||
const optionPrefix = "POLL_OPTION#" // we use a prefix to prevent someone from sending a Matrix poll that intentionally collides with other elements of the embed
|
||||
const matrixOption = select("poll_option", "matrix_option", {matrix_option: data.custom_id.substring(optionPrefix.length), message_id: message.id}).pluck().get()
|
||||
assert(matrixOption)
|
||||
|
||||
// Remove a vote
|
||||
if (alreadySelected.includes(matrixOption)) {
|
||||
db.prepare("DELETE FROM poll_vote WHERE discord_or_matrix_user_id = ? AND message_id = ? AND matrix_option = ?").run(userID, message.id, matrixOption)
|
||||
}
|
||||
// Replace votes (if only one selection is allowed)
|
||||
else if (maxSelections === 1 && alreadySelected.length === 1) {
|
||||
db.transaction(() => {
|
||||
db.prepare("DELETE FROM poll_vote WHERE message_id = ? AND discord_or_matrix_user_id = ?").run(message.id, userID)
|
||||
db.prepare("INSERT OR IGNORE INTO poll_vote (discord_or_matrix_user_id, message_id, matrix_option) VALUES (?, ?, ?)").run(userID, message.id, matrixOption)
|
||||
})()
|
||||
}
|
||||
// Add a vote (if capacity)
|
||||
else if (alreadySelected.length < maxSelections) {
|
||||
db.prepare("INSERT OR IGNORE INTO poll_vote (discord_or_matrix_user_id, message_id, matrix_option) VALUES (?, ?, ?)").run(userID, message.id, matrixOption)
|
||||
}
|
||||
|
||||
// Update counts on message
|
||||
yield {createInteractionResponse: {
|
||||
type: DiscordTypes.InteractionResponseType.UpdateMessage,
|
||||
data: pollComponents.getPollComponentsFromDatabase(message.id)
|
||||
}}
|
||||
|
||||
// Sync changes to Matrix
|
||||
await vote.sendVotes(member.user, message.channel_id, message.id, matrixPollEvent)
|
||||
}
|
||||
}
|
||||
|
||||
/* c8 ignore start */
|
||||
|
||||
/** @param {DiscordTypes.APIMessageComponentButtonInteraction} interaction */
|
||||
async function interact(interaction) {
|
||||
for await (const response of _interact(interaction, {api})) {
|
||||
if (response.createInteractionResponse) {
|
||||
await discord.snow.interaction.createInteractionResponse(interaction.id, interaction.token, response.createInteractionResponse)
|
||||
} else if (response.editOriginalInteractionResponse) {
|
||||
await discord.snow.interaction.editOriginalInteractionResponse(botID, interaction.token, response.editOriginalInteractionResponse)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports.interact = interact
|
||||
module.exports._interact = _interact
|
||||
|
|
@ -7,8 +7,8 @@ const {InteractionMethods} = require("snowtransfer")
|
|||
|
||||
/** @type {import("../../matrix/api")} */
|
||||
const api = sync.require("../../matrix/api")
|
||||
/** @type {import("../../matrix/utils")} */
|
||||
const utils = sync.require("../../matrix/utils")
|
||||
/** @type {import("../../m2d/converters/utils")} */
|
||||
const utils = sync.require("../../m2d/converters/utils")
|
||||
|
||||
/**
|
||||
* @param {DiscordTypes.APIMessageApplicationCommandGuildInteraction} interaction
|
||||
|
|
@ -16,7 +16,7 @@ const utils = sync.require("../../matrix/utils")
|
|||
* @returns {AsyncGenerator<{[k in keyof InteractionMethods]?: Parameters<InteractionMethods[k]>[2]}>}
|
||||
*/
|
||||
async function* _interact({data}, {api}) {
|
||||
const row = from("event_message").join("message_room", "message_id").join("historical_channel_room", "historical_room_index")
|
||||
const row = from("event_message").join("message_channel", "message_id").join("channel_room", "channel_id")
|
||||
.select("event_id", "room_id").where({message_id: data.target_id}).get()
|
||||
if (!row) {
|
||||
return yield {createInteractionResponse: {
|
||||
|
|
|
|||
|
|
@ -9,122 +9,84 @@ const invite = sync.require("./interactions/invite.js")
|
|||
const permissions = sync.require("./interactions/permissions.js")
|
||||
const reactions = sync.require("./interactions/reactions.js")
|
||||
const privacy = sync.require("./interactions/privacy.js")
|
||||
const poll = sync.require("./interactions/poll.js")
|
||||
const pollResponses = sync.require("./interactions/poll-responses.js")
|
||||
const ping = sync.require("./interactions/ping.js")
|
||||
|
||||
// User must have EVERY permission in default_member_permissions to be able to use the command
|
||||
|
||||
function registerInteractions() {
|
||||
discord.snow.interaction.bulkOverwriteApplicationCommands(id, [{
|
||||
name: "Matrix info",
|
||||
contexts: [DiscordTypes.InteractionContextType.Guild],
|
||||
type: DiscordTypes.ApplicationCommandType.Message,
|
||||
}, {
|
||||
name: "Permissions",
|
||||
contexts: [DiscordTypes.InteractionContextType.Guild],
|
||||
type: DiscordTypes.ApplicationCommandType.Message,
|
||||
default_member_permissions: String(DiscordTypes.PermissionFlagsBits.KickMembers | DiscordTypes.PermissionFlagsBits.ManageRoles)
|
||||
}, {
|
||||
name: "Responses",
|
||||
contexts: [DiscordTypes.InteractionContextType.Guild],
|
||||
type: DiscordTypes.ApplicationCommandType.Message
|
||||
}, {
|
||||
name: "invite",
|
||||
contexts: [DiscordTypes.InteractionContextType.Guild],
|
||||
type: DiscordTypes.ApplicationCommandType.ChatInput,
|
||||
description: "Invite a Matrix user to this Discord server",
|
||||
default_member_permissions: String(DiscordTypes.PermissionFlagsBits.CreateInstantInvite),
|
||||
options: [
|
||||
{
|
||||
type: DiscordTypes.ApplicationCommandOptionType.String,
|
||||
description: "The Matrix user to invite, e.g. @username:example.org",
|
||||
name: "user"
|
||||
}
|
||||
],
|
||||
}, {
|
||||
name: "ping",
|
||||
contexts: [DiscordTypes.InteractionContextType.Guild],
|
||||
type: DiscordTypes.ApplicationCommandType.ChatInput,
|
||||
description: "Ping a Matrix user.",
|
||||
options: [
|
||||
{
|
||||
type: DiscordTypes.ApplicationCommandOptionType.String,
|
||||
description: "Display name or ID of the Matrix user",
|
||||
name: "user",
|
||||
autocomplete: true,
|
||||
required: true
|
||||
}
|
||||
]
|
||||
}, {
|
||||
name: "privacy",
|
||||
contexts: [DiscordTypes.InteractionContextType.Guild],
|
||||
type: DiscordTypes.ApplicationCommandType.ChatInput,
|
||||
description: "Change whether Matrix users can join through direct invites, links, or the public directory.",
|
||||
default_member_permissions: String(DiscordTypes.PermissionFlagsBits.ManageGuild),
|
||||
options: [
|
||||
{
|
||||
type: DiscordTypes.ApplicationCommandOptionType.String,
|
||||
description: "Check or set the new privacy level",
|
||||
name: "level",
|
||||
choices: [{
|
||||
name: "❓️ Check the current privacy level and get more information.",
|
||||
value: "info"
|
||||
}, {
|
||||
name: "🤝 Only allow joining with a direct in-app invite from another user. No shareable invite links.",
|
||||
value: "invite"
|
||||
}, {
|
||||
name: "🔗 Matrix links can be created and shared like Discord's invite links. In-app invites still work.",
|
||||
value: "link",
|
||||
}, {
|
||||
name: "🌏️ Publicly visible in the Matrix directory, like Server Discovery. Invites and links still work.",
|
||||
value: "directory"
|
||||
}]
|
||||
}
|
||||
]
|
||||
}]).catch(e => {
|
||||
console.error(e)
|
||||
})
|
||||
}
|
||||
discord.snow.interaction.bulkOverwriteApplicationCommands(id, [{
|
||||
name: "Matrix info",
|
||||
contexts: [DiscordTypes.InteractionContextType.Guild],
|
||||
type: DiscordTypes.ApplicationCommandType.Message,
|
||||
}, {
|
||||
name: "Permissions",
|
||||
contexts: [DiscordTypes.InteractionContextType.Guild],
|
||||
type: DiscordTypes.ApplicationCommandType.Message,
|
||||
default_member_permissions: String(DiscordTypes.PermissionFlagsBits.KickMembers | DiscordTypes.PermissionFlagsBits.ManageRoles)
|
||||
}, {
|
||||
name: "Reactions",
|
||||
contexts: [DiscordTypes.InteractionContextType.Guild],
|
||||
type: DiscordTypes.ApplicationCommandType.Message
|
||||
}, {
|
||||
name: "invite",
|
||||
contexts: [DiscordTypes.InteractionContextType.Guild],
|
||||
type: DiscordTypes.ApplicationCommandType.ChatInput,
|
||||
description: "Invite a Matrix user to this Discord server",
|
||||
default_member_permissions: String(DiscordTypes.PermissionFlagsBits.CreateInstantInvite),
|
||||
options: [
|
||||
{
|
||||
type: DiscordTypes.ApplicationCommandOptionType.String,
|
||||
description: "The Matrix user to invite, e.g. @username:example.org",
|
||||
name: "user"
|
||||
}
|
||||
]
|
||||
}, {
|
||||
name: "privacy",
|
||||
contexts: [DiscordTypes.InteractionContextType.Guild],
|
||||
type: DiscordTypes.ApplicationCommandType.ChatInput,
|
||||
description: "Change whether Matrix users can join through direct invites, links, or the public directory.",
|
||||
default_member_permissions: String(DiscordTypes.PermissionFlagsBits.ManageGuild),
|
||||
options: [
|
||||
{
|
||||
type: DiscordTypes.ApplicationCommandOptionType.String,
|
||||
description: "Check or set the new privacy level",
|
||||
name: "level",
|
||||
choices: [{
|
||||
name: "❓️ Check the current privacy level and get more information.",
|
||||
value: "info"
|
||||
}, {
|
||||
name: "🤝 Only allow joining with a direct in-app invite from another user. No shareable invite links.",
|
||||
value: "invite"
|
||||
}, {
|
||||
name: "🔗 Matrix links can be created and shared like Discord's invite links. In-app invites still work.",
|
||||
value: "link",
|
||||
}, {
|
||||
name: "🌏️ Publicly visible in the Matrix directory, like Server Discovery. Invites and links still work.",
|
||||
value: "directory"
|
||||
}]
|
||||
}
|
||||
]
|
||||
}]).catch(e => {
|
||||
console.error(e)
|
||||
})
|
||||
|
||||
/** @param {DiscordTypes.APIInteraction} interaction */
|
||||
async function dispatchInteraction(interaction) {
|
||||
const interactionId = interaction.data?.["custom_id"] || interaction.data?.["name"]
|
||||
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}`)
|
||||
}
|
||||
if (interactionId === "Matrix info") {
|
||||
await matrixInfo.interact(interaction)
|
||||
} else if (interactionId === "invite") {
|
||||
await invite.interact(interaction)
|
||||
} else if (interactionId === "invite_channel") {
|
||||
await invite.interactButton(interaction)
|
||||
} else if (interactionId === "Permissions") {
|
||||
await permissions.interact(interaction)
|
||||
} else if (interactionId === "permissions_edit") {
|
||||
await permissions.interactEdit(interaction)
|
||||
} else if (interactionId === "Reactions") {
|
||||
await reactions.interact(interaction)
|
||||
} else if (interactionId === "privacy") {
|
||||
await privacy.interact(interaction)
|
||||
} else {
|
||||
if (interactionId === "Matrix info") {
|
||||
await matrixInfo.interact(interaction)
|
||||
} else if (interactionId === "invite") {
|
||||
await invite.interact(interaction)
|
||||
} else if (interactionId === "invite_channel") {
|
||||
await invite.interactButton(interaction)
|
||||
} else if (interactionId === "Permissions") {
|
||||
await permissions.interact(interaction)
|
||||
} else if (interactionId === "permissions_edit") {
|
||||
await permissions.interactEdit(interaction)
|
||||
} else if (interactionId === "Responses") {
|
||||
/** @type {DiscordTypes.APIMessageApplicationCommandGuildInteraction} */ // @ts-ignore
|
||||
const messageInteraction = interaction
|
||||
if (select("poll", "message_id", {message_id: messageInteraction.data.target_id}).get()) {
|
||||
await pollResponses.interact(messageInteraction)
|
||||
} else {
|
||||
await reactions.interact(messageInteraction)
|
||||
}
|
||||
} else if (interactionId === "ping") {
|
||||
await ping.interact(interaction)
|
||||
} else if (interactionId === "privacy") {
|
||||
await privacy.interact(interaction)
|
||||
} else {
|
||||
throw new Error(`Unknown interaction ${interactionId}`)
|
||||
}
|
||||
throw new Error(`Unknown interaction ${interactionId}`)
|
||||
}
|
||||
} catch (e) {
|
||||
let stackLines = null
|
||||
|
|
@ -135,18 +97,13 @@ async function dispatchInteraction(interaction) {
|
|||
stackLines = stackLines.slice(0, cloudstormLine - 2)
|
||||
}
|
||||
}
|
||||
try {
|
||||
await discord.snow.interaction.createFollowupMessage(id, interaction.token, {
|
||||
content: `Interaction failed: **${interactionId}**`
|
||||
+ `\nError trace:\n\`\`\`\n${stackLines.join("\n")}\`\`\``
|
||||
+ `Interaction data:\n\`\`\`\n${JSON.stringify(interaction.data, null, 2)}\`\`\``,
|
||||
flags: DiscordTypes.MessageFlags.Ephemeral
|
||||
})
|
||||
} catch (_) {
|
||||
throw e
|
||||
}
|
||||
await discord.snow.interaction.createFollowupMessage(id, interaction.token, {
|
||||
content: `Interaction failed: **${interactionId}**`
|
||||
+ `\nError trace:\n\`\`\`\n${stackLines.join("\n")}\`\`\``
|
||||
+ `Interaction data:\n\`\`\`\n${JSON.stringify(interaction.data, null, 2)}\`\`\``,
|
||||
flags: DiscordTypes.MessageFlags.Ephemeral
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
module.exports.dispatchInteraction = dispatchInteraction
|
||||
module.exports.registerInteractions = registerInteractions
|
||||
|
|
|
|||
|
|
@ -15,18 +15,19 @@ require("xxhash-wasm")().then(h => hasher = h)
|
|||
const EPOCH = 1420070400000
|
||||
|
||||
/**
|
||||
* @param {string} guildID
|
||||
* @param {string[]} userRoles
|
||||
* @param {DiscordTypes.APIGuild["roles"]} guildRoles
|
||||
* @param {string} [userID]
|
||||
* @param {DiscordTypes.APIGuildChannel["permission_overwrites"]} [channelOverwrites]
|
||||
*/
|
||||
function getPermissions(guildID, userRoles, guildRoles, userID, channelOverwrites) {
|
||||
function getPermissions(userRoles, guildRoles, userID, channelOverwrites) {
|
||||
let allowed = BigInt(0)
|
||||
let everyoneID
|
||||
// Guild allows
|
||||
for (const role of guildRoles) {
|
||||
if (role.id === guildID) {
|
||||
if (role.name === "@everyone") {
|
||||
allowed |= BigInt(role.permissions)
|
||||
everyoneID = role.id
|
||||
}
|
||||
if (userRoles.includes(role.id)) {
|
||||
allowed |= BigInt(role.permissions)
|
||||
|
|
@ -37,9 +38,9 @@ function getPermissions(guildID, userRoles, guildRoles, userID, channelOverwrite
|
|||
/** @type {((overwrite: Required<DiscordTypes.APIOverwrite>) => any)[]} */
|
||||
const actions = [
|
||||
// Channel @everyone deny
|
||||
overwrite => overwrite.id === guildID && (allowed &= ~BigInt(overwrite.deny)),
|
||||
overwrite => overwrite.id === everyoneID && (allowed &= ~BigInt(overwrite.deny)),
|
||||
// Channel @everyone allow
|
||||
overwrite => overwrite.id === guildID && (allowed |= BigInt(overwrite.allow)),
|
||||
overwrite => overwrite.id === everyoneID && (allowed |= BigInt(overwrite.allow)),
|
||||
// Role deny
|
||||
overwrite => userRoles.includes(overwrite.id) && (allowed &= ~BigInt(overwrite.deny)),
|
||||
// Role allow
|
||||
|
|
@ -153,26 +154,6 @@ function howOldUnbridgedMessage(oldTimestamp, newTimestamp) {
|
|||
return dateDisplay
|
||||
}
|
||||
|
||||
/**
|
||||
* Modifies the input, removing items that don't pass the filter. Returns the items that didn't pass.
|
||||
* @param {T[]} xs
|
||||
* @param {(x: T, i?: number) => any} fn
|
||||
* @template T
|
||||
* @returns T[]
|
||||
*/
|
||||
function filterTo(xs, fn) {
|
||||
/** @type {T[]} */
|
||||
const filtered = []
|
||||
for (let i = xs.length-1; i >= 0; i--) {
|
||||
const x = xs[i]
|
||||
if (!fn(x, i)) {
|
||||
filtered.unshift(x)
|
||||
xs.splice(i, 1)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
module.exports.getPermissions = getPermissions
|
||||
module.exports.hasPermission = hasPermission
|
||||
module.exports.hasSomePermissions = hasSomePermissions
|
||||
|
|
@ -183,4 +164,3 @@ module.exports.snowflakeToTimestampExact = snowflakeToTimestampExact
|
|||
module.exports.timestampToSnowflakeInexact = timestampToSnowflakeInexact
|
||||
module.exports.getPublicUrlForCdn = getPublicUrlForCdn
|
||||
module.exports.howOldUnbridgedMessage = howOldUnbridgedMessage
|
||||
module.exports.filterTo = filterTo
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ test("getPermissions: channel overwrite to allow role works", t => {
|
|||
{ type: 0, id: "1168988246680801360", deny: "0", allow: "1024" },
|
||||
{ type: 1, id: "353373325575323648", deny: "0", allow: "1024" }
|
||||
]
|
||||
const permissions = utils.getPermissions("1154868424724463687", userRoles, guildRoles, userID, overwrites)
|
||||
const permissions = utils.getPermissions(userRoles, guildRoles, userID, overwrites)
|
||||
const want = BigInt(1 << 10 | 1 << 16)
|
||||
t.equal((permissions & want), want)
|
||||
})
|
||||
|
|
@ -140,7 +140,7 @@ test("getPermissions: channel overwrite to allow user works", t => {
|
|||
{ type: 0, id: "1168988246680801360", deny: "0", allow: "1024" },
|
||||
{ type: 1, id: "353373325575323648", deny: "0", allow: "1024" }
|
||||
]
|
||||
const permissions = utils.getPermissions("1154868424724463687", userRoles, guildRoles, userID, overwrites)
|
||||
const permissions = utils.getPermissions(userRoles, guildRoles, userID, overwrites)
|
||||
const want = BigInt(1 << 10 | 1 << 16)
|
||||
t.equal((permissions & want), want)
|
||||
})
|
||||
|
|
@ -180,22 +180,3 @@ test("isEphemeralMessage: doesn't detect normal message", t => {
|
|||
test("getPublicUrlForCdn: no-op on non-discord URL", t => {
|
||||
t.equal(utils.getPublicUrlForCdn("https://cadence.moe"), "https://cadence.moe")
|
||||
})
|
||||
|
||||
test("how old: now", t => {
|
||||
t.equal(utils.howOldUnbridgedMessage(new Date().toISOString(), new Date().toISOString()), "an unbridged message")
|
||||
})
|
||||
|
||||
test("how old: hours", t => {
|
||||
t.equal(utils.howOldUnbridgedMessage("2026-01-01T00:00:00", "2026-01-01T03:10:00"), "a 3-hour-old unbridged message")
|
||||
})
|
||||
|
||||
test("how old: days", t => {
|
||||
t.equal(utils.howOldUnbridgedMessage("2024-01-01", "2025-01-01"), "a 366-day-old unbridged message")
|
||||
})
|
||||
|
||||
test("filterTo: works", t => {
|
||||
const fruit = ["apple", "banana", "apricot"]
|
||||
const rest = utils.filterTo(fruit, f => f[0] === "b")
|
||||
t.deepEqual(fruit, ["banana"])
|
||||
t.deepEqual(rest, ["apple", "apricot"])
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,27 +4,20 @@ const assert = require("assert").strict
|
|||
const Ty = require("../../types")
|
||||
|
||||
const passthrough = require("../../passthrough")
|
||||
const {discord, as, sync, db, select, from} = passthrough
|
||||
/** @type {import("../../matrix/utils")} */
|
||||
const utils = sync.require("../../matrix/utils")
|
||||
const {discord, sync, db, select} = passthrough
|
||||
/** @type {import("../converters/utils")} */
|
||||
const utils = sync.require("../converters/utils")
|
||||
/** @type {import("../converters/emoji")} */
|
||||
const emoji = sync.require("../converters/emoji")
|
||||
/** @type {import("../../d2m/actions/retrigger")} */
|
||||
const retrigger = sync.require("../../d2m/actions/retrigger")
|
||||
|
||||
/**
|
||||
* @param {Ty.Event.Outer<Ty.Event.M_Reaction>} event
|
||||
*/
|
||||
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
|
||||
|
||||
// These will exist because it passed retrigger
|
||||
const row = from("event_message").join("message_room", "message_id").join("historical_channel_room", "historical_room_index")
|
||||
.select("message_id", "reference_channel_id").where({event_id: event.content["m.relates_to"].event_id}).and("ORDER BY reaction_part ASC").get()
|
||||
assert(row)
|
||||
const messageID = row.message_id
|
||||
const channelID = row.reference_channel_id
|
||||
const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get()
|
||||
if (!channelID) return // We just assume the bridge has already been created
|
||||
const messageID = select("event_message", "message_id", {event_id: event.content["m.relates_to"].event_id}, "ORDER BY reaction_part").pluck().get()
|
||||
if (!messageID) return // Nothing can be done if the parent message was never bridged.
|
||||
|
||||
const key = event.content["m.relates_to"].key
|
||||
const discordPreferredEncoding = await emoji.encodeEmoji(key, event.content.shortcode)
|
||||
|
|
@ -42,10 +35,6 @@ async function addReaction(event) {
|
|||
// happens if a matrix user tries to add on to a super reaction
|
||||
return
|
||||
}
|
||||
if (e.message?.includes("Unknown Message")) {
|
||||
// happens under a race condition where a message is deleted after it passes the database check above
|
||||
return
|
||||
}
|
||||
throw e
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,40 +1,24 @@
|
|||
// @ts-check
|
||||
|
||||
const DiscordTypes = require("discord-api-types/v10")
|
||||
const assert = require("assert").strict
|
||||
const Ty = require("../../types")
|
||||
|
||||
const passthrough = require("../../passthrough")
|
||||
const {discord, as, sync, db, select, from} = passthrough
|
||||
/** @type {import("../../matrix/utils")} */
|
||||
const utils = sync.require("../../matrix/utils")
|
||||
/** @type {import("../../d2m/actions/retrigger")} */
|
||||
const retrigger = sync.require("../../d2m/actions/retrigger")
|
||||
const {discord, sync, db, select, from} = passthrough
|
||||
/** @type {import("../converters/utils")} */
|
||||
const utils = sync.require("../converters/utils")
|
||||
|
||||
/**
|
||||
* @param {Ty.Event.Outer_M_Room_Redaction} event
|
||||
*/
|
||||
async function deleteMessage(event) {
|
||||
const rows = from("event_message").join("message_room", "message_id").join("historical_channel_room", "historical_room_index")
|
||||
.select("reference_channel_id", "message_id").where({event_id: event.redacts}).all()
|
||||
const rows = from("event_message").join("message_channel", "message_id").select("channel_id", "message_id").where({event_id: event.redacts}).all()
|
||||
if (!rows.length) return
|
||||
for (const row of rows) {
|
||||
await discord.snow.channel.deleteMessage(row.reference_channel_id, row.message_id, event.content.reason)
|
||||
await discord.snow.channel.deleteMessage(row.channel_id, row.message_id, event.content.reason)
|
||||
db.prepare("DELETE FROM event_message WHERE message_id = ?").run(row.message_id)
|
||||
}
|
||||
db.prepare("DELETE FROM message_room WHERE message_id = ?").run(rows[0].message_id)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Ty.Event.Outer_M_Room_Redaction} event
|
||||
*/
|
||||
async function suppressEmbeds(event) {
|
||||
const rows = from("event_message").join("message_room", "message_id").join("historical_channel_room", "historical_room_index")
|
||||
.select("reference_channel_id", "message_id").where({event_id: event.redacts}).all()
|
||||
if (!rows.length) return
|
||||
db.prepare("DELETE FROM event_message WHERE event_id = ?").run(event.redacts)
|
||||
for (const row of rows) {
|
||||
await discord.snow.channel.editMessage(row.reference_channel_id, row.message_id, {flags: DiscordTypes.MessageFlags.SuppressEmbeds})
|
||||
}
|
||||
db.prepare("DELETE FROM message_channel WHERE message_id = ?").run(rows[0].message_id)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -42,10 +26,9 @@ async function suppressEmbeds(event) {
|
|||
*/
|
||||
async function removeReaction(event) {
|
||||
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()
|
||||
const row = from("reaction").join("message_channel", "message_id").select("channel_id", "message_id", "encoded_emoji").where({hashed_event_id: hash}).get()
|
||||
if (!row) return
|
||||
await discord.snow.channel.deleteReactionSelf(row.reference_channel_id, row.message_id, row.encoded_emoji)
|
||||
await discord.snow.channel.deleteReactionSelf(row.channel_id, row.message_id, row.encoded_emoji)
|
||||
db.prepare("DELETE FROM reaction WHERE hashed_event_id = ?").run(hash)
|
||||
}
|
||||
|
||||
|
|
@ -54,18 +37,8 @@ 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 deleteMessage(event)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports.handle = handle
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ const stream = require("stream")
|
|||
const assert = require("assert").strict
|
||||
const crypto = require("crypto")
|
||||
const passthrough = require("../../passthrough")
|
||||
const {sync, discord, db, from, select} = passthrough
|
||||
const {sync, discord, db, select} = passthrough
|
||||
|
||||
/** @type {import("./channel-webhook")} */
|
||||
const channelWebhook = sync.require("./channel-webhook")
|
||||
|
|
@ -14,8 +14,6 @@ const channelWebhook = sync.require("./channel-webhook")
|
|||
const eventToMessage = sync.require("../converters/event-to-message")
|
||||
/** @type {import("../../matrix/api")}) */
|
||||
const api = sync.require("../../matrix/api")
|
||||
/** @type {import("../../matrix/utils")}) */
|
||||
const utils = sync.require("../../matrix/utils")
|
||||
/** @type {import("../../d2m/actions/register-user")} */
|
||||
const registerUser = sync.require("../../d2m/actions/register-user")
|
||||
/** @type {import("../../d2m/actions/edit-message")} */
|
||||
|
|
@ -61,9 +59,9 @@ async function resolvePendingFiles(message) {
|
|||
return newMessage
|
||||
}
|
||||
|
||||
/** @param {Ty.Event.Outer_M_Room_Message | Ty.Event.Outer_M_Room_Message_File | Ty.Event.Outer_M_Sticker | Ty.Event.Outer_Org_Matrix_Msc3381_Poll_Start | Ty.Event.Outer_Org_Matrix_Msc3381_Poll_End} event */
|
||||
/** @param {Ty.Event.Outer_M_Room_Message | Ty.Event.Outer_M_Room_Message_File | Ty.Event.Outer_M_Sticker} event */
|
||||
async function sendEvent(event) {
|
||||
const row = from("channel_room").where({room_id: event.room_id}).select("channel_id", "thread_parent").get()
|
||||
const row = select("channel_room", ["channel_id", "thread_parent"], {room_id: event.room_id}).get()
|
||||
if (!row) return [] // allow the bot to exist in unbridged rooms, just don't do anything with it
|
||||
let channelID = row.channel_id
|
||||
let threadID = undefined
|
||||
|
|
@ -71,29 +69,14 @@ async function sendEvent(event) {
|
|||
threadID = channelID
|
||||
channelID = row.thread_parent // it's the thread's parent... get with the times...
|
||||
}
|
||||
/** @type {DiscordTypes.APIGuildTextChannel} */ // @ts-ignore
|
||||
const channel = discord.channels.get(channelID)
|
||||
// @ts-ignore
|
||||
const guild = discord.guilds.get(channel.guild_id)
|
||||
const guildID = discord.channels.get(channelID).guild_id
|
||||
const guild = discord.guilds.get(guildID)
|
||||
assert(guild)
|
||||
const historicalRoomIndex = select("historical_channel_room", "historical_room_index", {room_id: event.room_id}).pluck().get()
|
||||
assert(historicalRoomIndex)
|
||||
|
||||
// no need to sync the matrix member to the other side. but if I did need to, this is where I'd do it
|
||||
|
||||
const di = {api, snow: discord.snow, mxcDownloader: emojiSheet.getAndConvertEmoji}
|
||||
|
||||
if (event.type === "org.matrix.msc3381.poll.end") {
|
||||
// Validity already checked by dispatcher. Poll is definitely closed. Update it and DI necessary data.
|
||||
const messageID = select("event_message", "message_id", {event_id: event.content["m.relates_to"].event_id, event_type: "org.matrix.msc3381.poll.start", source: 0}).pluck().get()
|
||||
assert(messageID)
|
||||
db.prepare("UPDATE poll SET is_closed = 1 WHERE message_id = ?").run(messageID)
|
||||
di.pollEnd = {
|
||||
messageID
|
||||
}
|
||||
}
|
||||
|
||||
let {messagesToEdit, messagesToSend, messagesToDelete, ensureJoined} = await eventToMessage.eventToMessage(event, guild, channel, di)
|
||||
let {messagesToEdit, messagesToSend, messagesToDelete, ensureJoined} = await eventToMessage.eventToMessage(event, guild, {api, snow: discord.snow, mxcDownloader: emojiSheet.getAndConvertEmoji})
|
||||
|
||||
messagesToEdit = await Promise.all(messagesToEdit.map(async e => {
|
||||
e.message = await resolvePendingFiles(e.message)
|
||||
|
|
@ -115,25 +98,15 @@ async function sendEvent(event) {
|
|||
}
|
||||
|
||||
for (const id of messagesToDelete) {
|
||||
db.prepare("DELETE FROM message_room WHERE message_id = ?").run(id)
|
||||
db.prepare("DELETE FROM message_channel WHERE message_id = ?").run(id)
|
||||
await channelWebhook.deleteMessageWithWebhook(channelID, id, threadID)
|
||||
}
|
||||
|
||||
// Poll ends do not follow the normal laws of parts.
|
||||
// Normally when editing and adding extra parts, the new parts should always have part = 1 and reaction_part = 1 (because the existing part, which is being edited, already took 0).
|
||||
// However for polls, the edit is actually for a different message. The message being sent is truly a new message, and should have parts = 0.
|
||||
// So in that case, just override these variables to have the right values.
|
||||
if (di.pollEnd) {
|
||||
eventPart = 0
|
||||
}
|
||||
|
||||
for (const message of messagesToSend) {
|
||||
const reactionPart = (messagesToEdit.length === 0 || di.pollEnd) && message === messagesToSend[messagesToSend.length - 1] ? 0 : 1
|
||||
const reactionPart = messagesToEdit.length === 0 && message === messagesToSend[messagesToSend.length - 1] ? 0 : 1
|
||||
const messageResponse = await channelWebhook.sendMessageWithWebhook(channelID, message, threadID)
|
||||
db.transaction(() => {
|
||||
db.prepare("INSERT INTO message_room (message_id, historical_room_index) VALUES (?, ?)").run(messageResponse.id, historicalRoomIndex)
|
||||
db.prepare("INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part, reaction_part, source) VALUES (?, ?, ?, ?, ?, ?, 0)").run(event.event_id, event.type, event.content["msgtype"] || null, messageResponse.id, eventPart, reactionPart) // source 0 = matrix
|
||||
})()
|
||||
db.prepare("INSERT INTO message_channel (message_id, channel_id) VALUES (?, ?)").run(messageResponse.id, threadID || channelID)
|
||||
db.prepare("INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part, reaction_part, source) VALUES (?, ?, ?, ?, ?, ?, 0)").run(event.event_id, event.type, event.content["msgtype"] || null, messageResponse.id, eventPart, reactionPart) // source 0 = matrix
|
||||
|
||||
eventPart = 1
|
||||
messageResponses.push(messageResponse)
|
||||
|
|
@ -158,25 +131,6 @@ async function sendEvent(event) {
|
|||
}
|
||||
}
|
||||
|
||||
if (event.type === "org.matrix.msc3381.poll.start") { // Need to store answer mapping in the database.
|
||||
db.transaction(() => {
|
||||
const messageID = messageResponses[0].id
|
||||
db.prepare("INSERT INTO poll (message_id, max_selections, question_text, is_closed) VALUES (?, ?, ?, 0)").run(
|
||||
messageID,
|
||||
event.content["org.matrix.msc3381.poll.start"].max_selections,
|
||||
event.content["org.matrix.msc3381.poll.start"].question["org.matrix.msc1767.text"]
|
||||
)
|
||||
for (const [i, option] of Object.entries(event.content["org.matrix.msc3381.poll.start"].answers)) {
|
||||
db.prepare("INSERT INTO poll_option (message_id, matrix_option, option_text, seq) VALUES (?, ?, ?, ?)").run(
|
||||
messageID,
|
||||
option.id,
|
||||
option["org.matrix.msc1767.text"],
|
||||
i
|
||||
)
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
for (const user of ensureJoined) {
|
||||
registerUser.ensureSimJoined(user, event.room_id)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ async function setupEmojis() {
|
|||
const {id} = require("../../../addbot")
|
||||
const {discord, db} = passthrough
|
||||
const emojis = await discord.snow.assets.getAppEmojis(id)
|
||||
for (const name of ["L1", "L2", "poll_win"]) {
|
||||
for (const name of ["L1", "L2"]) {
|
||||
const existing = emojis.items.find(e => e.name === name)
|
||||
if (existing) {
|
||||
db.prepare("REPLACE INTO auto_emoji (name, emoji_id) VALUES (?, ?)").run(existing.name, existing.id)
|
||||
|
|
|
|||
|
|
@ -1,40 +0,0 @@
|
|||
// @ts-check
|
||||
|
||||
const {Readable} = require("stream")
|
||||
const {ReadableStream} = require("stream/web")
|
||||
|
||||
const {sync} = require("../../passthrough")
|
||||
const sharp = require("sharp")
|
||||
/** @type {import("../../matrix/api")} */
|
||||
const api = sync.require("../../matrix/api")
|
||||
/** @type {import("../../matrix/mreq")} */
|
||||
const mreq = sync.require("../../matrix/mreq")
|
||||
const streamMimeType = require("stream-mime-type")
|
||||
|
||||
const WIDTH = 160
|
||||
const HEIGHT = 160
|
||||
/**
|
||||
* Downloads the sticker from the web and converts to webp data.
|
||||
* @param {string} mxc a single mxc:// URL
|
||||
* @returns {Promise<ReadableStream>} sticker webp data, or undefined if the downloaded sticker is not valid
|
||||
*/
|
||||
async function getAndResizeSticker(mxc) {
|
||||
const res = await api.getMedia(mxc)
|
||||
if (res.status !== 200) {
|
||||
const root = await res.json()
|
||||
throw new mreq.MatrixServerError(root, {mxc})
|
||||
}
|
||||
|
||||
const streamIn = Readable.fromWeb(res.body)
|
||||
const { stream, mime } = await streamMimeType.getMimeType(streamIn)
|
||||
const animated = ["image/gif", "image/webp"].includes(mime)
|
||||
|
||||
const transformer = sharp({animated: animated})
|
||||
.resize(WIDTH, HEIGHT, {fit: "inside", background: {r: 0, g: 0, b: 0, alpha: 0}})
|
||||
.webp()
|
||||
stream.pipe(transformer)
|
||||
return Readable.toWeb(transformer)
|
||||
}
|
||||
|
||||
|
||||
module.exports.getAndResizeSticker = getAndResizeSticker
|
||||
|
|
@ -12,13 +12,12 @@ const diffPins = sync.require("../converters/diff-pins")
|
|||
async function updatePins(pins, prev) {
|
||||
const diff = diffPins.diffPins(pins, prev)
|
||||
for (const [event_id, added] of diff) {
|
||||
const row = from("event_message").join("message_room", "message_id").join("historical_channel_room", "historical_room_index")
|
||||
.select("reference_channel_id", "message_id").get()
|
||||
const row = from("event_message").join("message_channel", "message_id").where({event_id, part: 0}).select("channel_id", "message_id").get()
|
||||
if (!row) continue
|
||||
if (added) {
|
||||
discord.snow.channel.addChannelPinnedMessage(row.reference_channel_id, row.message_id, "Message pinned on Matrix")
|
||||
discord.snow.channel.addChannelPinnedMessage(row.channel_id, row.message_id, "Message pinned on Matrix")
|
||||
} else {
|
||||
discord.snow.channel.removeChannelPinnedMessage(row.reference_channel_id, row.message_id, "Message unpinned on Matrix")
|
||||
discord.snow.channel.removeChannelPinnedMessage(row.channel_id, row.message_id, "Message unpinned on Matrix")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,42 +0,0 @@
|
|||
// @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 {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("../converters/poll-components")} */
|
||||
const pollComponents = sync.require("../converters/poll-components")
|
||||
/** @type {import("./channel-webhook")} */
|
||||
const webhook = sync.require("./channel-webhook")
|
||||
|
||||
/** @param {Ty.Event.Outer_Org_Matrix_Msc3381_Poll_Response} event */
|
||||
async function updateVote(event) {
|
||||
const messageRow = select("event_message", ["message_id", "source"], {event_id: event.content["m.relates_to"].event_id, event_type: "org.matrix.msc3381.poll.start"}).get()
|
||||
const messageID = messageRow?.message_id
|
||||
if (!messageID) return // Nothing can be done if the parent message was never bridged.
|
||||
|
||||
db.transaction(() => {
|
||||
db.prepare("DELETE FROM poll_vote WHERE discord_or_matrix_user_id = ? AND message_id = ?").run(event.sender, messageID) // Clear all the existing votes, since this overwrites.
|
||||
for (const answer of event.content["org.matrix.msc3381.poll.response"].answers) {
|
||||
db.prepare("INSERT OR IGNORE INTO poll_vote (discord_or_matrix_user_id, message_id, matrix_option) VALUES (?, ?, ?)").run(event.sender, messageID, answer)
|
||||
}
|
||||
})()
|
||||
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
|
||||
module.exports.updateVote = updateVote
|
||||
|
|
@ -50,15 +50,3 @@ test("emoji: spy needs u+fe0f in the middle", async t => {
|
|||
test("emoji: couple needs u+fe0f in the middle", async t => {
|
||||
t.equal(await encodeEmoji("👩❤👩", null), "%F0%9F%91%A9%E2%80%8D%E2%9D%A4%EF%B8%8F%E2%80%8D%F0%9F%91%A9")
|
||||
})
|
||||
|
||||
test("emoji: exact known emojis are returned", async t => {
|
||||
t.equal(await encodeEmoji("mxc://cadence.moe/qWmbXeRspZRLPcjseyLmeyXC", "hippo"), "hippo%3A230201364309868544")
|
||||
})
|
||||
|
||||
test("emoji: inexact emojis are guessed by name", async t => {
|
||||
t.equal(await encodeEmoji("mxc://example.invalid/a", "hippo"), "hippo%3A230201364309868544")
|
||||
})
|
||||
|
||||
test("emoji: unknown custom emoji returns null", async t => {
|
||||
t.equal(await encodeEmoji("mxc://example.invalid/a", "silly"), null)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
// @ts-check
|
||||
/// <reference lib="dom" />
|
||||
|
||||
const Ty = require("../../types")
|
||||
const DiscordTypes = require("discord-api-types/v10")
|
||||
|
|
@ -9,26 +8,20 @@ const TurndownService = require("@cloudrac3r/turndown")
|
|||
const domino = require("domino")
|
||||
const assert = require("assert").strict
|
||||
const entities = require("entities")
|
||||
const pb = require("prettier-bytes")
|
||||
const {tag} = require("@cloudrac3r/html-template-tag")
|
||||
|
||||
const passthrough = require("../../passthrough")
|
||||
const {sync, db, discord, select, from} = passthrough
|
||||
const {reg} = require("../../matrix/read-registration")
|
||||
/** @type {import("../../matrix/utils")} */
|
||||
const mxUtils = sync.require("../../matrix/utils")
|
||||
/** @type {import("../converters/utils")} */
|
||||
const mxUtils = sync.require("../converters/utils")
|
||||
/** @type {import("../../discord/utils")} */
|
||||
const dUtils = sync.require("../../discord/utils")
|
||||
/** @type {import("../../matrix/file")} */
|
||||
const file = sync.require("../../matrix/file")
|
||||
/** @type {import("./emoji-sheet")} */
|
||||
const emojiSheet = sync.require("./emoji-sheet")
|
||||
/** @type {import("./poll-components")} */
|
||||
const pollComponents = sync.require("./poll-components")
|
||||
/** @type {import("../actions/setup-emojis")} */
|
||||
const setupEmojis = sync.require("../actions/setup-emojis")
|
||||
/** @type {import("../../d2m/converters/user-to-mxid")} */
|
||||
const userToMxid = sync.require("../../d2m/converters/user-to-mxid")
|
||||
|
||||
/** @type {[RegExp, string][]} */
|
||||
const markdownEscapes = [
|
||||
|
|
@ -69,7 +62,7 @@ turndownService.escape = function (string) {
|
|||
return string.replace(/\s+|\S+/g, part => { // match chunks of spaces or non-spaces
|
||||
if (part.match(/\s/)) return part // don't process spaces
|
||||
|
||||
if (part.match(/^<?https?:\/\//)) {
|
||||
if (part.match(/^https?:\/\//)) {
|
||||
return part
|
||||
} else {
|
||||
return markdownEscapes.reduce(function (accumulator, escape) {
|
||||
|
|
@ -139,13 +132,10 @@ turndownService.addRule("inlineLink", {
|
|||
if (node.getAttribute("data-message-id")) return `https://discord.com/channels/${node.getAttribute("data-guild-id")}/${node.getAttribute("data-channel-id")}/${node.getAttribute("data-message-id")}`
|
||||
if (node.getAttribute("data-channel-id")) return `<#${node.getAttribute("data-channel-id")}>`
|
||||
const href = node.getAttribute("href")
|
||||
let shouldSuppress = node.hasAttribute("data-suppress")
|
||||
if (href.match(/^https?:\/\/matrix.to\//)) shouldSuppress = false // avoid double-escaping
|
||||
const suppressedHref = shouldSuppress ? "<" + href + ">" : href
|
||||
content = content.replace(/ @.*/, "")
|
||||
if (href === content) return suppressedHref
|
||||
if (href === content) return href
|
||||
if (decodeURIComponent(href).startsWith("https://matrix.to/#/@") && content[0] !== "@") content = "@" + content
|
||||
return "[" + content + "](" + suppressedHref + ")"
|
||||
return "[" + content + "](" + href + ")"
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -290,8 +280,8 @@ function convertEmoji(mxcUrl, nameForGuess, allowSpriteSheetIndicator, allowLink
|
|||
* @returns {Promise<{displayname?: string?, avatar_url?: string?}>}
|
||||
*/
|
||||
async function getMemberFromCacheOrHomeserver(roomID, mxid, api) {
|
||||
const row = select("member_cache", ["displayname", "avatar_url", "missing_profile"], {room_id: roomID, mxid}).get()
|
||||
if (row && !row.missing_profile) return row
|
||||
const row = select("member_cache", ["displayname", "avatar_url"], {room_id: roomID, mxid}).get()
|
||||
if (row) return row
|
||||
return api.getStateEvent(roomID, "m.room.member", mxid).then(event => {
|
||||
const room = select("channel_room", "room_id", {room_id: roomID}).get()
|
||||
if (room) {
|
||||
|
|
@ -299,7 +289,7 @@ async function getMemberFromCacheOrHomeserver(roomID, mxid, api) {
|
|||
// the cache will be kept in sync by the `m.room.member` event listener
|
||||
const displayname = event?.displayname || null
|
||||
const avatar_url = event?.avatar_url || null
|
||||
db.prepare("INSERT INTO member_cache (room_id, mxid, displayname, avatar_url) VALUES (?, ?, ?, ?) ON CONFLICT DO UPDATE SET displayname = ?, avatar_url = ?, missing_profile = NULL").run(
|
||||
db.prepare("INSERT INTO member_cache (room_id, mxid, displayname, avatar_url) VALUES (?, ?, ?, ?) ON CONFLICT DO UPDATE SET displayname = ?, avatar_url = ?").run(
|
||||
roomID, mxid,
|
||||
displayname, avatar_url,
|
||||
displayname, avatar_url
|
||||
|
|
@ -340,48 +330,34 @@ function splitDisplayName(displayName) {
|
|||
* Convert a Matrix user ID into a Discord user ID for mentioning, where if the user is a PK proxy, it will mention the proxy owner.
|
||||
* @param {string} mxid
|
||||
*/
|
||||
function getUserOrProxyOwnerMention(mxid) {
|
||||
const row = from("sim").join("sim_proxy", "user_id", "left").select("user_id", "username", "proxy_owner_id").where({mxid}).get()
|
||||
function getUserOrProxyOwnerID(mxid) {
|
||||
const row = from("sim").join("sim_proxy", "user_id", "left").select("user_id", "proxy_owner_id").where({mxid}).get()
|
||||
if (!row) return null
|
||||
if (userToMxid.isWebhookUserID(row.user_id)) return `**@${row.username}**`
|
||||
return `<@${row.proxy_owner_id || row.user_id}>`
|
||||
return row.proxy_owner_id || row.user_id
|
||||
}
|
||||
|
||||
/**
|
||||
* At the time of this executing, we know what the end of message emojis are, and we know that at least one of them is unknown.
|
||||
* This function will strip them from the content and add a link that Discord will preview with a sprite sheet of emojis.
|
||||
* This function will strip them from the content and generate the correct pending file of the sprite sheet.
|
||||
* @param {string} content
|
||||
* @returns {string} new content with emoji sheet link
|
||||
* @param {{id: string, filename: string}[]} attachments
|
||||
* @param {({name: string, mxc: string} | {name: string, mxc: string, key: string, iv: string} | {name: string, buffer: Buffer})[]} pendingFiles
|
||||
* @param {(mxc: string) => Promise<Buffer | undefined>} mxcDownloader function that will download the mxc URLs and convert to uncompressed PNG data. use `getAndConvertEmoji` or a mock.
|
||||
*/
|
||||
function linkEndOfMessageSpriteSheet(content) {
|
||||
async function uploadEndOfMessageSpriteSheet(content, attachments, pendingFiles, mxcDownloader) {
|
||||
if (!content.includes("<::>")) return content // No unknown emojis, nothing to do
|
||||
// Remove known and unknown emojis from the end of the message
|
||||
const r = /<a?:[a-zA-Z0-9_]*:[0-9]*>\s*$/
|
||||
|
||||
while (content.match(r)) {
|
||||
content = content.replace(r, "")
|
||||
}
|
||||
|
||||
// Use a markdown link to hide the URL. If this is the only thing in the message, Discord will hide it entirely, same as lone URLs. Good for us.
|
||||
content = content.trimEnd()
|
||||
content += " [\u2800](" // U+2800 Braille Pattern Blank is invisible on all known platforms but is digitally not a whitespace character
|
||||
const afterLink = ")"
|
||||
|
||||
// Make emojis URL params
|
||||
const params = new URLSearchParams()
|
||||
for (const mxc of endOfMessageEmojis) {
|
||||
// We can do up to 2000 chars max. (In this maximal case it will get chunked to a separate message.) Ignore additional emojis.
|
||||
const withoutMxc = mxUtils.makeMxcPublic(mxc)
|
||||
assert(withoutMxc)
|
||||
const emojisLength = params.toString().length + encodeURIComponent(withoutMxc).length + 2
|
||||
if (content.length + emojisLength + afterLink.length > 2000) {
|
||||
break
|
||||
}
|
||||
params.append("e", withoutMxc)
|
||||
}
|
||||
|
||||
const url = `${reg.ooye.bridge_origin}/download/sheet?${params.toString()}`
|
||||
return content + url + afterLink
|
||||
// Create a sprite sheet of known and unknown emojis from the end of the message
|
||||
const buffer = await emojiSheet.compositeMatrixEmojis(endOfMessageEmojis, mxcDownloader)
|
||||
// Attach it
|
||||
const filename = "emojis.png"
|
||||
attachments.push({id: String(attachments.length), filename})
|
||||
pendingFiles.push({name: filename, buffer})
|
||||
return content
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -420,7 +396,7 @@ async function handleRoomOrMessageLinks(input, di) {
|
|||
}
|
||||
}
|
||||
|
||||
const channelID = select("historical_channel_room", "reference_channel_id", {room_id: roomID}).pluck().get()
|
||||
const channelID = select("channel_room", "channel_id", {room_id: roomID}).pluck().get()
|
||||
if (!channelID) continue
|
||||
if (!eventID) {
|
||||
// 1: It's a room link, so <#link> to the channel
|
||||
|
|
@ -465,8 +441,9 @@ async function checkWrittenMentions(content, senderMxid, roomID, guild, di) {
|
|||
let writtenMentionMatch = content.match(/(?:^|[^"[<>/A-Za-z0-9])@([A-Za-z][A-Za-z0-9._\[\]\(\)-]+):?/d) // /d flag for indices requires node.js 16+
|
||||
if (writtenMentionMatch) {
|
||||
if (writtenMentionMatch[1] === "room") { // convert @room to @everyone
|
||||
const {powers: {[senderMxid]: userPower}, powerLevels} = await mxUtils.getEffectivePower(roomID, [senderMxid], di.api)
|
||||
if (userPower >= (powerLevels.notifications?.room ?? 50)) {
|
||||
const powerLevels = await di.api.getStateEvent(roomID, "m.room.power_levels", "")
|
||||
const userPower = powerLevels.users?.[senderMxid] || 0
|
||||
if (userPower >= powerLevels.notifications?.room) {
|
||||
return {
|
||||
// @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]),
|
||||
|
|
@ -506,17 +483,6 @@ const attachmentEmojis = new Map([
|
|||
["m.file", "📄"]
|
||||
])
|
||||
|
||||
/** @param {DiscordTypes.APIGuild} guild */
|
||||
function getFileSizeForGuild(guild) {
|
||||
// guild.features may include strings such as MAX_FILE_SIZE_50_MB and MAX_FILE_SIZE_100_MB, which are the current server boost amounts
|
||||
const fileSizeFeature = guild?.features.map(f => Number(f.match(/^MAX_FILE_SIZE_([0-9]+)_MB$/)?.[1])).filter(f => f).sort()[0]
|
||||
if (fileSizeFeature) {
|
||||
return fileSizeFeature * 1024 * 1024 // discord uses big megabytes
|
||||
} else {
|
||||
return 10 * 1024 * 1024 // default file size is 10 MB
|
||||
}
|
||||
}
|
||||
|
||||
async function getL1L2ReplyLine(called = false) {
|
||||
// @ts-ignore
|
||||
const autoEmoji = new Map(select("auto_emoji", ["name", "emoji_id"], {}, "WHERE name = 'L1' OR name = 'L2'").raw().all())
|
||||
|
|
@ -535,12 +501,11 @@ async function getL1L2ReplyLine(called = false) {
|
|||
}
|
||||
|
||||
/**
|
||||
* @param {Ty.Event.Outer_M_Room_Message | Ty.Event.Outer_M_Room_Message_File | Ty.Event.Outer_M_Sticker | Ty.Event.Outer_M_Room_Message_Encrypted_File | Ty.Event.Outer_Org_Matrix_Msc3381_Poll_Start | Ty.Event.Outer_Org_Matrix_Msc3381_Poll_End} event
|
||||
* @param {DiscordTypes.APIGuild} guild
|
||||
* @param {DiscordTypes.APIGuildTextChannel} channel
|
||||
* @param {{api: import("../../matrix/api"), snow: import("snowtransfer").SnowTransfer, pollEnd?: {messageID: string}}} di simple-as-nails dependency injection for the matrix API
|
||||
* @param {Ty.Event.Outer_M_Room_Message | Ty.Event.Outer_M_Room_Message_File | Ty.Event.Outer_M_Sticker | Ty.Event.Outer_M_Room_Message_Encrypted_File} event
|
||||
* @param {import("discord-api-types/v10").APIGuild} guild
|
||||
* @param {{api: import("../../matrix/api"), snow: import("snowtransfer").SnowTransfer, mxcDownloader: (mxc: string) => Promise<Buffer | undefined>}} di simple-as-nails dependency injection for the matrix API
|
||||
*/
|
||||
async function eventToMessage(event, guild, channel, di) {
|
||||
async function eventToMessage(event, guild, di) {
|
||||
let displayName = event.sender
|
||||
let avatarURL = undefined
|
||||
const allowedMentionsParse = ["users", "roles"]
|
||||
|
|
@ -562,104 +527,54 @@ async function eventToMessage(event, guild, channel, di) {
|
|||
displayNameRunoff = ""
|
||||
}
|
||||
|
||||
let content = event.content["body"] || "" // ultimate fallback
|
||||
let content = event.content.body // ultimate fallback
|
||||
/** @type {{id: string, filename: string}[]} */
|
||||
const attachments = []
|
||||
/** @type {({name: string, mxc: string} | {name: string, mxc: string, key: string, iv: string} | {name: string, buffer: Buffer})[]} */
|
||||
const pendingFiles = []
|
||||
/** @type {DiscordTypes.APIUser[]} */
|
||||
const ensureJoined = []
|
||||
/** @type {DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody[]} */
|
||||
const pollMessages = []
|
||||
|
||||
// Convert content depending on what the message is
|
||||
// Handle images first - might need to handle their `body`/`formatted_body` as well, which will fall through to the text processor
|
||||
let shouldProcessTextEvent = event.type === "m.room.message" && (event.content.msgtype === "m.text" || event.content.msgtype === "m.emote")
|
||||
if (event.type === "m.room.message" && (event.content.msgtype === "m.file" || event.content.msgtype === "m.video" || event.content.msgtype === "m.audio" || event.content.msgtype === "m.image")) {
|
||||
// Build message content in addition to the uploaded file
|
||||
const fileIsSpoiler = event.content["page.codeberg.everypizza.msc4193.spoiler"] || event.content["town.robin.msc3725.content_warning"]
|
||||
const fileSpoilerReason = event.content["page.codeberg.everypizza.msc4193.spoiler.reason"] || event.content["town.robin.msc3725.content_warning"]?.description
|
||||
content = ""
|
||||
const captionContent = new mxUtils.MatrixStringBuilder()
|
||||
|
||||
// Caption from Matrix message
|
||||
const fileHasCaption = (event.content.body && event.content.filename && event.content.body !== event.content.filename) || event.content.formatted_body
|
||||
if (fileHasCaption) {
|
||||
captionContent.addLine(event.content.body || "", event.content.formatted_body || tag`${event.content.body || ""}`)
|
||||
}
|
||||
|
||||
// Spoiler message
|
||||
if (fileIsSpoiler && typeof fileSpoilerReason === "string") {
|
||||
captionContent.addLine(`(Spoiler: ${fileSpoilerReason})`)
|
||||
}
|
||||
|
||||
// File link as alternative to uploading
|
||||
if (!("file" in event.content) && event.content.info?.size > getFileSizeForGuild(guild)) {
|
||||
// Upload (unencrypted) file as link, because it's too large for Discord
|
||||
// Do this by constructing a sample Matrix message with the link and then use the text processor to convert that + the original caption.
|
||||
const url = mxUtils.getPublicUrlForMxc(event.content.url)
|
||||
assert(url)
|
||||
const filename = event.content.filename || event.content.body
|
||||
const emoji = attachmentEmojis.has(event.content.msgtype) ? attachmentEmojis.get(event.content.msgtype) + " " : ""
|
||||
if (fileIsSpoiler) {
|
||||
captionContent.addLine(`${emoji}Uploaded SPOILER file: <${url}> (${pb(event.content.info.size)})`, tag`${emoji}<em>Uploaded <strong>SPOILER</strong> file: <span data-mx-spoiler><a href="${url} ">${filename}</a></span> (${pb(event.content.info.size)})</em>`) // the space is necessary to work around a bug in Discord's URL previewer. the preview still gets blurred in the client.
|
||||
} else {
|
||||
captionContent.addLine(`${emoji}Uploaded file: ${url} (${pb(event.content.info.size)})`, tag`${emoji}<em>Uploaded file: <a href="${url}">${filename}</a> (${pb(event.content.info.size)})</em>`)
|
||||
}
|
||||
const filename = event.content.filename || event.content.body
|
||||
if ("file" in event.content) {
|
||||
// Encrypted
|
||||
assert.equal(event.content.file.key.alg, "A256CTR")
|
||||
attachments.push({id: "0", filename})
|
||||
pendingFiles.push({name: filename, mxc: event.content.file.url, key: event.content.file.key.k, iv: event.content.file.iv})
|
||||
} else {
|
||||
// Upload file as file
|
||||
let filename = event.content.filename || event.content.body
|
||||
if (fileIsSpoiler) filename = "SPOILER_" + filename
|
||||
if ("file" in event.content) {
|
||||
// Encrypted
|
||||
assert.equal(event.content.file.key.alg, "A256CTR")
|
||||
attachments.push({id: "0", filename})
|
||||
pendingFiles.push({name: filename, mxc: event.content.file.url, key: event.content.file.key.k, iv: event.content.file.iv})
|
||||
} else {
|
||||
// Unencrypted
|
||||
attachments.push({id: "0", filename})
|
||||
pendingFiles.push({name: filename, mxc: event.content.url})
|
||||
}
|
||||
// Unencrypted
|
||||
attachments.push({id: "0", filename})
|
||||
pendingFiles.push({name: filename, mxc: event.content.url})
|
||||
}
|
||||
|
||||
// Add result to content
|
||||
const result = captionContent.get()
|
||||
if (result.body) {
|
||||
Object.assign(event.content, {body: result.body, format: result.format, formatted_body: result.formatted_body})
|
||||
// Check if we also need to process a text event for this image - if it has a caption that's different from its filename
|
||||
if ((event.content.body && event.content.filename && event.content.body !== event.content.filename) || event.content.formatted_body) {
|
||||
shouldProcessTextEvent = true
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "m.sticker") {
|
||||
const withoutMxc = mxUtils.makeMxcPublic(event.content.url)
|
||||
assert(withoutMxc)
|
||||
const url = `${reg.ooye.bridge_origin}/download/sticker/${withoutMxc}/_.webp`
|
||||
content = `[${event.content.body || "\u2800"}](${url})`
|
||||
|
||||
} else if (event.type === "org.matrix.msc3381.poll.start") {
|
||||
const pollContent = event.content["org.matrix.msc3381.poll.start"] // just for convenience
|
||||
const isClosed = false;
|
||||
const maxSelections = pollContent.max_selections || 1
|
||||
const questionText = pollContent.question["org.matrix.msc1767.text"]
|
||||
const pollOptions = pollContent.answers.map(answer => ({
|
||||
matrix_option: answer.id,
|
||||
option_text: answer["org.matrix.msc1767.text"],
|
||||
count: 0 // no votes initially
|
||||
}))
|
||||
content = ""
|
||||
pollMessages.push(pollComponents.getPollComponents(isClosed, maxSelections, questionText, pollOptions))
|
||||
|
||||
} else if (event.type === "org.matrix.msc3381.poll.end") {
|
||||
assert(di.pollEnd)
|
||||
content = ""
|
||||
messageIDsToEdit.push(di.pollEnd.messageID)
|
||||
pollMessages.push(pollComponents.getPollComponentsFromDatabase(di.pollEnd.messageID))
|
||||
pollMessages.push({
|
||||
...await pollComponents.getPollEndMessageFromDatabase(channel.id, di.pollEnd.messageID),
|
||||
avatar_url: `${reg.ooye.bridge_origin}/download/file/poll-star-avatar.png`
|
||||
})
|
||||
|
||||
} else {
|
||||
let filename = event.content.body
|
||||
if (event.type === "m.sticker") {
|
||||
let mimetype
|
||||
if (event.content.info?.mimetype?.includes("/")) {
|
||||
mimetype = event.content.info.mimetype
|
||||
} else {
|
||||
const res = await di.api.getMedia(event.content.url, {method: "HEAD"})
|
||||
if (res.status === 200) {
|
||||
mimetype = res.headers.get("content-type")
|
||||
}
|
||||
if (!mimetype) throw new Error(`Server error ${res.status} or missing content-type while detecting sticker mimetype`)
|
||||
}
|
||||
filename += "." + mimetype.split("/")[1]
|
||||
}
|
||||
attachments.push({id: "0", filename})
|
||||
pendingFiles.push({name: filename, mxc: event.content.url})
|
||||
} else if (shouldProcessTextEvent) {
|
||||
// Handling edits. If the edit was an edit of a reply, edits do not include the reply reference, so we need to fetch up to 2 more events.
|
||||
// this event ---is an edit of--> original event ---is a reply to--> past event
|
||||
await (async () => {
|
||||
|
|
@ -735,10 +650,9 @@ async function eventToMessage(event, guild, channel, di) {
|
|||
}
|
||||
|
||||
replyLine = await getL1L2ReplyLine()
|
||||
const row = from("event_message").join("message_room", "message_id").join("historical_channel_room", "historical_room_index")
|
||||
.select("reference_channel_id", "message_id").where({event_id: repliedToEventId}).and("ORDER BY part").get()
|
||||
const row = from("event_message").join("message_channel", "message_id").select("channel_id", "message_id").where({event_id: repliedToEventId}).and("ORDER BY part").get()
|
||||
if (row) {
|
||||
replyLine += `https://discord.com/channels/${guild.id}/${row.reference_channel_id}/${row.message_id} `
|
||||
replyLine += `https://discord.com/channels/${guild.id}/${row.channel_id}/${row.message_id} `
|
||||
}
|
||||
// If the event has been edited, the homeserver will include the relation in `unsigned`.
|
||||
if (repliedToEvent.unsigned?.["m.relations"]?.["m.replace"]?.content?.["m.new_content"]) {
|
||||
|
|
@ -771,20 +685,20 @@ async function eventToMessage(event, guild, channel, di) {
|
|||
return convertEmoji(mxcUrlMatch?.[1], titleTextMatch?.[1], false, false)
|
||||
})
|
||||
repliedToContent = repliedToContent.replace(/<[^:>][^>]*>/g, "") // Completely strip all HTML tags and formatting.
|
||||
repliedToContent = repliedToContent.replace(/\bhttps?:\/\/[^ )]*/g, "<$&>")
|
||||
repliedToContent = entities.decodeHTML5Strict(repliedToContent) // Remove entities like & "
|
||||
const contentPreviewChunks = chunk(repliedToContent, 50)
|
||||
if (contentPreviewChunks.length) {
|
||||
contentPreview = ": " + contentPreviewChunks[0]
|
||||
contentPreview = contentPreview.replace(/\bhttps?:\/\/[^ )]*/g, "<$&>")
|
||||
if (contentPreviewChunks.length > 1) contentPreview = contentPreview.replace(/[,.']$/, "") + "..."
|
||||
} else {
|
||||
contentPreview = ""
|
||||
}
|
||||
}
|
||||
const sender = repliedToEvent.sender
|
||||
const authorMention = getUserOrProxyOwnerMention(sender)
|
||||
if (authorMention) {
|
||||
replyLine += authorMention
|
||||
const authorID = getUserOrProxyOwnerID(sender)
|
||||
if (authorID) {
|
||||
replyLine += `<@${authorID}>`
|
||||
} else {
|
||||
let senderName = select("member_cache", "displayname", {mxid: sender}).pluck().get()
|
||||
if (!senderName) senderName = sender.match(/@([^:]*)/)?.[1]
|
||||
|
|
@ -793,196 +707,157 @@ async function eventToMessage(event, guild, channel, di) {
|
|||
replyLine = `-# > ${replyLine}${contentPreview}\n`
|
||||
})()
|
||||
|
||||
if (shouldProcessTextEvent) {
|
||||
if (event.content.format === "org.matrix.custom.html" && event.content.formatted_body) {
|
||||
let input = event.content.formatted_body
|
||||
if (event.content.msgtype === "m.emote") {
|
||||
input = `* ${displayName} ${input}`
|
||||
}
|
||||
|
||||
// Handling mentions of Discord users
|
||||
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
|
||||
const id = select("sim", "user_id", {mxid}).pluck().get()
|
||||
if (!id) return whole
|
||||
return `${attributeValue} data-user-id="${id}">`
|
||||
} else {
|
||||
// Handle mention of a Matrix user by their mxid
|
||||
// Check if this Matrix user is actually the sim user from another old bridge in the room?
|
||||
const match = mxid.match(/[^:]*discord[^:]*_([0-9]{6,}):/) // try to match @_discord_123456, @_discordpuppet_123456, etc.
|
||||
if (match) return `${attributeValue} data-user-id="${match[1]}">`
|
||||
// Nope, just a real Matrix user.
|
||||
return whole
|
||||
}
|
||||
})
|
||||
|
||||
// Handling mentions of rooms and room-messages
|
||||
input = await handleRoomOrMessageLinks(input, di)
|
||||
|
||||
// Stripping colons after mentions
|
||||
input = input.replace(/( data-user-id.*?<\/a>):?/g, "$1")
|
||||
input = input.replace(/("https:\/\/matrix.to.*?<\/a>):?/g, "$1")
|
||||
|
||||
// Element adds a bunch of <br> before </blockquote> but doesn't render them. I can't figure out how this even works in the browser, so let's just delete those.
|
||||
input = input.replace(/(?:\n|<br ?\/?>\s*)*<\/blockquote>/g, "</blockquote>")
|
||||
|
||||
// The matrix spec hasn't decided whether \n counts as a newline or not, but I'm going to count it, because if it's in the data it's there for a reason.
|
||||
// But I should not count it if it's between block elements.
|
||||
input = input.replace(/(<\/?([^ >]+)[^>]*>)?\n(<\/?([^ >]+)[^>]*>)?/g, (whole, beforeContext, beforeTag, afterContext, afterTag) => {
|
||||
if (typeof beforeTag !== "string" && typeof afterTag !== "string") {
|
||||
return "<br>"
|
||||
}
|
||||
beforeContext = beforeContext || ""
|
||||
beforeTag = beforeTag || ""
|
||||
afterContext = afterContext || ""
|
||||
afterTag = afterTag || ""
|
||||
if (!mxUtils.BLOCK_ELEMENTS.includes(beforeTag.toUpperCase()) && !mxUtils.BLOCK_ELEMENTS.includes(afterTag.toUpperCase())) {
|
||||
return beforeContext + "<br>" + afterContext
|
||||
} else {
|
||||
return whole
|
||||
}
|
||||
})
|
||||
|
||||
// Note: Element's renderers on Web and Android currently collapse whitespace, like the browser does. Turndown also collapses whitespace which is good for me.
|
||||
// If later I'm using a client that doesn't collapse whitespace and I want turndown to follow suit, uncomment the following line of code, and it Just Works:
|
||||
// input = input.replace(/ /g, " ")
|
||||
// There is also a corresponding test to uncomment, named "event2message: whitespace is retained"
|
||||
|
||||
// Handling written @mentions: we need to look for candidate Discord members to join to the room
|
||||
// This shouldn't apply to code blocks, links, or inside attributes. So editing the HTML tree instead of regular expressions is a sensible choice here.
|
||||
// We're using the domino parser because Turndown uses the same and can reuse this tree.
|
||||
const doc = domino.createDocument(
|
||||
// DOM parsers arrange elements in the <head> and <body>. Wrapping in a custom element ensures elements are reliably arranged in a single element.
|
||||
'<x-turndown id="turndown-root">' + input + '</x-turndown>'
|
||||
);
|
||||
const root = doc.getElementById("turndown-root");
|
||||
async function forEachNode(event, node) {
|
||||
for (; node; node = node.nextSibling) {
|
||||
// Check written mentions
|
||||
if (node.nodeType === 3 && node.nodeValue.includes("@") && !nodeIsChildOf(node, ["A", "CODE", "PRE"])) {
|
||||
const result = await checkWrittenMentions(node.nodeValue, event.sender, event.room_id, guild, di)
|
||||
if (result) {
|
||||
node.nodeValue = result.content
|
||||
ensureJoined.push(...result.ensureJoined)
|
||||
allowedMentionsParse.push(...result.allowedMentionsParse)
|
||||
}
|
||||
}
|
||||
// Check for incompatible backticks in code blocks
|
||||
let preNode
|
||||
if (node.nodeType === 3 && node.nodeValue.includes("```") && (preNode = nodeIsChildOf(node, ["PRE"]))) {
|
||||
if (preNode.firstChild?.nodeName === "CODE") {
|
||||
const ext = preNode.firstChild.className.match(/language-(\S+)/)?.[1] || "txt"
|
||||
const filename = `inline_code.${ext}`
|
||||
// Build the replacement <code> node
|
||||
const replacementCode = doc.createElement("code")
|
||||
replacementCode.textContent = `[${filename}]`
|
||||
// Build its containing <span> node
|
||||
const replacement = doc.createElement("span")
|
||||
replacement.appendChild(doc.createTextNode(" "))
|
||||
replacement.appendChild(replacementCode)
|
||||
replacement.appendChild(doc.createTextNode(" "))
|
||||
// Replace the code block with the <span>
|
||||
preNode.replaceWith(replacement)
|
||||
// Upload the code as an attachment
|
||||
const content = getCodeContent(preNode.firstChild)
|
||||
attachments.push({id: String(attachments.length), filename})
|
||||
pendingFiles.push({name: filename, buffer: Buffer.from(content, "utf8")})
|
||||
}
|
||||
}
|
||||
// Suppress link embeds
|
||||
if (node.nodeType === 1 && node.tagName === "A") {
|
||||
// Suppress if sender tried to add angle brackets
|
||||
const inBody = event.content.body.indexOf(node.getAttribute("href"))
|
||||
let shouldSuppress = inBody !== -1 && event.content.body[inBody-1] === "<"
|
||||
if (!shouldSuppress && guild?.roles) {
|
||||
// Suppress if regular users don't have permission
|
||||
const permissions = dUtils.getPermissions(guild.id, [], guild.roles)
|
||||
const canEmbedLinks = dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.EmbedLinks)
|
||||
shouldSuppress = !canEmbedLinks
|
||||
}
|
||||
if (shouldSuppress) {
|
||||
node.setAttribute("data-suppress", "")
|
||||
}
|
||||
}
|
||||
await forEachNode(event, node.firstChild)
|
||||
}
|
||||
}
|
||||
await forEachNode(event, root)
|
||||
|
||||
// SPRITE SHEET EMOJIS FEATURE: Emojis at the end of the message that we don't know about will be reuploaded as a sprite sheet.
|
||||
// First we need to determine which emojis are at the end.
|
||||
endOfMessageEmojis = []
|
||||
let match
|
||||
let last = input.length
|
||||
while ((match = input.slice(0, last).match(/<img [^>]*>\s*$/))) {
|
||||
if (!match[0].includes("data-mx-emoticon")) break
|
||||
const mxcUrl = match[0].match(/\bsrc="(mxc:\/\/[^"]+)"/)
|
||||
if (mxcUrl) endOfMessageEmojis.unshift(mxcUrl[1])
|
||||
assert(typeof match.index === "number", "Your JavaScript implementation does not comply with TC39: https://tc39.es/ecma262/multipage/text-processing.html#sec-regexpbuiltinexec")
|
||||
last = match.index
|
||||
}
|
||||
|
||||
// @ts-ignore bad type from turndown
|
||||
content = turndownService.turndown(root)
|
||||
|
||||
// Put < > around any surviving matrix.to links to hide the URL previews
|
||||
content = content.replace(/\bhttps?:\/\/matrix\.to\/[^<>\n )]*/g, "<$&>")
|
||||
|
||||
// It's designed for commonmark, we need to replace the space-space-newline with just newline
|
||||
content = content.replace(/ \n/g, "\n")
|
||||
|
||||
// If there's a blockquote at the start of the message body and this message is a reply, they should be visually separated
|
||||
if (replyLine && content.startsWith("> ")) content = "\n" + content
|
||||
|
||||
// SPRITE SHEET EMOJIS FEATURE:
|
||||
content = await linkEndOfMessageSpriteSheet(content)
|
||||
} else {
|
||||
// Looks like we're using the plaintext body!
|
||||
content = event.content.body
|
||||
|
||||
if (event.content.msgtype === "m.emote") {
|
||||
content = `* ${displayName} ${content}`
|
||||
}
|
||||
|
||||
content = await handleRoomOrMessageLinks(content, di) // Replace matrix.to links with discord.com equivalents where possible
|
||||
|
||||
let offset = 0
|
||||
for (const match of [...content.matchAll(/\bhttps?:\/\/[^ )>\n]+/g)]) {
|
||||
assert(typeof match.index === "number")
|
||||
|
||||
// Respect sender's angle brackets
|
||||
const alreadySuppressed = content[match.index-1+offset] === "<" && content[match.index+match.length+offset] === ">"
|
||||
if (alreadySuppressed) continue
|
||||
|
||||
// Suppress matrix.to links always
|
||||
let shouldSuppress = !!match[0].match(/^https?:\/\/matrix\.to\//)
|
||||
|
||||
// Suppress if regular users don't have permission
|
||||
if (!shouldSuppress && guild?.roles) {
|
||||
const permissions = dUtils.getPermissions(guild.id, [], guild.roles, undefined, channel.permission_overwrites)
|
||||
const canEmbedLinks = dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.EmbedLinks)
|
||||
shouldSuppress = !canEmbedLinks
|
||||
}
|
||||
|
||||
if (shouldSuppress) {
|
||||
content = content.slice(0, match.index + offset) + "<" + match[0] + ">" + content.slice(match.index + match[0].length + offset)
|
||||
offset += 2
|
||||
}
|
||||
}
|
||||
|
||||
const result = await checkWrittenMentions(content, event.sender, event.room_id, guild, di)
|
||||
if (result) {
|
||||
content = result.content
|
||||
ensureJoined.push(...result.ensureJoined)
|
||||
allowedMentionsParse.push(...result.allowedMentionsParse)
|
||||
}
|
||||
|
||||
// Markdown needs to be escaped, though take care not to escape the middle of links
|
||||
// @ts-ignore bad type from turndown
|
||||
content = turndownService.escape(content)
|
||||
if (event.content.format === "org.matrix.custom.html" && event.content.formatted_body) {
|
||||
let input = event.content.formatted_body
|
||||
if (event.content.msgtype === "m.emote") {
|
||||
input = `* ${displayName} ${input}`
|
||||
}
|
||||
|
||||
// Handling mentions of Discord users
|
||||
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
|
||||
const id = select("sim", "user_id", {mxid}).pluck().get()
|
||||
if (!id) return whole
|
||||
return `${attributeValue} data-user-id="${id}">`
|
||||
} else {
|
||||
// Handle mention of a Matrix user by their mxid
|
||||
// Check if this Matrix user is actually the sim user from another old bridge in the room?
|
||||
const match = mxid.match(/[^:]*discord[^:]*_([0-9]{6,}):/) // try to match @_discord_123456, @_discordpuppet_123456, etc.
|
||||
if (match) return `${attributeValue} data-user-id="${match[1]}">`
|
||||
// Nope, just a real Matrix user.
|
||||
return whole
|
||||
}
|
||||
})
|
||||
|
||||
// Handling mentions of rooms and room-messages
|
||||
input = await handleRoomOrMessageLinks(input, di)
|
||||
|
||||
// Stripping colons after mentions
|
||||
input = input.replace(/( data-user-id.*?<\/a>):?/g, "$1")
|
||||
input = input.replace(/("https:\/\/matrix.to.*?<\/a>):?/g, "$1")
|
||||
|
||||
// Element adds a bunch of <br> before </blockquote> but doesn't render them. I can't figure out how this even works in the browser, so let's just delete those.
|
||||
input = input.replace(/(?:\n|<br ?\/?>\s*)*<\/blockquote>/g, "</blockquote>")
|
||||
|
||||
// The matrix spec hasn't decided whether \n counts as a newline or not, but I'm going to count it, because if it's in the data it's there for a reason.
|
||||
// But I should not count it if it's between block elements.
|
||||
input = input.replace(/(<\/?([^ >]+)[^>]*>)?\n(<\/?([^ >]+)[^>]*>)?/g, (whole, beforeContext, beforeTag, afterContext, afterTag) => {
|
||||
// console.error(beforeContext, beforeTag, afterContext, afterTag)
|
||||
if (typeof beforeTag !== "string" && typeof afterTag !== "string") {
|
||||
return "<br>"
|
||||
}
|
||||
beforeContext = beforeContext || ""
|
||||
beforeTag = beforeTag || ""
|
||||
afterContext = afterContext || ""
|
||||
afterTag = afterTag || ""
|
||||
if (!mxUtils.BLOCK_ELEMENTS.includes(beforeTag.toUpperCase()) && !mxUtils.BLOCK_ELEMENTS.includes(afterTag.toUpperCase())) {
|
||||
return beforeContext + "<br>" + afterContext
|
||||
} else {
|
||||
return whole
|
||||
}
|
||||
})
|
||||
|
||||
// Note: Element's renderers on Web and Android currently collapse whitespace, like the browser does. Turndown also collapses whitespace which is good for me.
|
||||
// If later I'm using a client that doesn't collapse whitespace and I want turndown to follow suit, uncomment the following line of code, and it Just Works:
|
||||
// input = input.replace(/ /g, " ")
|
||||
// There is also a corresponding test to uncomment, named "event2message: whitespace is retained"
|
||||
|
||||
// Handling written @mentions: we need to look for candidate Discord members to join to the room
|
||||
// This shouldn't apply to code blocks, links, or inside attributes. So editing the HTML tree instead of regular expressions is a sensible choice here.
|
||||
// We're using the domino parser because Turndown uses the same and can reuse this tree.
|
||||
const doc = domino.createDocument(
|
||||
// DOM parsers arrange elements in the <head> and <body>. Wrapping in a custom element ensures elements are reliably arranged in a single element.
|
||||
'<x-turndown id="turndown-root">' + input + '</x-turndown>'
|
||||
);
|
||||
const root = doc.getElementById("turndown-root");
|
||||
async function forEachNode(node) {
|
||||
for (; node; node = node.nextSibling) {
|
||||
// Check written mentions
|
||||
if (node.nodeType === 3 && node.nodeValue.includes("@") && !nodeIsChildOf(node, ["A", "CODE", "PRE"])) {
|
||||
const result = await checkWrittenMentions(node.nodeValue, event.sender, event.room_id, guild, di)
|
||||
if (result) {
|
||||
node.nodeValue = result.content
|
||||
ensureJoined.push(...result.ensureJoined)
|
||||
allowedMentionsParse.push(...result.allowedMentionsParse)
|
||||
}
|
||||
}
|
||||
// Check for incompatible backticks in code blocks
|
||||
let preNode
|
||||
if (node.nodeType === 3 && node.nodeValue.includes("```") && (preNode = nodeIsChildOf(node, ["PRE"]))) {
|
||||
if (preNode.firstChild?.nodeName === "CODE") {
|
||||
const ext = preNode.firstChild.className.match(/language-(\S+)/)?.[1] || "txt"
|
||||
const filename = `inline_code.${ext}`
|
||||
// Build the replacement <code> node
|
||||
const replacementCode = doc.createElement("code")
|
||||
replacementCode.textContent = `[${filename}]`
|
||||
// Build its containing <span> node
|
||||
const replacement = doc.createElement("span")
|
||||
replacement.appendChild(doc.createTextNode(" "))
|
||||
replacement.appendChild(replacementCode)
|
||||
replacement.appendChild(doc.createTextNode(" "))
|
||||
// Replace the code block with the <span>
|
||||
preNode.replaceWith(replacement)
|
||||
// Upload the code as an attachment
|
||||
const content = getCodeContent(preNode.firstChild)
|
||||
attachments.push({id: String(attachments.length), filename})
|
||||
pendingFiles.push({name: filename, buffer: Buffer.from(content, "utf8")})
|
||||
}
|
||||
}
|
||||
await forEachNode(node.firstChild)
|
||||
}
|
||||
}
|
||||
await forEachNode(root)
|
||||
|
||||
// SPRITE SHEET EMOJIS FEATURE: Emojis at the end of the message that we don't know about will be reuploaded as a sprite sheet.
|
||||
// First we need to determine which emojis are at the end.
|
||||
endOfMessageEmojis = []
|
||||
let match
|
||||
let last = input.length
|
||||
while ((match = input.slice(0, last).match(/<img [^>]*>\s*$/))) {
|
||||
if (!match[0].includes("data-mx-emoticon")) break
|
||||
const mxcUrl = match[0].match(/\bsrc="(mxc:\/\/[^"]+)"/)
|
||||
if (mxcUrl) endOfMessageEmojis.unshift(mxcUrl[1])
|
||||
assert(typeof match.index === "number", "Your JavaScript implementation does not comply with TC39: https://tc39.es/ecma262/multipage/text-processing.html#sec-regexpbuiltinexec")
|
||||
last = match.index
|
||||
}
|
||||
|
||||
// @ts-ignore bad type from turndown
|
||||
content = turndownService.turndown(root)
|
||||
|
||||
// Put < > around any surviving matrix.to links to hide the URL previews
|
||||
content = content.replace(/\bhttps?:\/\/matrix\.to\/[^<>\n )]*/g, "<$&>")
|
||||
|
||||
// It's designed for commonmark, we need to replace the space-space-newline with just newline
|
||||
content = content.replace(/ \n/g, "\n")
|
||||
|
||||
// If there's a blockquote at the start of the message body and this message is a reply, they should be visually separated
|
||||
if (replyLine && content.startsWith("> ")) content = "\n" + content
|
||||
|
||||
// SPRITE SHEET EMOJIS FEATURE:
|
||||
content = await uploadEndOfMessageSpriteSheet(content, attachments, pendingFiles, di?.mxcDownloader)
|
||||
} else {
|
||||
// Looks like we're using the plaintext body!
|
||||
content = event.content.body
|
||||
|
||||
if (event.content.msgtype === "m.emote") {
|
||||
content = `* ${displayName} ${content}`
|
||||
}
|
||||
|
||||
content = await handleRoomOrMessageLinks(content, di) // Replace matrix.to links with discord.com equivalents where possible
|
||||
content = content.replace(/\bhttps?:\/\/matrix\.to\/[^<>\n )]*/, "<$&>") // Put < > around any surviving matrix.to links to hide the URL previews
|
||||
|
||||
const result = await checkWrittenMentions(content, event.sender, event.room_id, guild, di)
|
||||
if (result) {
|
||||
content = result.content
|
||||
ensureJoined.push(...result.ensureJoined)
|
||||
allowedMentionsParse.push(...result.allowedMentionsParse)
|
||||
}
|
||||
|
||||
// Markdown needs to be escaped, though take care not to escape the middle of links
|
||||
// @ts-ignore bad type from turndown
|
||||
content = turndownService.escape(content)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1013,16 +888,6 @@ async function eventToMessage(event, guild, channel, di) {
|
|||
messages[0].pendingFiles = pendingFiles
|
||||
}
|
||||
|
||||
if (pollMessages.length) {
|
||||
for (const pollMessage of pollMessages) {
|
||||
messages.push({
|
||||
username: displayNameShortened,
|
||||
avatar_url: avatarURL,
|
||||
...pollMessage,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const messagesToEdit = []
|
||||
const messagesToSend = []
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,227 +0,0 @@
|
|||
// @ts-check
|
||||
|
||||
const assert = require("assert").strict
|
||||
const DiscordTypes = require("discord-api-types/v10")
|
||||
const {sync, db, discord, select, from} = require("../../passthrough")
|
||||
|
||||
/** @type {import("../actions/setup-emojis")} */
|
||||
const setupEmojis = sync.require("../actions/setup-emojis")
|
||||
|
||||
/**
|
||||
* @param {{count: number}[]} topAnswers
|
||||
* @param {number} count
|
||||
* @returns {string}
|
||||
*/
|
||||
function getMedal(topAnswers, count) {
|
||||
const winningOrTied = count && topAnswers[0].count === count
|
||||
const secondOrTied = !winningOrTied && count && topAnswers[1]?.count === count && topAnswers.slice(-1)[0].count !== count
|
||||
const thirdOrTied = !winningOrTied && !secondOrTied && count && topAnswers[2]?.count === count && topAnswers.slice(-1)[0].count !== count
|
||||
const medal =
|
||||
( winningOrTied ? "🥇"
|
||||
: secondOrTied ? "🥈"
|
||||
: thirdOrTied ? "🥉"
|
||||
: "")
|
||||
return medal
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} isClosed
|
||||
* @param {{matrix_option: string, option_text: string, count: number}[]} pollOptions already sorted correctly
|
||||
* @returns {DiscordTypes.APIMessageTopLevelComponent[]}
|
||||
*/
|
||||
function optionsToComponents(isClosed, pollOptions) {
|
||||
const topAnswers = pollOptions.toSorted((a, b) => b.count - a.count)
|
||||
/** @type {DiscordTypes.APIMessageTopLevelComponent[]} */
|
||||
return pollOptions.map(option => {
|
||||
const medal = getMedal(topAnswers, option.count)
|
||||
return {
|
||||
type: DiscordTypes.ComponentType.Container,
|
||||
components: [{
|
||||
type: DiscordTypes.ComponentType.Section,
|
||||
components: [{
|
||||
type: DiscordTypes.ComponentType.TextDisplay,
|
||||
content: medal && isClosed ? `${medal} ${option.option_text}` : option.option_text
|
||||
}],
|
||||
accessory: {
|
||||
type: DiscordTypes.ComponentType.Button,
|
||||
style: medal === "🥇" && isClosed ? DiscordTypes.ButtonStyle.Success : DiscordTypes.ButtonStyle.Secondary,
|
||||
label: option.count.toString(),
|
||||
custom_id: `POLL_OPTION#${option.matrix_option}`,
|
||||
disabled: isClosed
|
||||
}
|
||||
}]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} maxSelections
|
||||
* @param {number} optionCount
|
||||
*/
|
||||
function getMultiSelectString(maxSelections, optionCount) {
|
||||
if (maxSelections === 1) {
|
||||
return "Select one answer"
|
||||
} else if (maxSelections >= optionCount) {
|
||||
return "Select one or more answers"
|
||||
} else {
|
||||
return `Select up to ${maxSelections} answers`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} maxSelections
|
||||
* @param {number} optionCount
|
||||
*/
|
||||
function getMultiSelectClosedString(maxSelections, optionCount) {
|
||||
if (maxSelections === 1) {
|
||||
return "Single choice"
|
||||
} else if (maxSelections >= optionCount) {
|
||||
return "Multiple choice"
|
||||
} else {
|
||||
return `Multiple choice (up to ${maxSelections})`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} isClosed
|
||||
* @param {number} maxSelections
|
||||
* @param {string} questionText
|
||||
* @param {{matrix_option: string, option_text: string, count: number}[]} pollOptions already sorted correctly
|
||||
* @returns {DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody}
|
||||
*/
|
||||
function getPollComponents(isClosed, maxSelections, questionText, pollOptions) {
|
||||
/** @type {DiscordTypes.APIMessageTopLevelComponent[]} array because it can move around */
|
||||
const multiSelectInfoComponent = [{
|
||||
type: DiscordTypes.ComponentType.TextDisplay,
|
||||
content: isClosed ? `-# ${getMultiSelectClosedString(maxSelections, pollOptions.length)}` : `-# ${getMultiSelectString(maxSelections, pollOptions.length)}`
|
||||
}]
|
||||
/** @type {DiscordTypes.APIMessageTopLevelComponent} */
|
||||
let headingComponent
|
||||
if (isClosed) {
|
||||
headingComponent = { // This one is for the poll heading.
|
||||
type: DiscordTypes.ComponentType.Section,
|
||||
components: [
|
||||
{
|
||||
type: DiscordTypes.ComponentType.TextDisplay,
|
||||
content: `## ${questionText}`
|
||||
}
|
||||
],
|
||||
accessory: {
|
||||
type: DiscordTypes.ComponentType.Button,
|
||||
style: DiscordTypes.ButtonStyle.Secondary,
|
||||
custom_id: "POLL_VOTE",
|
||||
label: "Voting closed",
|
||||
disabled: true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
headingComponent = { // This one is for the poll heading.
|
||||
type: DiscordTypes.ComponentType.Section,
|
||||
components: [
|
||||
{
|
||||
type: DiscordTypes.ComponentType.TextDisplay,
|
||||
content: `## ${questionText}`
|
||||
},
|
||||
// @ts-ignore
|
||||
multiSelectInfoComponent.pop()
|
||||
],
|
||||
accessory: {
|
||||
type: DiscordTypes.ComponentType.Button,
|
||||
style: DiscordTypes.ButtonStyle.Primary,
|
||||
custom_id: "POLL_VOTE",
|
||||
label: "Vote!"
|
||||
}
|
||||
}
|
||||
}
|
||||
const optionComponents = optionsToComponents(isClosed, pollOptions)
|
||||
return {
|
||||
flags: DiscordTypes.MessageFlags.IsComponentsV2,
|
||||
components: [headingComponent, ...optionComponents, ...multiSelectInfoComponent]
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {string} messageID */
|
||||
function getPollComponentsFromDatabase(messageID) {
|
||||
const pollRow = select("poll", ["max_selections", "is_closed", "question_text"], {message_id: messageID}).get()
|
||||
assert(pollRow)
|
||||
/** @type {{matrix_option: string, option_text: string, count: number}[]} */
|
||||
const pollResults = db.prepare("SELECT matrix_option, option_text, seq, count(discord_or_matrix_user_id) as count FROM poll_option LEFT JOIN poll_vote USING (message_id, matrix_option) WHERE message_id = ? GROUP BY matrix_option ORDER BY seq").all(messageID)
|
||||
return getPollComponents(!!pollRow.is_closed, pollRow.max_selections, pollRow.question_text, pollResults)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} channelID
|
||||
* @param {string} messageID
|
||||
* @param {string} questionText
|
||||
* @param {{matrix_option: string, option_text: string, count: number}[]} pollOptions already sorted correctly
|
||||
* @returns {DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody}
|
||||
*/
|
||||
function getPollEndMessage(channelID, messageID, questionText, pollOptions) {
|
||||
const topAnswers = pollOptions.toSorted((a, b) => b.count - a.count)
|
||||
const totalVotes = pollOptions.reduce((a, c) => a + c.count, 0)
|
||||
const tied = topAnswers[0].count === topAnswers[1].count
|
||||
const titleString = `-# The poll **${questionText}** has closed.`
|
||||
let winnerString = ""
|
||||
let resultsString = ""
|
||||
if (totalVotes == 0) {
|
||||
winnerString = "There was no winner"
|
||||
} else if (tied) {
|
||||
winnerString = "It's a draw!"
|
||||
resultsString = `${Math.round((topAnswers[0].count/totalVotes)*100)}%`
|
||||
} else {
|
||||
const pollWin = select("auto_emoji", ["name", "emoji_id"], {name: "poll_win"}).get()
|
||||
winnerString = `${topAnswers[0].option_text} <:${pollWin?.name}:${pollWin?.emoji_id}>`
|
||||
resultsString = `Winning answer • ${Math.round((topAnswers[0].count/totalVotes)*100)}%`
|
||||
}
|
||||
// @ts-ignore
|
||||
const guildID = discord.channels.get(channelID).guild_id
|
||||
let mainContent = `**${winnerString}**`
|
||||
if (resultsString) {
|
||||
mainContent += `\n-# ${resultsString}`
|
||||
}
|
||||
return {
|
||||
flags: DiscordTypes.MessageFlags.IsComponentsV2,
|
||||
components: [{
|
||||
type: DiscordTypes.ComponentType.TextDisplay,
|
||||
content: titleString
|
||||
}, {
|
||||
type: DiscordTypes.ComponentType.Container,
|
||||
components: [{
|
||||
type: DiscordTypes.ComponentType.Section,
|
||||
components: [{
|
||||
type: DiscordTypes.ComponentType.TextDisplay,
|
||||
content: `**${winnerString}**\n-# ${resultsString}`
|
||||
}],
|
||||
accessory: {
|
||||
type: DiscordTypes.ComponentType.Button,
|
||||
style: DiscordTypes.ButtonStyle.Link,
|
||||
url: `https://discord.com/channels/${guildID}/${channelID}/${messageID}`,
|
||||
label: "View Poll"
|
||||
}
|
||||
}]
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} channelID
|
||||
* @param {string} messageID
|
||||
*/
|
||||
async function getPollEndMessageFromDatabase(channelID, messageID) {
|
||||
const pollWin = select("auto_emoji", ["name", "emoji_id"], {name: "poll_win"}).get()
|
||||
if (!pollWin) {
|
||||
await setupEmojis.setupEmojis()
|
||||
}
|
||||
|
||||
const pollRow = select("poll", ["max_selections", "question_text"], {message_id: messageID}).get()
|
||||
assert(pollRow)
|
||||
/** @type {{matrix_option: string, option_text: string, count: number}[]} */
|
||||
const pollResults = db.prepare("SELECT matrix_option, option_text, seq, count(discord_or_matrix_user_id) as count FROM poll_option LEFT JOIN poll_vote USING (message_id, matrix_option) WHERE message_id = ? GROUP BY matrix_option ORDER BY seq").all(messageID)
|
||||
return getPollEndMessage(channelID, messageID, pollRow.question_text, pollResults)
|
||||
}
|
||||
|
||||
module.exports.getMultiSelectString = getMultiSelectString
|
||||
module.exports.getPollComponents = getPollComponents
|
||||
module.exports.getPollComponentsFromDatabase = getPollComponentsFromDatabase
|
||||
module.exports.getPollEndMessageFromDatabase = getPollEndMessageFromDatabase
|
||||
module.exports.getMedal = getMedal
|
||||
241
src/m2d/converters/utils.js
Normal file
241
src/m2d/converters/utils.js
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
// @ts-check
|
||||
|
||||
const assert = require("assert").strict
|
||||
|
||||
const passthrough = require("../../passthrough")
|
||||
const {db} = passthrough
|
||||
|
||||
const {reg} = require("../../matrix/read-registration")
|
||||
const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex))
|
||||
|
||||
/** @type {import("xxhash-wasm").XXHashAPI} */ // @ts-ignore
|
||||
let hasher = null
|
||||
// @ts-ignore
|
||||
require("xxhash-wasm")().then(h => hasher = h)
|
||||
|
||||
const BLOCK_ELEMENTS = [
|
||||
"ADDRESS", "ARTICLE", "ASIDE", "AUDIO", "BLOCKQUOTE", "BODY", "CANVAS",
|
||||
"CENTER", "DD", "DETAILS", "DIR", "DIV", "DL", "DT", "FIELDSET", "FIGCAPTION", "FIGURE",
|
||||
"FOOTER", "FORM", "FRAMESET", "H1", "H2", "H3", "H4", "H5", "H6", "HEADER",
|
||||
"HGROUP", "HR", "HTML", "ISINDEX", "LI", "MAIN", "MENU", "NAV", "NOFRAMES",
|
||||
"NOSCRIPT", "OL", "OUTPUT", "P", "PRE", "SECTION", "SUMMARY", "TABLE", "TBODY", "TD",
|
||||
"TFOOT", "TH", "THEAD", "TR", "UL"
|
||||
]
|
||||
const NEWLINE_ELEMENTS = BLOCK_ELEMENTS.concat(["BR"])
|
||||
|
||||
/**
|
||||
* Determine whether an event is the bridged representation of a discord message.
|
||||
* Such messages shouldn't be bridged again.
|
||||
* @param {string} sender
|
||||
*/
|
||||
function eventSenderIsFromDiscord(sender) {
|
||||
// If it's from a user in the bridge's namespace, then it originated from discord
|
||||
// This could include messages sent by the appservice's bot user, because that is what's used for webhooks
|
||||
if (userRegex.some(x => sender.match(x))) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Event IDs are really big and have more entropy than we need.
|
||||
* If we want to store the event ID in the database, we can store a more compact version by hashing it with this.
|
||||
* I choose a 64-bit non-cryptographic hash as only a 32-bit hash will see birthday collisions unreasonably frequently: https://en.wikipedia.org/wiki/Birthday_attack#Mathematics
|
||||
* xxhash outputs an unsigned 64-bit integer.
|
||||
* Converting to a signed 64-bit integer with no bit loss so that it can be stored in an SQLite integer field as-is: https://www.sqlite.org/fileformat2.html#record_format
|
||||
* This should give very efficient storage with sufficient entropy.
|
||||
* @param {string} eventID
|
||||
*/
|
||||
function getEventIDHash(eventID) {
|
||||
assert(hasher, "xxhash is not ready yet")
|
||||
if (eventID[0] === "$" && eventID.length >= 13) {
|
||||
eventID = eventID.slice(1) // increase entropy per character to potentially help xxhash
|
||||
}
|
||||
const unsignedHash = hasher.h64(eventID)
|
||||
const signedHash = unsignedHash - 0x8000000000000000n // shifting down to signed 64-bit range
|
||||
return signedHash
|
||||
}
|
||||
|
||||
class MatrixStringBuilder {
|
||||
constructor() {
|
||||
this.body = ""
|
||||
this.formattedBody = ""
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} body
|
||||
* @param {string} [formattedBody]
|
||||
* @param {any} [condition]
|
||||
*/
|
||||
add(body, formattedBody, condition = true) {
|
||||
if (condition) {
|
||||
if (formattedBody == undefined) formattedBody = body
|
||||
this.body += body
|
||||
this.formattedBody += formattedBody
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} body
|
||||
* @param {string} [formattedBody]
|
||||
* @param {any} [condition]
|
||||
*/
|
||||
addLine(body, formattedBody, condition = true) {
|
||||
if (condition) {
|
||||
if (formattedBody == undefined) formattedBody = body
|
||||
if (this.body.length && this.body.slice(-1) !== "\n") this.body += "\n"
|
||||
this.body += body
|
||||
const match = this.formattedBody.match(/<\/?([a-zA-Z]+[a-zA-Z0-9]*)[^>]*>\s*$/)
|
||||
if (this.formattedBody.length && (!match || !NEWLINE_ELEMENTS.includes(match[1].toUpperCase()))) this.formattedBody += "<br>"
|
||||
this.formattedBody += formattedBody
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} body
|
||||
* @param {string} [formattedBody]
|
||||
* @param {any} [condition]
|
||||
*/
|
||||
addParagraph(body, formattedBody, condition = true) {
|
||||
if (condition) {
|
||||
if (formattedBody == undefined) formattedBody = body
|
||||
if (this.body.length && this.body.slice(-1) !== "\n") this.body += "\n\n"
|
||||
this.body += body
|
||||
formattedBody = `<p>${formattedBody}</p>`
|
||||
this.formattedBody += formattedBody
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
get() {
|
||||
return {
|
||||
msgtype: "m.text",
|
||||
body: this.body,
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: this.formattedBody
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Context: Room IDs are not routable on their own. Room permalinks need a list of servers to try. The client is responsible for coming up with a list of servers.
|
||||
* ASSUMPTION 1: The bridge bot is a member of the target room and can therefore access its power levels and member list for calculation.
|
||||
* ASSUMPTION 2: Because the bridge bot is a member of the target room, the target room is bridged.
|
||||
* https://spec.matrix.org/v1.9/appendices/#routing
|
||||
* https://gitdab.com/cadence/out-of-your-element/issues/11
|
||||
* @param {string} roomID
|
||||
* @param {{[K in "getStateEvent" | "getJoinedMembers"]: import("../../matrix/api")[K]}} api
|
||||
*/
|
||||
async function getViaServers(roomID, api) {
|
||||
const candidates = []
|
||||
const {joined} = await api.getJoinedMembers(roomID)
|
||||
// Candidate 0: The bot's own server name
|
||||
candidates.push(reg.ooye.server_name)
|
||||
// Candidate 1: Highest joined non-sim non-bot power level user in the room
|
||||
// https://github.com/matrix-org/matrix-react-sdk/blob/552c65db98b59406fb49562e537a2721c8505517/src/utils/permalinks/Permalinks.ts#L172
|
||||
try {
|
||||
/** @type {{users?: {[mxid: string]: number}}} */
|
||||
const powerLevels = await api.getStateEvent(roomID, "m.room.power_levels", "")
|
||||
if (powerLevels.users) {
|
||||
const sorted = Object.entries(powerLevels.users).sort((a, b) => b[1] - a[1]) // Highest...
|
||||
for (const power of sorted) {
|
||||
const mxid = power[0]
|
||||
if (!(mxid in joined)) continue // joined...
|
||||
if (userRegex.some(r => mxid.match(r))) continue // non-sim non-bot...
|
||||
const match = mxid.match(/:(.*)/)
|
||||
assert(match)
|
||||
if (!candidates.includes(match[1])) {
|
||||
candidates.push(match[1])
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// power levels event not found
|
||||
}
|
||||
// Candidates 2-3: Most popular servers in the room
|
||||
/** @type {Map<string, number>} */
|
||||
const servers = new Map()
|
||||
// We can get the most popular servers if we know the members, so let's process those...
|
||||
Object.keys(joined)
|
||||
.filter(mxid => !mxid.startsWith("@_")) // Quick check
|
||||
.filter(mxid => !userRegex.some(r => mxid.match(r))) // Full check
|
||||
.slice(0, 1000) // Just sample the first thousand real members
|
||||
.map(mxid => {
|
||||
const match = mxid.match(/:(.*)/)
|
||||
assert(match)
|
||||
return match[1]
|
||||
})
|
||||
.filter(server => !server.match(/([a-f0-9:]+:+)+[a-f0-9]+/)) // No IPv6 servers
|
||||
.filter(server => !server.match(/[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}/)) // No IPv4 servers
|
||||
// I don't care enough to check ACLs
|
||||
.forEach(server => {
|
||||
const existing = servers.get(server)
|
||||
if (!existing) servers.set(server, 1)
|
||||
else servers.set(server, existing + 1)
|
||||
})
|
||||
const serverList = [...servers.entries()].sort((a, b) => b[1] - a[1])
|
||||
for (const server of serverList) {
|
||||
if (!candidates.includes(server[0])) {
|
||||
candidates.push(server[0])
|
||||
if (candidates.length >= 4) break // Can have at most 4 candidate via servers
|
||||
}
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
/**
|
||||
* Context: Room IDs are not routable on their own. Room permalinks need a list of servers to try. The client is responsible for coming up with a list of servers.
|
||||
* ASSUMPTION 1: The bridge bot is a member of the target room and can therefore access its power levels and member list for calculation.
|
||||
* ASSUMPTION 2: Because the bridge bot is a member of the target room, the target room is bridged.
|
||||
* https://spec.matrix.org/v1.9/appendices/#routing
|
||||
* https://gitdab.com/cadence/out-of-your-element/issues/11
|
||||
* @param {string} roomID
|
||||
* @param {{[K in "getStateEvent" | "getJoinedMembers"]: import("../../matrix/api")[K]}} api
|
||||
* @returns {Promise<URLSearchParams>}
|
||||
*/
|
||||
async function getViaServersQuery(roomID, api) {
|
||||
const list = await getViaServers(roomID, api)
|
||||
const qs = new URLSearchParams()
|
||||
for (const server of list) {
|
||||
qs.append("via", server)
|
||||
}
|
||||
return qs
|
||||
}
|
||||
|
||||
/**
|
||||
* Since the introduction of authenticated media, this can no longer just be the /_matrix/media/r0/download URL
|
||||
* because Discord and Discord users cannot use those URLs. Media now has to be proxied through the bridge.
|
||||
* To avoid the bridge acting as a proxy for *any* media, there is a list of permitted media stored in the database.
|
||||
* (The other approach would be signing the URLs with a MAC (or similar) and adding the signature, but I'm not a
|
||||
* cryptographer, so I don't want to.) To reduce database disk space usage, instead of storing each permitted URL,
|
||||
* we just store its xxhash as a signed (as in +/-, not signature) 64-bit integer, which fits in an SQLite integer field.
|
||||
* @see https://matrix.org/blog/2024/06/26/sunsetting-unauthenticated-media/ background
|
||||
* @see https://matrix.org/blog/2024/06/20/matrix-v1.11-release/ implementation details
|
||||
* @see https://www.sqlite.org/fileformat2.html#record_format SQLite integer field size
|
||||
* @param {string} mxc
|
||||
* @returns {string | undefined}
|
||||
*/
|
||||
function getPublicUrlForMxc(mxc) {
|
||||
assert(hasher, "xxhash is not ready yet")
|
||||
const mediaParts = mxc?.match(/^mxc:\/\/([^/]+)\/(\w+)$/)
|
||||
if (!mediaParts) return undefined
|
||||
|
||||
const serverAndMediaID = `${mediaParts[1]}/${mediaParts[2]}`
|
||||
const unsignedHash = hasher.h64(serverAndMediaID)
|
||||
const signedHash = unsignedHash - 0x8000000000000000n // shifting down to signed 64-bit range
|
||||
db.prepare("INSERT OR IGNORE INTO media_proxy (permitted_hash) VALUES (?)").run(signedHash)
|
||||
|
||||
return `${reg.ooye.bridge_origin}/download/matrix/${serverAndMediaID}`
|
||||
}
|
||||
|
||||
module.exports.BLOCK_ELEMENTS = BLOCK_ELEMENTS
|
||||
module.exports.eventSenderIsFromDiscord = eventSenderIsFromDiscord
|
||||
module.exports.getPublicUrlForMxc = getPublicUrlForMxc
|
||||
module.exports.getEventIDHash = getEventIDHash
|
||||
module.exports.MatrixStringBuilder = MatrixStringBuilder
|
||||
module.exports.getViaServers = getViaServers
|
||||
module.exports.getViaServersQuery = getViaServersQuery
|
||||
178
src/m2d/converters/utils.test.js
Normal file
178
src/m2d/converters/utils.test.js
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
// @ts-check
|
||||
|
||||
const e = new Error("Custom error")
|
||||
|
||||
const {test} = require("supertape")
|
||||
const {eventSenderIsFromDiscord, getEventIDHash, MatrixStringBuilder, getViaServers} = require("./utils")
|
||||
const util = require("util")
|
||||
|
||||
/** @param {string[]} mxids */
|
||||
function joinedList(mxids) {
|
||||
/** @type {{[mxid: string]: {display_name: null, avatar_url: null}}} */
|
||||
const joined = {}
|
||||
for (const mxid of mxids) {
|
||||
joined[mxid] = {
|
||||
display_name: null,
|
||||
avatar_url: null
|
||||
}
|
||||
}
|
||||
return {joined}
|
||||
}
|
||||
|
||||
test("sender type: matrix user", t => {
|
||||
t.notOk(eventSenderIsFromDiscord("@cadence:cadence.moe"))
|
||||
})
|
||||
|
||||
test("sender type: ooye bot", t => {
|
||||
t.ok(eventSenderIsFromDiscord("@_ooye_bot:cadence.moe"))
|
||||
})
|
||||
|
||||
test("sender type: ooye puppet", t => {
|
||||
t.ok(eventSenderIsFromDiscord("@_ooye_sheep:cadence.moe"))
|
||||
})
|
||||
|
||||
test("event hash: hash is the same each time", t => {
|
||||
const eventID = "$example"
|
||||
t.equal(getEventIDHash(eventID), getEventIDHash(eventID))
|
||||
})
|
||||
|
||||
test("event hash: hash is different for different inputs", t => {
|
||||
t.notEqual(getEventIDHash("$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe1"), getEventIDHash("$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe2"))
|
||||
})
|
||||
|
||||
test("MatrixStringBuilder: add, addLine, add same text", t => {
|
||||
const gatewayMessage = {t: "MY_MESSAGE", d: {display: "Custom message data"}}
|
||||
let stackLines = e.stack?.split("\n")
|
||||
|
||||
const builder = new MatrixStringBuilder()
|
||||
builder.addLine("\u26a0 Bridged event from Discord not delivered", "\u26a0 <strong>Bridged event from Discord not delivered</strong>")
|
||||
builder.addLine(`Gateway event: ${gatewayMessage.t}`)
|
||||
builder.addLine(e.toString())
|
||||
if (stackLines) {
|
||||
stackLines = stackLines.slice(0, 2)
|
||||
stackLines[1] = stackLines[1].replace(/\\/g, "/").replace(/(\s*at ).*(\/m2d\/)/, "$1.$2")
|
||||
builder.addLine(`Error trace:`, `<details><summary>Error trace</summary>`)
|
||||
builder.add(`\n${stackLines.join("\n")}`, `<pre>${stackLines.join("\n")}</pre></details>`)
|
||||
}
|
||||
builder.addLine("", `<details><summary>Original payload</summary><pre>${util.inspect(gatewayMessage.d, false, 4, false)}</pre></details>`)
|
||||
|
||||
t.deepEqual(builder.get(), {
|
||||
msgtype: "m.text",
|
||||
body: "\u26a0 Bridged event from Discord not delivered"
|
||||
+ "\nGateway event: MY_MESSAGE"
|
||||
+ "\nError: Custom error"
|
||||
+ "\nError trace:"
|
||||
+ "\nError: Custom error"
|
||||
+ "\n at ./m2d/converters/utils.test.js:3:11)\n",
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: "\u26a0 <strong>Bridged event from Discord not delivered</strong>"
|
||||
+ "<br>Gateway event: MY_MESSAGE"
|
||||
+ "<br>Error: Custom error"
|
||||
+ "<br><details><summary>Error trace</summary><pre>Error: Custom error\n at ./m2d/converters/utils.test.js:3:11)</pre></details>"
|
||||
+ `<details><summary>Original payload</summary><pre>{ display: 'Custom message data' }</pre></details>`
|
||||
})
|
||||
})
|
||||
|
||||
test("MatrixStringBuilder: complete code coverage", t => {
|
||||
const builder = new MatrixStringBuilder()
|
||||
builder.add("Line 1")
|
||||
builder.addParagraph("Line 2")
|
||||
builder.add("Line 3")
|
||||
builder.addParagraph("Line 4")
|
||||
|
||||
t.deepEqual(builder.get(), {
|
||||
msgtype: "m.text",
|
||||
body: "Line 1\n\nLine 2Line 3\n\nLine 4",
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: "Line 1<p>Line 2</p>Line 3<p>Line 4</p>"
|
||||
})
|
||||
})
|
||||
|
||||
test("getViaServers: returns the server name if the room only has sim users", async t => {
|
||||
const result = await getViaServers("!baby", {
|
||||
getStateEvent: async () => ({}),
|
||||
getJoinedMembers: async () => joinedList(["@_ooye_bot:cadence.moe", "@_ooye_hazel:cadence.moe"])
|
||||
})
|
||||
t.deepEqual(result, ["cadence.moe"])
|
||||
})
|
||||
|
||||
test("getViaServers: also returns the most popular servers in order", async t => {
|
||||
const result = await getViaServers("!baby", {
|
||||
getStateEvent: async () => ({}),
|
||||
getJoinedMembers: async () => joinedList(["@_ooye_bot:cadence.moe", "@_ooye_hazel:cadence.moe", "@cadence:cadence.moe", "@singleuser:selfhosted.invalid", "@hazel:thecollective.invalid", "@june:thecollective.invalid"])
|
||||
})
|
||||
t.deepEqual(result, ["cadence.moe", "thecollective.invalid", "selfhosted.invalid"])
|
||||
})
|
||||
|
||||
test("getViaServers: does not return IP address servers", async t => {
|
||||
const result = await getViaServers("!baby", {
|
||||
getStateEvent: async () => ({}),
|
||||
getJoinedMembers: async () => joinedList(["@_ooye_bot:cadence.moe", "@_ooye_hazel:cadence.moe", "@cadence:45.77.232.172:8443", "@cadence:[::1]:8443", "@cadence:123example.456example.invalid"])
|
||||
})
|
||||
t.deepEqual(result, ["cadence.moe", "123example.456example.invalid"])
|
||||
})
|
||||
|
||||
test("getViaServers: also returns the highest power level user (100)", async t => {
|
||||
const result = await getViaServers("!baby", {
|
||||
getStateEvent: async () => ({
|
||||
users: {
|
||||
"@moderator:tractor.invalid": 50,
|
||||
"@singleuser:selfhosted.invalid": 100,
|
||||
"@_ooye_bot:cadence.moe": 100
|
||||
}
|
||||
}),
|
||||
getJoinedMembers: async () => joinedList(["@_ooye_bot:cadence.moe", "@_ooye_hazel:cadence.moe", "@cadence:cadence.moe", "@singleuser:selfhosted.invalid", "@hazel:thecollective.invalid", "@june:thecollective.invalid", "@moderator:tractor.invalid"])
|
||||
})
|
||||
t.deepEqual(result, ["cadence.moe", "selfhosted.invalid", "thecollective.invalid", "tractor.invalid"])
|
||||
})
|
||||
|
||||
test("getViaServers: also returns the highest power level user (50)", async t => {
|
||||
const result = await getViaServers("!baby", {
|
||||
getStateEvent: async () => ({
|
||||
users: {
|
||||
"@moderator:tractor.invalid": 50,
|
||||
"@_ooye_bot:cadence.moe": 100
|
||||
}
|
||||
}),
|
||||
getJoinedMembers: async () => joinedList(["@_ooye_bot:cadence.moe", "@_ooye_hazel:cadence.moe", "@cadence:cadence.moe", "@moderator:tractor.invalid", "@hazel:thecollective.invalid", "@june:thecollective.invalid", "@singleuser:selfhosted.invalid"])
|
||||
})
|
||||
t.deepEqual(result, ["cadence.moe", "tractor.invalid", "thecollective.invalid", "selfhosted.invalid"])
|
||||
})
|
||||
|
||||
test("getViaServers: returns at most 4 results", async t => {
|
||||
const result = await getViaServers("!baby", {
|
||||
getStateEvent: async () => ({
|
||||
users: {
|
||||
"@moderator:tractor.invalid": 50,
|
||||
"@singleuser:selfhosted.invalid": 100,
|
||||
"@_ooye_bot:cadence.moe": 100
|
||||
}
|
||||
}),
|
||||
getJoinedMembers: async () => joinedList(["@_ooye_bot:cadence.moe", "@_ooye_hazel:cadence.moe", "@cadence:cadence.moe", "@moderator:tractor.invalid", "@singleuser:selfhosted.invalid", "@hazel:thecollective.invalid", "@cadence:123example.456example.invalid"])
|
||||
})
|
||||
t.deepEqual(result.length, 4)
|
||||
})
|
||||
|
||||
test("getViaServers: returns results even when power levels can't be fetched", async t => {
|
||||
const result = await getViaServers("!baby", {
|
||||
getStateEvent: async () => {
|
||||
throw new Error("event not found or something")
|
||||
},
|
||||
getJoinedMembers: async () => joinedList(["@_ooye_bot:cadence.moe", "@_ooye_hazel:cadence.moe", "@cadence:cadence.moe", "@moderator:tractor.invalid", "@singleuser:selfhosted.invalid", "@hazel:thecollective.invalid", "@cadence:123example.456example.invalid"])
|
||||
})
|
||||
t.deepEqual(result.length, 4)
|
||||
})
|
||||
|
||||
test("getViaServers: only considers power levels of currently joined members", async t => {
|
||||
const result = await getViaServers("!baby", {
|
||||
getStateEvent: async () => ({
|
||||
users: {
|
||||
"@moderator:tractor.invalid": 50,
|
||||
"@former_moderator:missing.invalid": 100,
|
||||
"@_ooye_bot:cadence.moe": 100
|
||||
}
|
||||
}),
|
||||
getJoinedMembers: async () => joinedList(["@_ooye_bot:cadence.moe", "@_ooye_hazel:cadence.moe", "@cadence:cadence.moe", "@moderator:tractor.invalid", "@hazel:thecollective.invalid", "@june:thecollective.invalid", "@singleuser:selfhosted.invalid"])
|
||||
})
|
||||
t.deepEqual(result, ["cadence.moe", "tractor.invalid", "thecollective.invalid", "selfhosted.invalid"])
|
||||
})
|
||||
|
|
@ -7,8 +7,6 @@
|
|||
const util = require("util")
|
||||
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")
|
||||
|
||||
/** @type {import("./actions/send-event")} */
|
||||
const sendEvent = sync.require("./actions/send-event")
|
||||
|
|
@ -18,20 +16,14 @@ const addReaction = sync.require("./actions/add-reaction")
|
|||
const redact = sync.require("./actions/redact")
|
||||
/** @type {import("./actions/update-pins")}) */
|
||||
const updatePins = sync.require("./actions/update-pins")
|
||||
/** @type {import("./actions/vote")}) */
|
||||
const vote = sync.require("./actions/vote")
|
||||
/** @type {import("../matrix/matrix-command-handler")} */
|
||||
const matrixCommandHandler = sync.require("../matrix/matrix-command-handler")
|
||||
/** @type {import("../matrix/utils")} */
|
||||
const utils = sync.require("../matrix/utils")
|
||||
/** @type {import("./converters/utils")} */
|
||||
const utils = sync.require("./converters/utils")
|
||||
/** @type {import("../matrix/api")}) */
|
||||
const api = sync.require("../matrix/api")
|
||||
/** @type {import("../d2m/actions/create-room")} */
|
||||
const createRoom = sync.require("../d2m/actions/create-room")
|
||||
/** @type {import("../matrix/room-upgrade")} */
|
||||
const roomUpgrade = require("../matrix/room-upgrade")
|
||||
/** @type {import("../d2m/actions/retrigger")} */
|
||||
const retrigger = sync.require("../d2m/actions/retrigger")
|
||||
const {reg} = require("../matrix/read-registration")
|
||||
|
||||
let lastReportedEvent = 0
|
||||
|
|
@ -88,12 +80,6 @@ function stringifyErrorStack(err, depth = 0) {
|
|||
return collapsed;
|
||||
}
|
||||
|
||||
function printError(type, source, e, payload) {
|
||||
console.error(`Error while processing a ${type} ${source} event:`)
|
||||
console.error(e)
|
||||
console.dir(payload, {depth: null})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} roomID
|
||||
* @param {"Discord" | "Matrix"} source
|
||||
|
|
@ -102,9 +88,9 @@ function printError(type, source, e, payload) {
|
|||
* @param {any} payload
|
||||
*/
|
||||
async function sendError(roomID, source, type, e, payload) {
|
||||
if (source === "Matrix") {
|
||||
printError(type, source, e, payload)
|
||||
}
|
||||
console.error(`Error while processing a ${type} ${source} event:`)
|
||||
console.error(e)
|
||||
console.dir(payload, {depth: null})
|
||||
|
||||
if (Date.now() - lastReportedEvent < 5000) return null
|
||||
lastReportedEvent = Date.now()
|
||||
|
|
@ -135,10 +121,10 @@ async function sendError(roomID, source, type, e, payload) {
|
|||
|
||||
// Where
|
||||
const stack = stringifyErrorStack(e)
|
||||
builder.addLine(`Error trace:\n${stack}`, tag`<details><summary>Error trace</summary><pre>${stack}</pre></details>`)
|
||||
builder.addLine(`Error trace:\n${stack}`, `<details><summary>Error trace</summary><pre>${stack}</pre></details>`)
|
||||
|
||||
// How
|
||||
builder.addLine("", tag`<details><summary>Original payload</summary><pre>${util.inspect(payload, false, 4, false)}</pre></details>`)
|
||||
builder.addLine("", `<details><summary>Original payload</summary><pre>${util.inspect(payload, false, 4, false)}</pre></details>`)
|
||||
}
|
||||
|
||||
// Send
|
||||
|
|
@ -166,37 +152,34 @@ function guard(type, fn) {
|
|||
}
|
||||
}
|
||||
|
||||
const errorRetrySema = new Semaphore()
|
||||
|
||||
/**
|
||||
* @param {Ty.Event.Outer<Ty.Event.M_Reaction>} reactionEvent
|
||||
*/
|
||||
async function onRetryReactionAdd(reactionEvent) {
|
||||
const roomID = reactionEvent.room_id
|
||||
await errorRetrySema.request(async () => {
|
||||
const event = await api.getEvent(roomID, reactionEvent.content["m.relates_to"]?.event_id)
|
||||
const event = await api.getEvent(roomID, reactionEvent.content["m.relates_to"]?.event_id)
|
||||
|
||||
// Check that it's a real error from OOYE
|
||||
const error = event.content["moe.cadence.ooye.error"]
|
||||
if (event.sender !== `@${reg.sender_localpart}:${reg.ooye.server_name}` || !error) return
|
||||
// Check that it's a real error from OOYE
|
||||
const error = event.content["moe.cadence.ooye.error"]
|
||||
if (event.sender !== `@${reg.sender_localpart}:${reg.ooye.server_name}` || !error) return
|
||||
|
||||
// To stop people injecting misleading messages, the reaction needs to come from either the original sender or a room moderator
|
||||
if (reactionEvent.sender !== error.payload.sender) {
|
||||
// Check if it's a room moderator
|
||||
const {powers: {[reactionEvent.sender]: senderPower}, powerLevels} = await utils.getEffectivePower(roomID, [reactionEvent.sender], api)
|
||||
if (senderPower < (powerLevels.state_default ?? 50)) return
|
||||
}
|
||||
// To stop people injecting misleading messages, the reaction needs to come from either the original sender or a room moderator
|
||||
if (reactionEvent.sender !== event.sender) {
|
||||
// Check if it's a room moderator
|
||||
const powerLevelsStateContent = await api.getStateEvent(roomID, "m.room.power_levels", "")
|
||||
const powerLevel = powerLevelsStateContent.users?.[reactionEvent.sender] || 0
|
||||
if (powerLevel < 50) return
|
||||
}
|
||||
|
||||
// Retry
|
||||
if (error.source === "matrix") {
|
||||
as.emit(`type:${error.payload.type}`, error.payload)
|
||||
} else if (error.source === "discord") {
|
||||
discord.cloud.emit("event", error.payload)
|
||||
}
|
||||
// Retry
|
||||
if (error.source === "matrix") {
|
||||
as.emit(`type:${error.payload.type}`, error.payload)
|
||||
} else if (error.source === "discord") {
|
||||
discord.cloud.emit("event", error.payload)
|
||||
}
|
||||
|
||||
// Redact the error to stop people from executing multiple retries
|
||||
await api.redactEvent(roomID, event.event_id)
|
||||
}, roomID)
|
||||
// Redact the error to stop people from executing multiple retries
|
||||
await api.redactEvent(roomID, event.event_id)
|
||||
}
|
||||
|
||||
sync.addTemporaryListener(as, "type:m.room.message", guard("m.room.message",
|
||||
|
|
@ -211,7 +194,6 @@ async event => {
|
|||
// @ts-ignore
|
||||
await matrixCommandHandler.execute(event)
|
||||
}
|
||||
retrigger.messageFinishedBridging(event.event_id)
|
||||
await api.ackEvent(event)
|
||||
}))
|
||||
|
||||
|
|
@ -221,55 +203,6 @@ 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)
|
||||
await api.ackEvent(event)
|
||||
}))
|
||||
|
||||
sync.addTemporaryListener(as, "type:org.matrix.msc3381.poll.start", guard("org.matrix.msc3381.poll.start",
|
||||
/**
|
||||
* @param {Ty.Event.Outer_Org_Matrix_Msc3381_Poll_Start} event it is a org.matrix.msc3381.poll.start because that's what this listener is filtering for
|
||||
*/
|
||||
async event => {
|
||||
if (utils.eventSenderIsFromDiscord(event.sender)) return
|
||||
const messageResponses = await sendEvent.sendEvent(event)
|
||||
await api.ackEvent(event)
|
||||
}))
|
||||
|
||||
sync.addTemporaryListener(as, "type:org.matrix.msc3381.poll.response", guard("org.matrix.msc3381.poll.response",
|
||||
/**
|
||||
* @param {Ty.Event.Outer_Org_Matrix_Msc3381_Poll_Response} event it is a org.matrix.msc3381.poll.response because that's what this listener is filtering for
|
||||
*/
|
||||
async event => {
|
||||
if (utils.eventSenderIsFromDiscord(event.sender)) return
|
||||
await vote.updateVote(event) // Matrix votes can't be bridged, so all we do is store it in the database.
|
||||
await api.ackEvent(event)
|
||||
}))
|
||||
|
||||
sync.addTemporaryListener(as, "type:org.matrix.msc3381.poll.end", guard("org.matrix.msc3381.poll.end",
|
||||
/**
|
||||
* @param {Ty.Event.Outer_Org_Matrix_Msc3381_Poll_End} event it is a org.matrix.msc3381.poll.end because that's what this listener is filtering for
|
||||
*/
|
||||
async event => {
|
||||
if (utils.eventSenderIsFromDiscord(event.sender)) return
|
||||
const pollEventID = event.content["m.relates_to"]?.event_id
|
||||
if (!pollEventID) return // Validity check
|
||||
const messageID = select("event_message", "message_id", {event_id: pollEventID, event_type: "org.matrix.msc3381.poll.start", source: 0}).pluck().get()
|
||||
if (!messageID) return // Nothing can be done if the parent message was never bridged. Also, Discord-native polls cannot be ended by others, so this only works for polls started on Matrix.
|
||||
try {
|
||||
var pollEvent = await api.getEvent(event.room_id, pollEventID) // Poll start event must exist for this to be valid
|
||||
} catch (e) {
|
||||
return
|
||||
}
|
||||
|
||||
// According to the rules, the poll end is only allowed if it was sent by the poll starter, or by someone with redact powers.
|
||||
if (pollEvent.sender !== event.sender) {
|
||||
const {powerLevels, powers: {[event.sender]: enderPower}} = await utils.getEffectivePower(event.room_id, [event.sender], api)
|
||||
if (enderPower < (powerLevels.redact ?? 50)) {
|
||||
return // Not allowed
|
||||
}
|
||||
}
|
||||
|
||||
const messageResponses = await sendEvent.sendEvent(event)
|
||||
await api.ackEvent(event)
|
||||
}))
|
||||
|
|
@ -363,7 +296,15 @@ async event => {
|
|||
await api.ackEvent(event)
|
||||
}))
|
||||
|
||||
|
||||
function getFromInviteRoomState(inviteRoomState, nskey, key) {
|
||||
if (!Array.isArray(inviteRoomState)) return null
|
||||
for (const event of inviteRoomState) {
|
||||
if (event.type === nskey && event.state_key === "") {
|
||||
return event.content[key]
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
sync.addTemporaryListener(as, "type:m.space.child", guard("m.space.child",
|
||||
/**
|
||||
|
|
@ -371,18 +312,7 @@ sync.addTemporaryListener(as, "type:m.space.child", guard("m.space.child",
|
|||
*/
|
||||
async event => {
|
||||
if (Array.isArray(event.content.via) && event.content.via.length) { // space child is being added
|
||||
try {
|
||||
// try to join if able, it's okay if it doesn't want, bot will still respond to invites
|
||||
await api.joinRoom(event.state_key)
|
||||
// if autojoined a child space, store it in invite (otherwise the child space will be impossible to use with self-service in the future)
|
||||
const hierarchy = await api.getHierarchy(event.state_key, {limit: 1})
|
||||
const roomProperties = hierarchy.rooms?.[0]
|
||||
if (roomProperties?.room_id === event.state_key && roomProperties.room_type === "m.space" && roomProperties.name) {
|
||||
db.prepare("INSERT OR IGNORE INTO invite (mxid, room_id, type, name, topic, avatar) VALUES (?, ?, ?, ?, ?, ?)")
|
||||
.run(event.sender, event.state_key, roomProperties.room_type, roomProperties.name, roomProperties.topic, roomProperties.avatar_url)
|
||||
await updateMemberCachePowerLevels(event.state_key) // store privileged users in member_cache so they are also allowed to perform self-service
|
||||
}
|
||||
} catch (e) {}
|
||||
await api.joinRoom(event.state_key).catch(() => {}) // try to join if able, it's okay if it doesn't want, bot will still respond to invites
|
||||
}
|
||||
}))
|
||||
|
||||
|
|
@ -393,58 +323,47 @@ sync.addTemporaryListener(as, "type:m.room.member", guard("m.room.member",
|
|||
async event => {
|
||||
if (event.state_key[0] !== "@") return
|
||||
|
||||
if (event.state_key === utils.bot) {
|
||||
const upgraded = await roomUpgrade.onBotMembership(event, api, createRoom)
|
||||
if (upgraded) return
|
||||
}
|
||||
|
||||
if (event.content.membership === "invite" && event.state_key === utils.bot) {
|
||||
// Supposed to be here already?
|
||||
const guildID = select("guild_space", "guild_id", {space_id: event.room_id}).pluck().get()
|
||||
if (guildID) {
|
||||
await api.joinRoom(event.room_id)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.content.membership === "invite" && event.state_key === `@${reg.sender_localpart}:${reg.ooye.server_name}`) {
|
||||
// We were invited to a room. We should join, and register the invite details for future reference in web.
|
||||
try {
|
||||
var inviteRoomState = await api.getInviteState(event.room_id, event)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
return await api.leaveRoomWithReason(event.room_id, `I wasn't able to find out what this room is. Please report this as a bug. Check console for more details. (${e.toString()})`)
|
||||
}
|
||||
if (!inviteRoomState?.name) return await api.leaveRoomWithReason(event.room_id, `Please only invite me to rooms that have a name/avatar set. Update the room details and reinvite.`)
|
||||
const name = getFromInviteRoomState(event.unsigned?.invite_room_state, "m.room.name", "name")
|
||||
const topic = getFromInviteRoomState(event.unsigned?.invite_room_state, "m.room.topic", "topic")
|
||||
const avatar = getFromInviteRoomState(event.unsigned?.invite_room_state, "m.room.avatar", "url")
|
||||
const creationType = getFromInviteRoomState(event.unsigned?.invite_room_state, "m.room.create", "type")
|
||||
if (!name) return await api.leaveRoomWithReason(event.room_id, "Please only invite me to rooms that have a name/avatar set. Update the room details and reinvite!")
|
||||
await api.joinRoom(event.room_id)
|
||||
db.prepare("REPLACE INTO invite (mxid, room_id, type, name, topic, avatar) VALUES (?, ?, ?, ?, ?, ?)").run(event.sender, event.room_id, inviteRoomState.type, inviteRoomState.name, inviteRoomState.topic, inviteRoomState.avatar)
|
||||
if (inviteRoomState.avatar) utils.getPublicUrlForMxc(inviteRoomState.avatar) // make sure it's available in the media_proxy allowed URLs
|
||||
await updateMemberCachePowerLevels(event.room_id) // store privileged users in member_cache so they are also allowed to perform self-service
|
||||
db.prepare("INSERT OR IGNORE INTO invite (mxid, room_id, type, name, topic, avatar) VALUES (?, ?, ?, ?, ?, ?)").run(event.sender, event.room_id, creationType, name, topic, avatar)
|
||||
if (avatar) utils.getPublicUrlForMxc(avatar) // make sure it's available in the media_proxy allowed URLs
|
||||
}
|
||||
|
||||
if (utils.eventSenderIsFromDiscord(event.state_key)) return
|
||||
|
||||
if (event.content.membership === "leave" || event.content.membership === "ban") {
|
||||
// Member is gone
|
||||
db.prepare("DELETE FROM member_cache WHERE room_id = ? and mxid = ?").run(event.room_id, event.state_key)
|
||||
|
||||
// Unregister room's use as a direct chat and/or an invite target if the bot itself left
|
||||
if (event.state_key === utils.bot) {
|
||||
// Unregister room's use as a direct chat if the bot itself left
|
||||
const bot = `@${reg.sender_localpart}:${reg.ooye.server_name}`
|
||||
if (event.state_key === bot) {
|
||||
db.prepare("DELETE FROM direct WHERE room_id = ?").run(event.room_id)
|
||||
db.prepare("DELETE FROM invite WHERE room_id = ?").run(event.room_id)
|
||||
}
|
||||
}
|
||||
|
||||
if (utils.eventSenderIsFromDiscord(event.state_key)) return
|
||||
|
||||
const exists = select("channel_room", "room_id", {room_id: event.room_id}) ?? select("guild_space", "space_id", {space_id: event.room_id})
|
||||
if (!exists) return // don't cache members in unbridged rooms
|
||||
|
||||
// Member is here
|
||||
let {powers: {[event.state_key]: memberPower}, tombstone} = await utils.getEffectivePower(event.room_id, [event.state_key], api)
|
||||
if (memberPower === Infinity) memberPower = tombstone // database storage compatibility
|
||||
let powerLevel = 0
|
||||
try {
|
||||
/** @type {Ty.Event.M_Power_Levels} */
|
||||
const powerLevelsEvent = await api.getStateEvent(event.room_id, "m.room.power_levels", "")
|
||||
powerLevel = powerLevelsEvent.users?.[event.state_key] ?? powerLevelsEvent.users_default ?? 0
|
||||
} catch (e) {}
|
||||
const displayname = event.content.displayname || null
|
||||
const avatar_url = event.content.avatar_url
|
||||
db.prepare("INSERT INTO member_cache (room_id, mxid, displayname, avatar_url, power_level) VALUES (?, ?, ?, ?, ?) ON CONFLICT DO UPDATE SET displayname = ?, avatar_url = ?, power_level = ?, missing_profile = NULL").run(
|
||||
db.prepare("INSERT INTO member_cache (room_id, mxid, displayname, avatar_url, power_level) VALUES (?, ?, ?, ?, ?) ON CONFLICT DO UPDATE SET displayname = ?, avatar_url = ?, power_level = ?").run(
|
||||
event.room_id, event.state_key,
|
||||
displayname, avatar_url, memberPower,
|
||||
displayname, avatar_url, memberPower
|
||||
displayname, avatar_url, powerLevel,
|
||||
displayname, avatar_url, powerLevel
|
||||
)
|
||||
}))
|
||||
|
||||
|
|
@ -454,35 +373,12 @@ sync.addTemporaryListener(as, "type:m.room.power_levels", guard("m.room.power_le
|
|||
*/
|
||||
async event => {
|
||||
if (event.state_key !== "") return
|
||||
await updateMemberCachePowerLevels(event.room_id)
|
||||
}))
|
||||
|
||||
/**
|
||||
* @param {string} roomID
|
||||
*/
|
||||
async function updateMemberCachePowerLevels(roomID) {
|
||||
const existingPower = select("member_cache", "mxid", {room_id: roomID}).pluck().all()
|
||||
const {powerLevels, allCreators, tombstone} = await utils.getEffectivePower(roomID, [], api)
|
||||
const newPower = powerLevels.users || {}
|
||||
const newPowerUsers = Object.keys(newPower)
|
||||
const relevantUsers = existingPower.concat(newPowerUsers).concat(allCreators)
|
||||
for (const mxid of [...new Set(relevantUsers)]) {
|
||||
const level = allCreators.includes(mxid) ? tombstone : newPower[mxid] ?? powerLevels.users_default ?? 0
|
||||
db.prepare("INSERT INTO member_cache (room_id, mxid, power_level, missing_profile) VALUES (?, ?, ?, 1) ON CONFLICT DO UPDATE SET power_level = ?")
|
||||
.run(roomID, mxid, level, level)
|
||||
const existingPower = select("member_cache", "mxid", {room_id: event.room_id}).pluck().all()
|
||||
const newPower = event.content.users || {}
|
||||
for (const mxid of existingPower) {
|
||||
db.prepare("UPDATE member_cache SET power_level = ? WHERE room_id = ? AND mxid = ?").run(newPower[mxid] || 0, event.room_id, mxid)
|
||||
}
|
||||
}
|
||||
|
||||
sync.addTemporaryListener(as, "type:m.room.tombstone", guard("m.room.tombstone",
|
||||
/**
|
||||
* @param {Ty.Event.StateOuter<Ty.Event.M_Room_Tombstone>} event
|
||||
*/
|
||||
async event => {
|
||||
if (event.state_key !== "") return
|
||||
if (!event.content.replacement_room) return
|
||||
await roomUpgrade.onTombstone(event, api)
|
||||
}))
|
||||
|
||||
module.exports.stringifyErrorStack = stringifyErrorStack
|
||||
module.exports.sendError = sendError
|
||||
module.exports.printError = printError
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ const assert = require("assert").strict
|
|||
const streamWeb = require("stream/web")
|
||||
|
||||
const passthrough = require("../passthrough")
|
||||
const {sync, db, select} = passthrough
|
||||
const {sync} = passthrough
|
||||
/** @type {import("./mreq")} */
|
||||
const mreq = sync.require("./mreq")
|
||||
/** @type {import("./txnid")} */
|
||||
|
|
@ -44,7 +44,6 @@ async function register(username) {
|
|||
try {
|
||||
await mreq.mreq("POST", "/client/v3/register", {
|
||||
type: "m.login.application_service",
|
||||
inhibit_login: true, // https://github.com/element-hq/matrix-bot-sdk/pull/70/changes https://github.com/matrix-org/matrix-spec-proposals/blob/quenting/as-device-management/proposals/4190-as-device-management.md
|
||||
username
|
||||
})
|
||||
} catch (e) {
|
||||
|
|
@ -79,17 +78,9 @@ async function joinRoom(roomIDOrAlias, mxid, via) {
|
|||
}
|
||||
|
||||
async function inviteToRoom(roomID, mxidToInvite, mxid) {
|
||||
try {
|
||||
await mreq.mreq("POST", path(`/client/v3/rooms/${roomID}/invite`, mxid), {
|
||||
user_id: mxidToInvite
|
||||
})
|
||||
} catch (e) {
|
||||
if (e.message.includes("is already in the room.") || e.message.includes("cannot invite user that is joined")) {
|
||||
// Sweet!
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
await mreq.mreq("POST", path(`/client/v3/rooms/${roomID}/invite`, mxid), {
|
||||
user_id: mxidToInvite
|
||||
})
|
||||
}
|
||||
|
||||
async function leaveRoom(roomID, mxid) {
|
||||
|
|
@ -130,20 +121,7 @@ async function getEventForTimestamp(roomID, ts) {
|
|||
|
||||
/**
|
||||
* @param {string} roomID
|
||||
* @param {"b" | "f"} dir
|
||||
* @param {{from?: string, limit?: any}} [pagination]
|
||||
* @param {any} [filter]
|
||||
*/
|
||||
async function getEvents(roomID, dir, pagination = {}, filter) {
|
||||
filter = filter && JSON.stringify(filter)
|
||||
/** @type {Ty.MessagesPagination<Ty.Event.Outer<any>>} */
|
||||
const root = await mreq.mreq("GET", path(`/client/v3/rooms/${roomID}/messages`, null, {...pagination, dir, filter}))
|
||||
return root
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} roomID
|
||||
* @returns {Promise<Ty.Event.StateOuter<any>[]>}
|
||||
* @returns {Promise<Ty.Event.BaseStateEvent[]>}
|
||||
*/
|
||||
function getAllState(roomID) {
|
||||
return mreq.mreq("GET", `/client/v3/rooms/${roomID}/state`)
|
||||
|
|
@ -159,97 +137,6 @@ function getStateEvent(roomID, type, key) {
|
|||
return mreq.mreq("GET", `/client/v3/rooms/${roomID}/state/${type}/${key}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} roomID
|
||||
* @param {string} type
|
||||
* @param {string} key
|
||||
* @returns {Promise<Ty.Event.StateOuter<any>>} the entire state event
|
||||
*/
|
||||
function getStateEventOuter(roomID, type, key) {
|
||||
return mreq.mreq("GET", `/client/v3/rooms/${roomID}/state/${type}/${key}?format=event`)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} roomID
|
||||
* @param {{unsigned?: {invite_room_state?: Ty.Event.InviteStrippedState[]}}} [event]
|
||||
* @returns {Promise<{name: string?, topic: string?, avatar: string?, type: string?}>}
|
||||
*/
|
||||
async function getInviteState(roomID, event) {
|
||||
function getFromInviteRoomState(strippedState, nskey, key) {
|
||||
if (!Array.isArray(strippedState)) return null
|
||||
for (const event of strippedState) {
|
||||
if (event.type === nskey && event.state_key === "") {
|
||||
return event.content[key]
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Try extracting from event (if passed)
|
||||
if (Array.isArray(event?.unsigned?.invite_room_state) && event.unsigned.invite_room_state.length) {
|
||||
return {
|
||||
name: getFromInviteRoomState(event.unsigned.invite_room_state, "m.room.name", "name"),
|
||||
topic: getFromInviteRoomState(event.unsigned.invite_room_state, "m.room.topic", "topic"),
|
||||
avatar: getFromInviteRoomState(event.unsigned.invite_room_state, "m.room.avatar", "url"),
|
||||
type: getFromInviteRoomState(event.unsigned.invite_room_state, "m.room.create", "type")
|
||||
}
|
||||
}
|
||||
|
||||
// Try calling sliding sync API and extracting from stripped state
|
||||
let root
|
||||
try {
|
||||
/** @type {Ty.R.SSS} */
|
||||
root = await mreq.mreq("POST", path("/client/unstable/org.matrix.simplified_msc3575/sync", `@${reg.sender_localpart}:${reg.ooye.server_name}`, {timeout: "0"}), {
|
||||
lists: {
|
||||
a: {
|
||||
ranges: [[0, 999]],
|
||||
timeline_limit: 0,
|
||||
required_state: [],
|
||||
filters: {
|
||||
is_invite: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Extract from sliding sync response if valid (seems to be okay on Synapse, Tuwunel and Continuwuity at time of writing)
|
||||
if ("lists" in root) {
|
||||
if (!root.rooms?.[roomID]) {
|
||||
const e = new Error("Room data unavailable via SSS")
|
||||
e["data_sss"] = root
|
||||
throw e
|
||||
}
|
||||
|
||||
const roomResponse = root.rooms[roomID]
|
||||
const strippedState = "stripped_state" in roomResponse ? roomResponse.stripped_state : roomResponse.invite_state
|
||||
|
||||
return {
|
||||
name: getFromInviteRoomState(strippedState, "m.room.name", "name"),
|
||||
topic: getFromInviteRoomState(strippedState, "m.room.topic", "topic"),
|
||||
avatar: getFromInviteRoomState(strippedState, "m.room.avatar", "url"),
|
||||
type: getFromInviteRoomState(strippedState, "m.room.create", "type")
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
// Invalid sliding sync response, try alternative (required for Conduit at time of writing)
|
||||
const hierarchy = await getHierarchy(roomID, {limit: 1})
|
||||
if (hierarchy?.rooms?.[0]?.room_id === roomID) {
|
||||
const room = hierarchy?.rooms?.[0]
|
||||
return {
|
||||
name: room.name ?? null,
|
||||
topic: room.topic ?? null,
|
||||
avatar: room.avatar_url ?? null,
|
||||
type: room.room_type ?? null
|
||||
}
|
||||
}
|
||||
|
||||
const e = new Error("Room data unavailable via SSS/hierarchy")
|
||||
e["data_sss"] = root
|
||||
e["data_hierarchy"] = hierarchy
|
||||
throw e
|
||||
}
|
||||
|
||||
/**
|
||||
* "Any of the AS's users must be in the room. This API is primarily for Application Services and should be faster to respond than /members as it can be implemented more efficiently on the server."
|
||||
* @param {string} roomID
|
||||
|
|
@ -412,33 +299,64 @@ async function sendTyping(roomID, isTyping, mxid, duration) {
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} mxid
|
||||
* @param {string} displayname
|
||||
* @param {boolean} [inhibitPropagate]
|
||||
*/
|
||||
async function profileSetDisplayname(mxid, displayname, inhibitPropagate) {
|
||||
const params = {}
|
||||
if (inhibitPropagate) params["org.matrix.msc4069.propagate"] = false
|
||||
await mreq.mreq("PUT", path(`/client/v3/profile/${mxid}/displayname`, mxid, params), {
|
||||
async function profileSetDisplayname(mxid, displayname) {
|
||||
await mreq.mreq("PUT", path(`/client/v3/profile/${mxid}/displayname`, mxid), {
|
||||
displayname
|
||||
})
|
||||
}
|
||||
|
||||
async function profileSetAvatarUrl(mxid, avatar_url) {
|
||||
await mreq.mreq("PUT", path(`/client/v3/profile/${mxid}/avatar_url`, mxid), {
|
||||
avatar_url
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a user's power level within a room.
|
||||
* @param {string} roomID
|
||||
* @param {string} mxid
|
||||
* @param {string | null | undefined} avatar_url
|
||||
* @param {boolean} [inhibitPropagate]
|
||||
* @param {number} newPower
|
||||
*/
|
||||
async function profileSetAvatarUrl(mxid, avatar_url, inhibitPropagate) {
|
||||
const params = {}
|
||||
if (inhibitPropagate) params["org.matrix.msc4069.propagate"] = false
|
||||
if (avatar_url) {
|
||||
await mreq.mreq("PUT", path(`/client/v3/profile/${mxid}/avatar_url`, mxid, params), {
|
||||
avatar_url
|
||||
})
|
||||
async function setUserPower(roomID, mxid, newPower) {
|
||||
assert(roomID[0] === "!")
|
||||
assert(mxid[0] === "@")
|
||||
// Yes there's no shortcut https://github.com/matrix-org/matrix-appservice-bridge/blob/2334b0bae28a285a767fe7244dad59f5a5963037/src/components/intent.ts#L352
|
||||
const power = await getStateEvent(roomID, "m.room.power_levels", "")
|
||||
power.users = power.users || {}
|
||||
|
||||
// Check if it has really changed to avoid sending a useless state event
|
||||
// (Can't diff kstate here because of (a) circular imports (b) kstate has special behaviour diffing power levels)
|
||||
const oldPowerLevel = power.users?.[mxid] ?? power.users_default ?? 0
|
||||
if (oldPowerLevel === newPower) return
|
||||
|
||||
// Bridge bot can't demote equal power users, so need to decide which user will send the event
|
||||
const botPowerLevel = power.users?.[`@${reg.sender_localpart}:${reg.ooye.server_name}`] ?? power.users_default ?? 0
|
||||
const eventSender = oldPowerLevel >= botPowerLevel ? mxid : undefined
|
||||
|
||||
// Update the event content
|
||||
if (newPower == null || newPower === (power.users_default ?? 0)) {
|
||||
delete power.users[mxid]
|
||||
} else {
|
||||
await mreq.mreq("DELETE", path(`/client/v3/profile/${mxid}/avatar_url`, mxid, params))
|
||||
power.users[mxid] = newPower
|
||||
}
|
||||
|
||||
await sendState(roomID, "m.room.power_levels", "", power, eventSender)
|
||||
return power
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a user's power level for a whole room hierarchy.
|
||||
* @param {string} spaceID
|
||||
* @param {string} mxid
|
||||
* @param {number} power
|
||||
*/
|
||||
async function setUserPowerCascade(spaceID, mxid, power) {
|
||||
assert(spaceID[0] === "!")
|
||||
assert(mxid[0] === "@")
|
||||
const rooms = await getFullHierarchy(spaceID)
|
||||
await setUserPower(spaceID, mxid, power)
|
||||
for (const room of rooms) {
|
||||
await setUserPower(room.room_id, mxid, power)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -460,26 +378,19 @@ async function ping() {
|
|||
}
|
||||
|
||||
/**
|
||||
* Given an mxc:// URL, and an optional height for thumbnailing, get the file from the content repository. Returns res.
|
||||
* @param {string} mxc
|
||||
* @param {RequestInit & {height?: number | string}} [init]
|
||||
* @param {RequestInit} [init]
|
||||
* @return {Promise<Response & {body: streamWeb.ReadableStream<Uint8Array>}>}
|
||||
*/
|
||||
async function getMedia(mxc, 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)})
|
||||
const res = await fetch(url, {
|
||||
const res = await fetch(`${mreq.baseUrl}/client/v1/media/download/${mediaParts[1]}/${mediaParts[2]}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${reg.as_token}`
|
||||
},
|
||||
...init
|
||||
})
|
||||
if (res.status !== 200) {
|
||||
throw await mreq.makeMatrixServerError(res, {...init, url})
|
||||
}
|
||||
if (init.method !== "HEAD") {
|
||||
assert(res.body)
|
||||
}
|
||||
|
|
@ -554,40 +465,6 @@ function getProfile(mxid) {
|
|||
return mreq.mreq("GET", `/client/v3/profile/${mxid}`)
|
||||
}
|
||||
|
||||
function versions() {
|
||||
return mreq.mreq("GET", "/client/versions")
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} mxid
|
||||
*/
|
||||
async function usePrivateChat(mxid) {
|
||||
// Check if we have an existing DM
|
||||
let roomID = select("direct", "room_id", {mxid}).pluck().get()
|
||||
if (roomID) {
|
||||
// Check that the person is/still in the room
|
||||
try {
|
||||
var member = await getStateEvent(roomID, "m.room.member", mxid)
|
||||
} catch (e) {}
|
||||
|
||||
// Invite them back to the room if needed
|
||||
if (!member || member.membership === "leave") {
|
||||
await inviteToRoom(roomID, mxid)
|
||||
}
|
||||
return roomID
|
||||
}
|
||||
|
||||
// No existing DM, create a new room and invite
|
||||
roomID = await createRoom({
|
||||
invite: [mxid],
|
||||
is_direct: true,
|
||||
preset: "trusted_private_chat"
|
||||
})
|
||||
// Store the newly created room in the database (not using account data due to awkward bugs with misaligned state)
|
||||
db.prepare("REPLACE INTO direct (mxid, room_id) VALUES (?, ?)").run(mxid, roomID)
|
||||
return roomID
|
||||
}
|
||||
|
||||
module.exports.path = path
|
||||
module.exports.register = register
|
||||
module.exports.createRoom = createRoom
|
||||
|
|
@ -597,11 +474,8 @@ module.exports.leaveRoom = leaveRoom
|
|||
module.exports.leaveRoomWithReason = leaveRoomWithReason
|
||||
module.exports.getEvent = getEvent
|
||||
module.exports.getEventForTimestamp = getEventForTimestamp
|
||||
module.exports.getEvents = getEvents
|
||||
module.exports.getAllState = getAllState
|
||||
module.exports.getStateEvent = getStateEvent
|
||||
module.exports.getStateEventOuter = getStateEventOuter
|
||||
module.exports.getInviteState = getInviteState
|
||||
module.exports.getJoinedMembers = getJoinedMembers
|
||||
module.exports.getMembers = getMembers
|
||||
module.exports.getHierarchy = getHierarchy
|
||||
|
|
@ -615,6 +489,8 @@ module.exports.redactEvent = redactEvent
|
|||
module.exports.sendTyping = sendTyping
|
||||
module.exports.profileSetDisplayname = profileSetDisplayname
|
||||
module.exports.profileSetAvatarUrl = profileSetAvatarUrl
|
||||
module.exports.setUserPower = setUserPower
|
||||
module.exports.setUserPowerCascade = setUserPowerCascade
|
||||
module.exports.ping = ping
|
||||
module.exports.getMedia = getMedia
|
||||
module.exports.sendReadReceipt = sendReadReceipt
|
||||
|
|
@ -624,5 +500,3 @@ module.exports.getAccountData = getAccountData
|
|||
module.exports.setAccountData = setAccountData
|
||||
module.exports.setPresence = setPresence
|
||||
module.exports.getProfile = getProfile
|
||||
module.exports.versions = versions
|
||||
module.exports.usePrivateChat = usePrivateChat
|
||||
|
|
|
|||
|
|
@ -3,5 +3,6 @@
|
|||
const {reg} = require("../matrix/read-registration")
|
||||
const {AppService} = require("@cloudrac3r/in-your-element")
|
||||
const as = new AppService(reg)
|
||||
as.listen()
|
||||
|
||||
module.exports.as = as
|
||||
|
|
|
|||
|
|
@ -103,9 +103,9 @@ function memberAvatar(guildID, user, member) {
|
|||
}
|
||||
|
||||
function emoji(emojiID, animated) {
|
||||
const base = `/emojis/${emojiID}.webp`
|
||||
if (animated) return base + "?animated=true"
|
||||
else return base
|
||||
const base = `/emojis/${emojiID}`
|
||||
if (animated) return base + ".gif"
|
||||
else return base + ".png"
|
||||
}
|
||||
|
||||
const stickerFormat = new Map([
|
||||
|
|
|
|||
|
|
@ -10,8 +10,6 @@ const {sync} = passthrough
|
|||
const file = sync.require("./file")
|
||||
/** @type {import("./api")} */
|
||||
const api = sync.require("./api")
|
||||
/** @type {import("./utils")} */
|
||||
const utils = sync.require("./utils")
|
||||
|
||||
/** Mutates the input. Not recursive - can only include or exclude entire state events. */
|
||||
function kstateStripConditionals(kstate) {
|
||||
|
|
@ -47,7 +45,7 @@ async function kstateUploadMxc(obj) {
|
|||
return obj
|
||||
}
|
||||
|
||||
/** Automatically strips conditionals and uploads URLs to mxc. m.room.create is removed. */
|
||||
/** Automatically strips conditionals and uploads URLs to mxc. */
|
||||
async function kstateToState(kstate) {
|
||||
const events = []
|
||||
kstateStripConditionals(kstate)
|
||||
|
|
@ -57,30 +55,19 @@ async function kstateToState(kstate) {
|
|||
assert(slashIndex > 0)
|
||||
const type = k.slice(0, slashIndex)
|
||||
const state_key = k.slice(slashIndex + 1)
|
||||
if (type === "m.room.create") continue
|
||||
events.push({type, state_key, content})
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
/** Extracts m.room.create for use in room creation_content. */
|
||||
function kstateToCreationContent(kstate) {
|
||||
return kstate["m.room.create/"] || {}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("../types").Event.StateOuter<any>[]} events
|
||||
* @param {import("../types").Event.BaseStateEvent[]} events
|
||||
* @returns {any}
|
||||
*/
|
||||
function stateToKState(events) {
|
||||
const kstate = {}
|
||||
for (const event of events) {
|
||||
kstate[event.type + "/" + event.state_key] = event.content
|
||||
|
||||
// need to remember m.room.create sender for later...
|
||||
if (event.type === "m.room.create" && event.state_key === "") {
|
||||
kstate["m.room.create/outer"] = event
|
||||
}
|
||||
}
|
||||
return kstate
|
||||
}
|
||||
|
|
@ -94,27 +81,15 @@ function diffKState(actual, target) {
|
|||
if (key === "m.room.power_levels/") {
|
||||
// Special handling for power levels, we want to deep merge the actual and target into the final state.
|
||||
if (!(key in actual)) throw new Error(`want to apply a power levels diff, but original power level data is missing\nstarted with: ${JSON.stringify(actual)}\nwant to apply: ${JSON.stringify(target)}`)
|
||||
// if the diff includes users, it needs to be cleaned wrt room version 12
|
||||
const cleanedTarget = mixin({}, target[key])
|
||||
if (target[key].users && Object.keys(target[key].users).length > 0) {
|
||||
assert("m.room.create/" in actual, `want to apply a power levels diff, but original m.room.create/ is missing\nstarted with: ${JSON.stringify(actual)}\nwant to apply: ${JSON.stringify(target)}`)
|
||||
assert("m.room.create/outer" in actual, `want to apply a power levels diff, but original m.room.create/outer is missing\nstarted with: ${JSON.stringify(actual)}\nwant to apply: ${JSON.stringify(target)}`)
|
||||
utils.removeCreatorsFromPowerLevels(actual["m.room.create/outer"], cleanedTarget)
|
||||
}
|
||||
const mixedTarget = mixin({}, actual[key], cleanedTarget)
|
||||
if (!isDeepStrictEqual(actual[key], mixedTarget)) {
|
||||
const temp = mixin({}, actual[key], target[key])
|
||||
if (!isDeepStrictEqual(actual[key], temp)) {
|
||||
// they differ. use the newly prepared object as the diff.
|
||||
diff[key] = mixedTarget
|
||||
diff[key] = temp
|
||||
}
|
||||
|
||||
} else if (key === "m.room.create/") {
|
||||
// can't be modified - only for kstateToCreationContent
|
||||
|
||||
} else if (key === "m.room.topic/") {
|
||||
// synapse generates different m.room.topic events on original creation
|
||||
// https://github.com/element-hq/synapse/blob/0f2b29511fd88d1dc2278f41fd6e4e2f2989fcb7/synapse/handlers/room.py#L1729
|
||||
// diff the `topic` to determine change
|
||||
if (!(key in actual) || actual[key].topic !== target[key].topic) {
|
||||
} else if (key === "chat.schildi.hide_ui/read_receipts") {
|
||||
// Special handling: don't add this key if it's new. Do overwrite if already present.
|
||||
if (key in actual) {
|
||||
diff[key] = target[key]
|
||||
}
|
||||
|
||||
|
|
@ -140,22 +115,10 @@ function diffKState(actual, target) {
|
|||
/**
|
||||
* Async because it gets all room state from the homeserver.
|
||||
* @param {string} roomID
|
||||
* @param {[type: string, key: string][]} [limitToEvents]
|
||||
*/
|
||||
async function roomToKState(roomID, limitToEvents) {
|
||||
if (!limitToEvents) {
|
||||
const root = await api.getAllState(roomID)
|
||||
return stateToKState(root)
|
||||
} else {
|
||||
const root = []
|
||||
await Promise.all(limitToEvents.map(async ([type, key]) => {
|
||||
try {
|
||||
const outer = await api.getStateEventOuter(roomID, type, key)
|
||||
root.push(outer)
|
||||
} catch (e) {}
|
||||
}))
|
||||
return stateToKState(root)
|
||||
}
|
||||
async function roomToKState(roomID) {
|
||||
const root = await api.getAllState(roomID)
|
||||
return stateToKState(root)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -172,7 +135,6 @@ async function applyKStateDiffToRoom(roomID, kstate) {
|
|||
module.exports.kstateStripConditionals = kstateStripConditionals
|
||||
module.exports.kstateUploadMxc = kstateUploadMxc
|
||||
module.exports.kstateToState = kstateToState
|
||||
module.exports.kstateToCreationContent = kstateToCreationContent
|
||||
module.exports.stateToKState = stateToKState
|
||||
module.exports.diffKState = diffKState
|
||||
module.exports.roomToKState = roomToKState
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue