Compare commits
44 commits
v3.0-beta1
...
main
Author | SHA1 | Date | |
---|---|---|---|
a63d173a9a | |||
07d6eb3c12 | |||
15e5b17b0d | |||
14115c0e06 | |||
0d8b9d5705 | |||
1b539cfa64 | |||
b23b818192 | |||
49948ae2c1 | |||
ac165845d7 | |||
cce432aeee | |||
e5f7c7fdcb | |||
4167a01ed1 | |||
c127923f4d | |||
da5525a542 | |||
6f7ed829b8 | |||
5a86c07eb9 | |||
4287a329f5 | |||
086e8cdc25 | |||
9f9d1f615e | |||
3662ee5db6 | |||
d72b162fe7 | |||
b79b010568 | |||
f77602afa6 | |||
f5853ccf95 | |||
33915a595d | |||
61803c3838 | |||
bad8c5b8c2 | |||
65170c1282 | |||
5dbd79cf39 | |||
cf756cb0af | |||
034f8d6b55 | |||
0e6e5e61e4 | |||
bac2deb32f | |||
d629e666db | |||
69c93ca9d9 | |||
8743910c35 | |||
d6de57f0c3 | |||
734c9a5838 | |||
b0a0e62a86 | |||
312ea69d73 | |||
3af31385f0 | |||
8915e8d96c | |||
af68657ec4 | |||
dbbb8281e6 |
61 changed files with 2079 additions and 489 deletions
|
@ -9,6 +9,7 @@ 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=1610883072 `
|
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 */
|
||||||
if (process.argv.find(a => a.endsWith("addbot") || a.endsWith("addbot.js"))) {
|
if (process.argv.find(a => a.endsWith("addbot") || a.endsWith("addbot.js"))) {
|
||||||
console.log(addbot())
|
console.log(addbot())
|
||||||
}
|
}
|
||||||
|
|
74
docs/self-service-room-creation-rules.md
Normal file
74
docs/self-service-room-creation-rules.md
Normal file
|
@ -0,0 +1,74 @@
|
||||||
|
# Self-service room creation rules
|
||||||
|
|
||||||
|
Before version 3 of Out Of Your Element, new Matrix rooms would be created on-demand when a Discord channel is spoken in for the first time. This has worked pretty well.
|
||||||
|
|
||||||
|
This is done through functions like ensureRoom and ensureSpace in actions:
|
||||||
|
|
||||||
|
```js
|
||||||
|
async function sendMessage(message, channel, guild, row) {
|
||||||
|
const roomID = await createRoom.ensureRoom(message.channel_id)
|
||||||
|
...
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensures the room exists. If it doesn't, creates the room with an accurate initial state.
|
||||||
|
* @param {string} channelID
|
||||||
|
* @returns {Promise<string>} Matrix room ID
|
||||||
|
*/
|
||||||
|
function ensureRoom(channelID) {
|
||||||
|
return _syncRoom(channelID, /* shouldActuallySync */ false) /* calls ensureSpace */
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensures the space exists. If it doesn't, creates the space with an accurate initial state.
|
||||||
|
* @param {DiscordTypes.APIGuild} guild
|
||||||
|
* @returns {Promise<string>} Matrix space ID
|
||||||
|
*/
|
||||||
|
function ensureSpace(guild) {
|
||||||
|
return _syncSpace(guild, /* shouldActuallySync */ false)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
With the introduction of self-service mode, we still want to retain this as a possible mode of operation, since some people prefer to have OOYE handle this administrative work. However, other people prefer to manage the links between channels and rooms themselves, and have control over what new rooms get linked up to.
|
||||||
|
|
||||||
|
Visibly, this is managed through the web interface. The web interface lets moderators enable/disable auto-creation of new rooms, as well as set which channels and rooms are linked together.
|
||||||
|
|
||||||
|
There is a small complication. Not only are Matrix rooms created automatically, their Matrix spaces are also created automatically during room sync: ensureRoom calls ensureSpace. If a user opts in to self-service mode by clicking the specific button in the web portal, we must ensure the _space is not created automatically either,_ because the Matrix user will provide a space to link to.
|
||||||
|
|
||||||
|
To solve this, we need a way to suppress specific guilds from having auto-created spaces. The natural way to represent this is a column on guild_space, but that doesn't work, because each guild_space row requires a guild and space to be linked, and we _don't want_ them to be linked.
|
||||||
|
|
||||||
|
So, internally, OOYE keeps track of this through a new table:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE "guild_active" (
|
||||||
|
"guild_id" TEXT NOT NULL, -- only guilds that are bridged are present in this table
|
||||||
|
"autocreate" INTEGER NOT NULL, -- 0 or 1
|
||||||
|
PRIMARY KEY("guild_id")
|
||||||
|
) WITHOUT ROWID;
|
||||||
|
```
|
||||||
|
|
||||||
|
There is one more complication. When adding a Discord bot through web oauth with a redirect_uri, Discord adds the bot to the server normally, _then_ redirects back to OOYE, and only then does OOYE know which guild the bot was just added to. So, for a short time between the bot being added and the user being redirected, OOYE might receive Discord events in the server before it has the chance to create the guild_active database row.
|
||||||
|
|
||||||
|
So to prevent this, self-service behaviour needs to be an implicit default, and users must firmly choose one system or another to begin using OOYE. It is important for me to design this in a way that doesn't force users to do any extra work or make a choice they don't understand to keep the pre-v3 behaviour.
|
||||||
|
|
||||||
|
So there will be 3 states of whether a guild is self-service or not. At first, it could be absent from the table, in which case events for it will be dropped. Or it could be in the table with autocomplete = 0, in which case only rooms that already exist in channel_room will have messages bridged. Or it could have autocomplete = 1, in which case Matrix rooms will be created as needed, as per the pre-v3 behaviour.
|
||||||
|
|
||||||
|
| Auto-create | Meaning |
|
||||||
|
| -- | ------------ |
|
||||||
|
| 😶🌫️ | Unbridged - waiting |
|
||||||
|
| ❌ | Bridged - self-service |
|
||||||
|
| ✅ | Bridged - auto-create |
|
||||||
|
|
||||||
|
Pressing buttons on web or using the /invite command on a guild will insert a row into guild_active, allowing it to be bridged.
|
||||||
|
|
||||||
|
One more thing. Before v3, when a Matrix room was autocreated it would autocreate the space as well, if it needed to. But now, since nothing will be created until the user takes an action, the guild will always be created directly in response to a request. So room creation can now trust that the guild exists already.
|
||||||
|
|
||||||
|
So here's all the technical changes needed to support self-service in v3:
|
||||||
|
|
||||||
|
- New guild_active table showing whether, and how, a guild is bridged.
|
||||||
|
- When /invite command is used, INSERT OR IGNORE INTO state 1 and ensureRoom + ensureSpace.
|
||||||
|
- When bot is added through "easy mode" web button, REPLACE INTO state 1 and ensureSpace.
|
||||||
|
- When bot is added through "self-service" web button, REPLACE INTO state 0.
|
||||||
|
- Event dispatcher will only ensureRoom if the guild_active state is 1.
|
||||||
|
- createRoom can trust that the space exists because we check that in a calling function.
|
||||||
|
- createRoom will only create other dependencies if the guild is autocreate.
|
27
package-lock.json
generated
27
package-lock.json
generated
|
@ -29,7 +29,7 @@
|
||||||
"entities": "^5.0.0",
|
"entities": "^5.0.0",
|
||||||
"get-stream": "^6.0.1",
|
"get-stream": "^6.0.1",
|
||||||
"h3": "^1.12.0",
|
"h3": "^1.12.0",
|
||||||
"heatsync": "^2.5.3",
|
"heatsync": "^2.5.5",
|
||||||
"lru-cache": "^10.4.3",
|
"lru-cache": "^10.4.3",
|
||||||
"minimist": "^1.2.8",
|
"minimist": "^1.2.8",
|
||||||
"node-fetch": "^2.6.7",
|
"node-fetch": "^2.6.7",
|
||||||
|
@ -38,6 +38,7 @@
|
||||||
"snowtransfer": "^0.10.5",
|
"snowtransfer": "^0.10.5",
|
||||||
"stream-mime-type": "^1.0.2",
|
"stream-mime-type": "^1.0.2",
|
||||||
"try-to-catch": "^3.0.1",
|
"try-to-catch": "^3.0.1",
|
||||||
|
"uqr": "^0.1.2",
|
||||||
"xxhash-wasm": "^1.0.2",
|
"xxhash-wasm": "^1.0.2",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
|
@ -1217,9 +1218,9 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/better-sqlite3": {
|
"node_modules/better-sqlite3": {
|
||||||
"version": "11.2.1",
|
"version": "11.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.3.0.tgz",
|
||||||
"integrity": "sha512-Xbt1d68wQnUuFIEVsbt6V+RG30zwgbtCGQ4QOcXVrOH0FE4eHk64FWZ9NUfRHS4/x1PXqwz/+KOrnXD7f0WieA==",
|
"integrity": "sha512-iHt9j8NPYF3oKCNOO5ZI4JwThjt3Z6J6XrcwG85VNMVzv1ByqrHWv5VILEbCMFWDsoHhXvQ7oC8vgRXFAKgl9w==",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
@ -1633,9 +1634,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/discord-api-types": {
|
"node_modules/discord-api-types": {
|
||||||
"version": "0.37.98",
|
"version": "0.37.101",
|
||||||
"resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.37.98.tgz",
|
"resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.37.101.tgz",
|
||||||
"integrity": "sha512-xsH4UwmnCQl4KjAf01/p9ck9s+/vDqzHbUxPOBzo8fcVUa/hQG6qInD7Cr44KAuCM+xCxGJFSAUx450pBrX0+g==",
|
"integrity": "sha512-2wizd94t7G3A8U5Phr3AiuL4gSvhqistDwWnlk1VLTit8BI1jWUncFqFQNdPbHqS3661+Nx/iEyIwtVjPuBP3w==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/doctypes": {
|
"node_modules/doctypes": {
|
||||||
|
@ -1929,9 +1930,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/heatsync": {
|
"node_modules/heatsync": {
|
||||||
"version": "2.5.4",
|
"version": "2.5.5",
|
||||||
"resolved": "https://registry.npmjs.org/heatsync/-/heatsync-2.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/heatsync/-/heatsync-2.5.5.tgz",
|
||||||
"integrity": "sha512-KzsM+wR0MIykD80kCHNZCpNvFY4uC1Yze8R37eehJyGIvEepJd+7ubczh6FVoBFtK0nVEszt5Hl8AbzUvb+vMQ==",
|
"integrity": "sha512-Sy2/X2a69W2W1xgp7GBY81naHtWXxwV8N6uzPTJLQXgq4oTMJeL6F/AUlGS+fUa/Pt5ioxzi7gvd8THMJ3GpyA==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"backtracker": "^4.0.0"
|
"backtracker": "^4.0.0"
|
||||||
}
|
}
|
||||||
|
@ -3237,6 +3238,12 @@
|
||||||
"pathe": "^1.1.2"
|
"pathe": "^1.1.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/uqr": {
|
||||||
|
"version": "0.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/uqr/-/uqr-0.1.2.tgz",
|
||||||
|
"integrity": "sha512-MJu7ypHq6QasgF5YRTjqscSzQp/W11zoUk6kvmlH+fmWEs63Y0Eib13hYFwAzagRJcVY8WVnlV+eBDUGMJ5IbA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/util-deprecate": {
|
"node_modules/util-deprecate": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
|
|
|
@ -38,7 +38,7 @@
|
||||||
"entities": "^5.0.0",
|
"entities": "^5.0.0",
|
||||||
"get-stream": "^6.0.1",
|
"get-stream": "^6.0.1",
|
||||||
"h3": "^1.12.0",
|
"h3": "^1.12.0",
|
||||||
"heatsync": "^2.5.3",
|
"heatsync": "^2.5.5",
|
||||||
"lru-cache": "^10.4.3",
|
"lru-cache": "^10.4.3",
|
||||||
"minimist": "^1.2.8",
|
"minimist": "^1.2.8",
|
||||||
"node-fetch": "^2.6.7",
|
"node-fetch": "^2.6.7",
|
||||||
|
@ -47,6 +47,7 @@
|
||||||
"snowtransfer": "^0.10.5",
|
"snowtransfer": "^0.10.5",
|
||||||
"stream-mime-type": "^1.0.2",
|
"stream-mime-type": "^1.0.2",
|
||||||
"try-to-catch": "^3.0.1",
|
"try-to-catch": "^3.0.1",
|
||||||
|
"uqr": "^0.1.2",
|
||||||
"xxhash-wasm": "^1.0.2",
|
"xxhash-wasm": "^1.0.2",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
|
@ -61,9 +62,10 @@
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node start.js",
|
"start": "node start.js",
|
||||||
|
"setup": "node scripts/setup.js",
|
||||||
"addbot": "node addbot.js",
|
"addbot": "node addbot.js",
|
||||||
"test": "cross-env FORCE_COLOR=true supertape --no-check-assertions-count --format tap test/test.js | tap-dot",
|
"test": "cross-env FORCE_COLOR=true supertape --no-check-assertions-count --format tap test/test.js | tap-dot",
|
||||||
"test-slow": "cross-env FORCE_COLOR=true supertape --no-check-assertions-count --format tap --no-worker test/test.js -- --slow | tap-dot",
|
"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 matrix/file.js -x matrix/api.js -x matrix/mreq.js -x d2m/converters/rlottie-wasm.js -r html -r text supertape --no-check-assertions-count --format fail --no-worker test/test.js -- --slow"
|
"cover": "c8 -o test/coverage --skip-full -x db/migrations -x src/matrix/file.js -x src/matrix/api.js -x src/matrix/mreq.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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -81,9 +81,9 @@ Follow these steps:
|
||||||
|
|
||||||
1. Install dependencies: `npm install`
|
1. Install dependencies: `npm install`
|
||||||
|
|
||||||
1. Run `node scripts/seed.js` to check your setup and set the bot's initial state. It will prompt you for information. You only need to run this once ever.
|
1. Run `npm run setup` to check your setup and set the bot's initial state. It will prompt you for information. You only need to run this once ever.
|
||||||
|
|
||||||
1. Start the bridge: `npm start`
|
1. Start the bridge: `npm run start`
|
||||||
|
|
||||||
1. Add the bot to a server - use any *one* of the following commands for an invite link:
|
1. Add the bot to a server - use any *one* of the following commands for an invite link:
|
||||||
* (in the REPL) `addbot`
|
* (in the REPL) `addbot`
|
||||||
|
@ -152,8 +152,8 @@ To get into the rooms on your Matrix account, use the `/invite [your mxid here]`
|
||||||
│ └── *.js
|
│ └── *.js
|
||||||
* Various files you can run once if you need them.
|
* Various files you can run once if you need them.
|
||||||
└── scripts
|
└── scripts
|
||||||
* First time running a new bridge? Run this file to plant a seed, which will flourish into state for the bridge:
|
* First time running a new bridge? Run this file to set up prerequisites on the Matrix server:
|
||||||
├── seed.js
|
├── setup.js
|
||||||
* Hopefully you won't need the rest of these. Code quality varies wildly.
|
* Hopefully you won't need the rest of these. Code quality varies wildly.
|
||||||
└── *.js
|
└── *.js
|
||||||
|
|
||||||
|
@ -195,5 +195,6 @@ Total transitive production dependencies: 147
|
||||||
* (0) prettier-bytes: It does what I want and has no dependencies.
|
* (0) prettier-bytes: It does what I want and has no dependencies.
|
||||||
* (2) snowtransfer: Discord API library with bring-your-own-caching that I trust.
|
* (2) snowtransfer: Discord API library with bring-your-own-caching that I trust.
|
||||||
* (0) try-to-catch: Not strictly necessary, but it's already pulled in by supertape, so I may as well.
|
* (0) try-to-catch: Not strictly necessary, but it's already pulled in by supertape, so I may as well.
|
||||||
|
* (0) uqr: QR code SVG generator. Used on the website to scan in an invite link.
|
||||||
* (0) xxhash-wasm: Used where cryptographically secure hashing is not required.
|
* (0) xxhash-wasm: Used where cryptographically secure hashing is not required.
|
||||||
* (0) zod: Input validation for the web server. It's popular and easy to use.
|
* (0) zod: Input validation for the web server. It's popular and easy to use.
|
||||||
|
|
|
@ -1,25 +0,0 @@
|
||||||
id: de8c56117637cb5d9f4ac216f612dc2adb1de4c09ae8d13553f28c33a28147c7
|
|
||||||
hs_token: [a unique 64 character hex string]
|
|
||||||
as_token: [a unique 64 character hex string]
|
|
||||||
url: http://localhost:6693
|
|
||||||
sender_localpart: _ooye_bot
|
|
||||||
protocols:
|
|
||||||
- discord
|
|
||||||
namespaces:
|
|
||||||
users:
|
|
||||||
- exclusive: true
|
|
||||||
regex: '@_ooye_.*'
|
|
||||||
aliases:
|
|
||||||
- exclusive: true
|
|
||||||
regex: '#_ooye_.*'
|
|
||||||
rate_limited: false
|
|
||||||
ooye:
|
|
||||||
namespace_prefix: _ooye_
|
|
||||||
max_file_size: 5000000
|
|
||||||
server_name: [the part after the colon in your matrix id, like cadence.moe]
|
|
||||||
server_origin: [the full protocol and domain of your actual matrix server's location, with no trailing slash, like https://matrix.cadence.moe]
|
|
||||||
content_length_workaround: false
|
|
||||||
include_user_id_in_mxid: false
|
|
||||||
invite:
|
|
||||||
# uncomment this to auto-invite the named user to newly created spaces and mark them as admin (PL 100) everywhere
|
|
||||||
# - '@cadence:cadence.moe'
|
|
56
scripts/seed.js → scripts/setup.js
Executable file → Normal file
56
scripts/seed.js → scripts/setup.js
Executable file → Normal file
|
@ -7,6 +7,7 @@ const sqlite = require("better-sqlite3")
|
||||||
const {scheduler} = require("timers/promises")
|
const {scheduler} = require("timers/promises")
|
||||||
const {isDeepStrictEqual} = require("util")
|
const {isDeepStrictEqual} = require("util")
|
||||||
const {createServer} = require("http")
|
const {createServer} = require("http")
|
||||||
|
const {join} = require("path")
|
||||||
|
|
||||||
const {prompt} = require("enquirer")
|
const {prompt} = require("enquirer")
|
||||||
const Input = require("enquirer/lib/prompts/input")
|
const Input = require("enquirer/lib/prompts/input")
|
||||||
|
@ -38,7 +39,6 @@ const passthrough = require("../src/passthrough")
|
||||||
const db = new sqlite("ooye.db")
|
const db = new sqlite("ooye.db")
|
||||||
const migrate = require("../src/db/migrate")
|
const migrate = require("../src/db/migrate")
|
||||||
|
|
||||||
/** @type {import("heatsync").default} */ // @ts-ignore
|
|
||||||
const sync = new HeatSync({watchFS: false})
|
const sync = new HeatSync({watchFS: false})
|
||||||
|
|
||||||
Object.assign(passthrough, {sync, db})
|
Object.assign(passthrough, {sync, db})
|
||||||
|
@ -105,7 +105,8 @@ async function validateHomeserverOrigin(serverUrlPrompt, url) {
|
||||||
const serverNameResponse = await prompt({
|
const serverNameResponse = await prompt({
|
||||||
type: "input",
|
type: "input",
|
||||||
name: "server_name",
|
name: "server_name",
|
||||||
message: "Homeserver name"
|
message: "Homeserver name",
|
||||||
|
validate: serverName => !!serverName.match(/[a-z][a-z.]+[a-z]/)
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log("What is the URL of your homeserver?")
|
console.log("What is the URL of your homeserver?")
|
||||||
|
@ -147,10 +148,15 @@ async function validateHomeserverOrigin(serverUrlPrompt, url) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
bridgeOriginResponse.bridge_origin = bridgeOriginResponse.bridge_origin.replace(/\/+$/, "") // remove trailing slash
|
||||||
|
|
||||||
await server.close()
|
await server.close()
|
||||||
|
|
||||||
console.log("What is your Discord bot token?")
|
console.log("What is your Discord bot token?")
|
||||||
|
/** @type {SnowTransfer} */ // @ts-ignore
|
||||||
|
let snow = null
|
||||||
|
/** @type {{id: string, redirect_uris: string[]}} */ // @ts-ignore
|
||||||
|
let client = null
|
||||||
/** @type {{discord_token: string}} */
|
/** @type {{discord_token: string}} */
|
||||||
const discordTokenResponse = await prompt({
|
const discordTokenResponse = await prompt({
|
||||||
type: "input",
|
type: "input",
|
||||||
|
@ -159,8 +165,8 @@ async function validateHomeserverOrigin(serverUrlPrompt, url) {
|
||||||
validate: async token => {
|
validate: async token => {
|
||||||
process.stdout.write(magenta(" checking, please wait..."))
|
process.stdout.write(magenta(" checking, please wait..."))
|
||||||
try {
|
try {
|
||||||
const snow = new SnowTransfer(token)
|
snow = new SnowTransfer(token)
|
||||||
await snow.user.getSelf()
|
client = await snow.requestHandler.request(`/applications/@me`, {}, "get")
|
||||||
return true
|
return true
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return e.message
|
return e.message
|
||||||
|
@ -169,6 +175,7 @@ async function validateHomeserverOrigin(serverUrlPrompt, url) {
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log("What is your Discord client secret?")
|
console.log("What is your Discord client secret?")
|
||||||
|
console.log(`You can find it on the application page: https://discord.com/developers/applications/${client.id}/oauth2`)
|
||||||
/** @type {{discord_client_secret: string}} */
|
/** @type {{discord_client_secret: string}} */
|
||||||
const clientSecretResponse = await prompt({
|
const clientSecretResponse = await prompt({
|
||||||
type: "input",
|
type: "input",
|
||||||
|
@ -176,13 +183,31 @@ async function validateHomeserverOrigin(serverUrlPrompt, url) {
|
||||||
message: "Client secret"
|
message: "Client secret"
|
||||||
})
|
})
|
||||||
|
|
||||||
const template = getTemplateRegistration()
|
const expectedUri = `${bridgeOriginResponse.bridge_origin}/oauth`
|
||||||
|
if (!client.redirect_uris.includes(expectedUri)) {
|
||||||
|
console.log(`On the same application page, go to the Redirects section, and add this URI: ${cyan(expectedUri)}`)
|
||||||
|
await prompt({
|
||||||
|
type: "invisible",
|
||||||
|
name: "redirect_uri",
|
||||||
|
message: "Press Enter when you've added it",
|
||||||
|
validate: async token => {
|
||||||
|
process.stdout.write(magenta("checking, please wait..."))
|
||||||
|
client = await snow.requestHandler.request(`/applications/@me`, {}, "get")
|
||||||
|
if (client.redirect_uris.includes(expectedUri)) {
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
return "Redirect URI has not been added yet"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const template = getTemplateRegistration(serverNameResponse.server_name)
|
||||||
reg = {
|
reg = {
|
||||||
...template,
|
...template,
|
||||||
url: bridgeOriginResponse.bridge_origin,
|
url: bridgeOriginResponse.bridge_origin,
|
||||||
ooye: {
|
ooye: {
|
||||||
...template.ooye,
|
...template.ooye,
|
||||||
...serverNameResponse,
|
|
||||||
...bridgeOriginResponse,
|
...bridgeOriginResponse,
|
||||||
server_origin: serverOrigin,
|
server_origin: serverOrigin,
|
||||||
...discordTokenResponse,
|
...discordTokenResponse,
|
||||||
|
@ -205,7 +230,6 @@ async function validateHomeserverOrigin(serverUrlPrompt, url) {
|
||||||
// Done with user prompts, reg is now guaranteed to be valid
|
// Done with user prompts, reg is now guaranteed to be valid
|
||||||
const api = require("../src/matrix/api")
|
const api = require("../src/matrix/api")
|
||||||
const file = require("../src/matrix/file")
|
const file = require("../src/matrix/file")
|
||||||
const utils = require("../src/m2d/converters/utils")
|
|
||||||
const DiscordClient = require("../src/d2m/discord-client")
|
const DiscordClient = require("../src/d2m/discord-client")
|
||||||
const discord = new DiscordClient(reg.ooye.discord_token, "no")
|
const discord = new DiscordClient(reg.ooye.discord_token, "no")
|
||||||
passthrough.discord = discord
|
passthrough.discord = discord
|
||||||
|
@ -242,21 +266,11 @@ async function validateHomeserverOrigin(serverUrlPrompt, url) {
|
||||||
|
|
||||||
const mxid = `@${reg.sender_localpart}:${reg.ooye.server_name}`
|
const mxid = `@${reg.sender_localpart}:${reg.ooye.server_name}`
|
||||||
|
|
||||||
// ensure registration is correctly set...
|
|
||||||
assert(reg.sender_localpart.startsWith(reg.ooye.namespace_prefix), "appservice's localpart must be in the namespace it controls")
|
|
||||||
assert(utils.eventSenderIsFromDiscord(mxid), "appservice's mxid must be in the namespace it controls")
|
|
||||||
assert(reg.ooye.server_origin.match(/^https?:\/\//), "server origin must start with http or https")
|
|
||||||
assert.notEqual(reg.ooye.server_origin.slice(-1), "/", "server origin must not end in slash")
|
|
||||||
const botID = Buffer.from(reg.ooye.discord_token.split(".")[0], "base64").toString()
|
|
||||||
assert(botID.match(/^[0-9]{10,}$/), "discord token must follow the correct format")
|
|
||||||
assert.match(reg.url, /^https?:/, "url must start with http:// or https://")
|
|
||||||
|
|
||||||
console.log("✅ Configuration looks good...")
|
|
||||||
|
|
||||||
// database ddl...
|
// database ddl...
|
||||||
await migrate.migrate(db)
|
await migrate.migrate(db)
|
||||||
|
|
||||||
// add initial rows to database, like adding the bot to sim...
|
// add initial rows to database, like adding the bot to sim...
|
||||||
|
const botID = Buffer.from(reg.ooye.discord_token.split(".")[0], "base64").toString()
|
||||||
db.prepare("INSERT OR IGNORE INTO sim (user_id, sim_name, localpart, mxid) VALUES (?, ?, ?, ?)").run(botID, reg.sender_localpart.slice(reg.ooye.namespace_prefix.length), reg.sender_localpart, mxid)
|
db.prepare("INSERT OR IGNORE INTO sim (user_id, sim_name, localpart, mxid) VALUES (?, ?, ?, ?)").run(botID, reg.sender_localpart.slice(reg.ooye.namespace_prefix.length), reg.sender_localpart, mxid)
|
||||||
|
|
||||||
console.log("✅ Database is ready...")
|
console.log("✅ Database is ready...")
|
||||||
|
@ -305,12 +319,12 @@ async function validateHomeserverOrigin(serverUrlPrompt, url) {
|
||||||
}
|
}
|
||||||
// Otherwise, it's the user's problem
|
// Otherwise, it's the user's problem
|
||||||
if (!guild) {
|
if (!guild) {
|
||||||
return die(`Error: The bot needs to upload some emojis. Please say where to upload them to. Run seed.js again with --emoji-guild=GUILD_ID`)
|
return die(`Error: The bot needs to upload some emojis. Please say where to upload them to. Run setup again with --emoji-guild=GUILD_ID`)
|
||||||
}
|
}
|
||||||
// Upload those emojis to the chosen location
|
// Upload those emojis to the chosen location
|
||||||
db.prepare("REPLACE INTO auto_emoji (name, emoji_id, guild_id) VALUES ('_', '_', ?)").run(guild.id)
|
db.prepare("REPLACE INTO auto_emoji (name, emoji_id, guild_id) VALUES ('_', '_', ?)").run(guild.id)
|
||||||
await uploadAutoEmoji(discord.snow, guild, "L1", "docs/img/L1.png")
|
await uploadAutoEmoji(discord.snow, guild, "L1", join(__dirname, "../docs/img/L1.png"))
|
||||||
await uploadAutoEmoji(discord.snow, guild, "L2", "docs/img/L2.png")
|
await uploadAutoEmoji(discord.snow, guild, "L2", join(__dirname, "../docs/img/L2.png"))
|
||||||
}
|
}
|
||||||
console.log("✅ Emojis are ready...")
|
console.log("✅ Emojis are ready...")
|
||||||
|
|
|
@ -12,7 +12,6 @@ const {reg} = require("../src/matrix/read-registration")
|
||||||
const passthrough = require("../src/passthrough")
|
const passthrough = require("../src/passthrough")
|
||||||
const db = new sqlite("ooye.db")
|
const db = new sqlite("ooye.db")
|
||||||
|
|
||||||
/** @type {import("heatsync").default} */ // @ts-ignore
|
|
||||||
const sync = new HeatSync()
|
const sync = new HeatSync()
|
||||||
|
|
||||||
Object.assign(passthrough, {sync, db})
|
Object.assign(passthrough, {sync, db})
|
||||||
|
|
|
@ -15,8 +15,6 @@ const api = sync.require("../../matrix/api")
|
||||||
const ks = sync.require("../../matrix/kstate")
|
const ks = sync.require("../../matrix/kstate")
|
||||||
/** @type {import("../../discord/utils")} */
|
/** @type {import("../../discord/utils")} */
|
||||||
const utils = sync.require("../../discord/utils")
|
const utils = sync.require("../../discord/utils")
|
||||||
/** @type {import("./create-space")}) */
|
|
||||||
const createSpace = sync.require("./create-space") // watch out for the require loop
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* There are 3 levels of room privacy:
|
* There are 3 levels of room privacy:
|
||||||
|
@ -95,27 +93,21 @@ function convertNameAndTopic(channel, guild, customName) {
|
||||||
async function channelToKState(channel, guild, di) {
|
async function channelToKState(channel, guild, di) {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const parentChannel = discord.channels.get(channel.parent_id)
|
const parentChannel = discord.channels.get(channel.parent_id)
|
||||||
/** Used for membership/permission checks. */
|
const guildRow = select("guild_space", ["space_id", "privacy_level"], {guild_id: guild.id}).get()
|
||||||
let guildSpaceID
|
assert(guildRow)
|
||||||
/** Used as the literal parent on Matrix, for categorisation. Will be the same as `guildSpaceID` unless it's a forum channel's thread, in which case a different space is used to group those threads. */
|
|
||||||
let parentSpaceID
|
|
||||||
let privacyLevel
|
|
||||||
if (parentChannel?.type === DiscordTypes.ChannelType.GuildForum) { // it's a forum channel's thread, so use a different space to group those threads
|
|
||||||
guildSpaceID = await createSpace.ensureSpace(guild)
|
|
||||||
parentSpaceID = await ensureRoom(channel.parent_id)
|
|
||||||
privacyLevel = select("guild_space", "privacy_level", {space_id: guildSpaceID}).pluck().get()
|
|
||||||
} else { // otherwise use the guild's space like usual
|
|
||||||
parentSpaceID = await createSpace.ensureSpace(guild)
|
|
||||||
guildSpaceID = parentSpaceID
|
|
||||||
privacyLevel = select("guild_space", "privacy_level", {space_id: parentSpaceID}).pluck().get()
|
|
||||||
}
|
|
||||||
assert(typeof parentSpaceID === "string")
|
|
||||||
assert(typeof guildSpaceID === "string")
|
|
||||||
assert(typeof privacyLevel === "number")
|
|
||||||
|
|
||||||
const row = select("channel_room", ["nick", "custom_avatar"], {channel_id: channel.id}).get()
|
/** Used for membership/permission checks. */
|
||||||
const customName = row?.nick
|
let guildSpaceID = guildRow.space_id
|
||||||
const customAvatar = row?.custom_avatar
|
/** Used as the literal parent on Matrix, for categorisation. Will be the same as `guildSpaceID` unless it's a forum channel's thread, in which case a different space is used to group those threads. */
|
||||||
|
let parentSpaceID = guildSpaceID
|
||||||
|
if (parentChannel?.type === DiscordTypes.ChannelType.GuildForum) {
|
||||||
|
parentSpaceID = await ensureRoom(channel.parent_id)
|
||||||
|
assert(typeof parentSpaceID === "string")
|
||||||
|
}
|
||||||
|
|
||||||
|
const channelRow = select("channel_room", ["nick", "custom_avatar"], {channel_id: channel.id}).get()
|
||||||
|
const customName = channelRow?.nick
|
||||||
|
const customAvatar = channelRow?.custom_avatar
|
||||||
const [convertedName, convertedTopic] = convertNameAndTopic(channel, guild, customName)
|
const [convertedName, convertedTopic] = convertNameAndTopic(channel, guild, customName)
|
||||||
|
|
||||||
const avatarEventContent = {}
|
const avatarEventContent = {}
|
||||||
|
@ -125,6 +117,7 @@ async function channelToKState(channel, guild, di) {
|
||||||
avatarEventContent.url = {$url: file.guildIcon(guild)}
|
avatarEventContent.url = {$url: file.guildIcon(guild)}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const privacyLevel = guildRow.privacy_level
|
||||||
let history_visibility = PRIVACY_ENUMS.ROOM_HISTORY_VISIBILITY[privacyLevel]
|
let history_visibility = PRIVACY_ENUMS.ROOM_HISTORY_VISIBILITY[privacyLevel]
|
||||||
if (channel["thread_metadata"]) history_visibility = "world_readable"
|
if (channel["thread_metadata"]) history_visibility = "world_readable"
|
||||||
|
|
||||||
|
@ -180,7 +173,7 @@ async function channelToKState(channel, guild, di) {
|
||||||
network: {
|
network: {
|
||||||
id: guild.id,
|
id: guild.id,
|
||||||
displayname: guild.name,
|
displayname: guild.name,
|
||||||
avatar_url: await file.uploadDiscordFileToMxc(file.guildIcon(guild))
|
avatar_url: {$url: file.guildIcon(guild)}
|
||||||
},
|
},
|
||||||
channel: {
|
channel: {
|
||||||
id: channel.id,
|
id: channel.id,
|
||||||
|
@ -279,6 +272,61 @@ function channelToGuild(channel) {
|
||||||
return guild
|
return guild
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function handles whether it's allowed to bridge messages in this channel, and if so, where to.
|
||||||
|
* This has to account for whether self-service is enabled for the guild or not.
|
||||||
|
* This also has to account for different channel types, like forum channels (which need the
|
||||||
|
* parent forum to already exist, and ignore the self-service setting), or thread channels (which
|
||||||
|
* need the parent channel to already exist, and ignore the self-service setting).
|
||||||
|
* @param {DiscordTypes.APIGuildTextChannel | DiscordTypes.APIThreadChannel} channel text channel or thread
|
||||||
|
* @param {string} guildID
|
||||||
|
* @returns obj if bridged; 1 if autocreatable; null/undefined if guild is not bridged; 0 if self-service and not autocreatable thread
|
||||||
|
*/
|
||||||
|
function existsOrAutocreatable(channel, guildID) {
|
||||||
|
// 1. If the channel is already linked somewhere, it's always okay to bridge to that destination, no matter what. Yippee!
|
||||||
|
const existing = select("channel_room", ["room_id", "thread_parent"], {channel_id: channel.id}).get()
|
||||||
|
if (existing) return existing
|
||||||
|
|
||||||
|
// 2. If the guild is an autocreate guild, it's always okay to bridge to that destination, and
|
||||||
|
// we'll need to create any dependent resources recursively.
|
||||||
|
const autocreate = select("guild_active", "autocreate", {guild_id: guildID}).pluck().get()
|
||||||
|
if (autocreate === 1) return autocreate
|
||||||
|
|
||||||
|
// 3. If the guild is not approved for bridging yet, we can't bridge there.
|
||||||
|
// They need to decide one way or another whether it's self-service before we can continue.
|
||||||
|
if (autocreate == null) return autocreate
|
||||||
|
|
||||||
|
// 4. If we got here, the guild is in self-service mode.
|
||||||
|
// New channels won't be able to create new rooms. But forum threads or channel threads could be fine.
|
||||||
|
if ([DiscordTypes.ChannelType.PublicThread, DiscordTypes.ChannelType.PrivateThread, DiscordTypes.ChannelType.AnnouncementThread].includes(channel.type)) {
|
||||||
|
// In self-service mode, threads rely on the parent resource already existing.
|
||||||
|
/** @type {DiscordTypes.APIGuildTextChannel} */ // @ts-ignore
|
||||||
|
const parent = discord.channels.get(channel.parent_id)
|
||||||
|
assert(parent)
|
||||||
|
const parentExisting = existsOrAutocreatable(parent, guildID)
|
||||||
|
if (parentExisting) return 1 // Autocreatable
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. If we got here, the guild is in self-service mode and the channel is truly not bridged.
|
||||||
|
return autocreate
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {DiscordTypes.APIGuildTextChannel | DiscordTypes.APIThreadChannel} channel text channel or thread
|
||||||
|
* @param {string} guildID
|
||||||
|
* @returns obj if bridged; 1 if autocreatable. (throws if not autocreatable)
|
||||||
|
*/
|
||||||
|
function assertExistsOrAutocreatable(channel, guildID) {
|
||||||
|
const existing = existsOrAutocreatable(channel, guildID)
|
||||||
|
if (existing === 0) {
|
||||||
|
throw new Error(`Guild ${guildID} is self-service, so won't create a Matrix room for channel ${channel.id}`)
|
||||||
|
}
|
||||||
|
if (!existing) {
|
||||||
|
throw new Error(`Guild ${guildID} is not bridged, so won't create a Matrix room for channel ${channel.id}`)
|
||||||
|
}
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
Ensure flow:
|
Ensure flow:
|
||||||
1. Get IDs
|
1. Get IDs
|
||||||
|
@ -297,6 +345,7 @@ function channelToGuild(channel) {
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* Create room and/or sync room data. Please check that a channel_room entry exists or autocreate = 1 before calling this.
|
||||||
* @param {string} channelID
|
* @param {string} channelID
|
||||||
* @param {boolean} shouldActuallySync false if just need to ensure room exists (which is a quick database check), true if also want to sync room data when it does exist (slow)
|
* @param {boolean} shouldActuallySync false if just need to ensure room exists (which is a quick database check), true if also want to sync room data when it does exist (slow)
|
||||||
* @returns {Promise<string>} room ID
|
* @returns {Promise<string>} room ID
|
||||||
|
@ -311,9 +360,9 @@ async function _syncRoom(channelID, shouldActuallySync) {
|
||||||
await inflightRoomCreate.get(channelID) // just waiting, and then doing a new db query afterwards, is the simplest way of doing it
|
await inflightRoomCreate.get(channelID) // just waiting, and then doing a new db query afterwards, is the simplest way of doing it
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = select("channel_room", ["room_id", "thread_parent"], {channel_id: channelID}).get()
|
const existing = assertExistsOrAutocreatable(channel, guild.id)
|
||||||
|
|
||||||
if (!existing) {
|
if (existing === 1) {
|
||||||
const creation = (async () => {
|
const creation = (async () => {
|
||||||
const {spaceID, privacyLevel, channelKState} = await channelToKState(channel, guild, {api})
|
const {spaceID, privacyLevel, channelKState} = await channelToKState(channel, guild, {api})
|
||||||
const roomID = await createRoom(channel, guild, spaceID, channelKState, privacyLevel)
|
const roomID = await createRoom(channel, guild, spaceID, channelKState, privacyLevel)
|
||||||
|
@ -353,12 +402,12 @@ async function _syncRoom(channelID, shouldActuallySync) {
|
||||||
return roomID
|
return roomID
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ensures the room exists. If it doesn't, creates the room with an accurate initial state. */
|
/** Ensures the room exists. If it doesn't, creates the room with an accurate initial state. Please check that a channel_room entry exists or guild autocreate = 1 before calling this. */
|
||||||
function ensureRoom(channelID) {
|
function ensureRoom(channelID) {
|
||||||
return _syncRoom(channelID, false)
|
return _syncRoom(channelID, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Actually syncs. Gets all room state from the homeserver in order to diff, and uploads the icon to mxc if it has changed. */
|
/** Actually syncs. Gets all room state from the homeserver in order to diff, and uploads the icon to mxc if it has changed. Please check that a channel_room entry exists or guild autocreate = 1 before calling this. */
|
||||||
function syncRoom(channelID) {
|
function syncRoom(channelID) {
|
||||||
return _syncRoom(channelID, true)
|
return _syncRoom(channelID, true)
|
||||||
}
|
}
|
||||||
|
@ -455,3 +504,5 @@ module.exports.postApplyPowerLevels = postApplyPowerLevels
|
||||||
module.exports._convertNameAndTopic = convertNameAndTopic
|
module.exports._convertNameAndTopic = convertNameAndTopic
|
||||||
module.exports._unbridgeRoom = _unbridgeRoom
|
module.exports._unbridgeRoom = _unbridgeRoom
|
||||||
module.exports.unbridgeDeletedChannel = unbridgeDeletedChannel
|
module.exports.unbridgeDeletedChannel = unbridgeDeletedChannel
|
||||||
|
module.exports.existsOrAutocreatable = existsOrAutocreatable
|
||||||
|
module.exports.assertExistsOrAutocreatable = assertExistsOrAutocreatable
|
||||||
|
|
|
@ -92,6 +92,9 @@ async function _syncSpace(guild, shouldActuallySync) {
|
||||||
const row = select("guild_space", ["space_id", "privacy_level"], {guild_id: guild.id}).get()
|
const row = select("guild_space", ["space_id", "privacy_level"], {guild_id: guild.id}).get()
|
||||||
|
|
||||||
if (!row) {
|
if (!row) {
|
||||||
|
const autocreate = select("guild_active", "autocreate", {guild_id: guild.id}).pluck().get()
|
||||||
|
assert.equal(autocreate, 1, `refusing to implicitly create guild ${guild.id}. set the guild_active data first before calling ensureSpace/syncSpace.`)
|
||||||
|
|
||||||
const creation = (async () => {
|
const creation = (async () => {
|
||||||
const guildKState = await guildToKState(guild, createRoom.DEFAULT_PRIVACY_LEVEL) // New spaces will have to use the default privacy level; we obviously can't look up the existing entry
|
const guildKState = await guildToKState(guild, createRoom.DEFAULT_PRIVACY_LEVEL) // New spaces will have to use the default privacy level; we obviously can't look up the existing entry
|
||||||
const spaceID = await createSpace(guild, guildKState)
|
const spaceID = await createSpace(guild, guildKState)
|
||||||
|
|
|
@ -12,6 +12,7 @@ function debugRetrigger(message) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const paused = new Set()
|
||||||
const emitter = new EventEmitter()
|
const emitter = new EventEmitter()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -25,13 +26,15 @@ const emitter = new EventEmitter()
|
||||||
* @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
|
* @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(messageID, fn, ...rest) {
|
function eventNotFoundThenRetrigger(messageID, fn, ...rest) {
|
||||||
|
if (!paused.has(messageID)) {
|
||||||
const eventID = select("event_message", "event_id", {message_id: messageID}).pluck().get()
|
const eventID = select("event_message", "event_id", {message_id: messageID}).pluck().get()
|
||||||
if (eventID) {
|
if (eventID) {
|
||||||
debugRetrigger(`[retrigger] OK mid <-> eid = ${messageID} <-> ${eventID}`)
|
debugRetrigger(`[retrigger] OK mid <-> eid = ${messageID} <-> ${eventID}`)
|
||||||
return false // event was found so don't retrigger
|
return false // event was found so don't retrigger
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
debugRetrigger(`[retrigger] WAIT mid <-> eid = ${messageID} <-> ${eventID}`)
|
debugRetrigger(`[retrigger] WAIT mid = ${messageID}`)
|
||||||
emitter.once(messageID, () => {
|
emitter.once(messageID, () => {
|
||||||
debugRetrigger(`[retrigger] TRIGGER mid = ${messageID}`)
|
debugRetrigger(`[retrigger] TRIGGER mid = ${messageID}`)
|
||||||
fn(...rest)
|
fn(...rest)
|
||||||
|
@ -46,6 +49,25 @@ function eventNotFoundThenRetrigger(messageID, fn, ...rest) {
|
||||||
return true // event was not found, then retrigger
|
return true // event was not found, then retrigger
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anything calling retrigger during the callback will be paused and retriggered after the callback resolves.
|
||||||
|
* @template T
|
||||||
|
* @param {string} messageID
|
||||||
|
* @param {Promise<T>} promise
|
||||||
|
* @returns {Promise<T>}
|
||||||
|
*/
|
||||||
|
async function pauseChanges(messageID, promise) {
|
||||||
|
try {
|
||||||
|
debugRetrigger(`[retrigger] PAUSE mid = ${messageID}`)
|
||||||
|
paused.add(messageID)
|
||||||
|
return await promise
|
||||||
|
} finally {
|
||||||
|
debugRetrigger(`[retrigger] RESUME mid = ${messageID}`)
|
||||||
|
paused.delete(messageID)
|
||||||
|
messageFinishedBridging(messageID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Triggers any pending operations that were waiting on the corresponding event ID.
|
* Triggers any pending operations that were waiting on the corresponding event ID.
|
||||||
* @param {string} messageID
|
* @param {string} messageID
|
||||||
|
@ -59,3 +81,4 @@ function messageFinishedBridging(messageID) {
|
||||||
|
|
||||||
module.exports.eventNotFoundThenRetrigger = eventNotFoundThenRetrigger
|
module.exports.eventNotFoundThenRetrigger = eventNotFoundThenRetrigger
|
||||||
module.exports.messageFinishedBridging = messageFinishedBridging
|
module.exports.messageFinishedBridging = messageFinishedBridging
|
||||||
|
module.exports.pauseChanges = pauseChanges
|
||||||
|
|
|
@ -122,37 +122,43 @@ async function editToChanges(message, guild, api) {
|
||||||
eventsToReplace = eventsToReplace.filter(eventCanBeEdited)
|
eventsToReplace = eventsToReplace.filter(eventCanBeEdited)
|
||||||
|
|
||||||
// We want to maintain exactly one part = 0 and one reaction_part = 0 database row at all times.
|
// We want to maintain exactly one part = 0 and one reaction_part = 0 database row at all times.
|
||||||
|
// This would be disrupted if existing events that are (reaction_)part = 0 will be redacted.
|
||||||
|
// If that is the case, pick a different existing or newly sent event to be (reaction_)part = 0.
|
||||||
/** @type {({column: string, eventID: string, value?: number} | {column: string, nextEvent: true})[]} */
|
/** @type {({column: string, eventID: string, value?: number} | {column: string, nextEvent: true})[]} */
|
||||||
const promotions = []
|
const promotions = []
|
||||||
for (const column of ["part", "reaction_part"]) {
|
for (const column of ["part", "reaction_part"]) {
|
||||||
const candidatesForParts = unchangedEvents.concat(eventsToReplace)
|
const candidatesForParts = unchangedEvents.concat(eventsToReplace)
|
||||||
// If no events with part = 0 exist (or will exist), we need to do some management.
|
// If no events with part = 0 exist (or will exist), we need to do some management.
|
||||||
if (!candidatesForParts.some(e => e.old[column] === 0)) {
|
if (!candidatesForParts.some(e => e.old[column] === 0)) {
|
||||||
|
// Try to find an existing event to promote. Bigger order is better.
|
||||||
if (candidatesForParts.length) {
|
if (candidatesForParts.length) {
|
||||||
// We can choose an existing event to promote. Bigger order is better.
|
|
||||||
const order = e => 2*+(e.event_type === "m.room.message") + 1*+(e.old.event_subtype === "m.text")
|
const order = e => 2*+(e.event_type === "m.room.message") + 1*+(e.old.event_subtype === "m.text")
|
||||||
candidatesForParts.sort((a, b) => order(b) - order(a))
|
candidatesForParts.sort((a, b) => order(b) - order(a))
|
||||||
if (column === "part") {
|
if (column === "part") {
|
||||||
promotions.push({column, eventID: candidatesForParts[0].old.event_id}) // part should be the first one
|
promotions.push({column, eventID: candidatesForParts[0].old.event_id}) // part should be the first one
|
||||||
|
} else if (eventsToSend.length) {
|
||||||
|
promotions.push({column, nextEvent: true}) // reaction_part should be the last one
|
||||||
} else {
|
} else {
|
||||||
promotions.push({column, eventID: candidatesForParts[candidatesForParts.length - 1].old.event_id}) // reaction_part should be the last one
|
promotions.push({column, eventID: candidatesForParts[candidatesForParts.length - 1].old.event_id}) // reaction_part should be the last one
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
// No existing events to promote, but new events are being sent. Whatever gets sent will be the next part = 0.
|
// Or, if there are no existing events to promote and new events will be sent, whatever gets sent will be the next part = 0.
|
||||||
|
else {
|
||||||
promotions.push({column, nextEvent: true})
|
promotions.push({column, nextEvent: true})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// If adding events, try to keep reactions attached to the bottom of the group (unless reactions have already been added)
|
// If adding events, try to keep reactions attached to the bottom of the group (unless reactions have already been added)
|
||||||
if (eventsToSend.length && !promotions.length) {
|
if (eventsToSend.length && !promotions.length) {
|
||||||
const existingReaction = select("reaction", "message_id", {message_id: message.id}).pluck().get()
|
const existingReaction = select("reaction", "message_id", {message_id: message.id}).pluck().get()
|
||||||
if (!existingReaction) {
|
if (!existingReaction) {
|
||||||
const existingPartZero = candidatesForParts.find(p => p.old.reaction_part === 0)
|
const existingPartZero = unchangedEvents.concat(eventsToReplace).find(p => p.old.reaction_part === 0)
|
||||||
assert(existingPartZero) // will exist because a reaction_part=0 always exists and no events are being removed
|
assert(existingPartZero) // will exist because a reaction_part=0 always exists and no events are being removed
|
||||||
promotions.push({column: "reaction_part", eventID: existingPartZero.old.event_id, value: 1}) // update the current reaction_part to 1
|
promotions.push({column: "reaction_part", eventID: existingPartZero.old.event_id, value: 1}) // update the current reaction_part to 1
|
||||||
promotions.push({column: "reaction_part", nextEvent: true}) // the newly created event will have reaction_part = 0
|
promotions.push({column: "reaction_part", nextEvent: true}) // the newly created event will have reaction_part = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Removing unnecessary properties before returning
|
// Removing unnecessary properties before returning
|
||||||
eventsToRedact = eventsToRedact.map(e => e.old.event_id)
|
eventsToRedact = eventsToRedact.map(e => e.old.event_id)
|
||||||
|
|
|
@ -67,7 +67,7 @@ test("message2event embeds: image embed and attachment", async t => {
|
||||||
msgtype: "m.image",
|
msgtype: "m.image",
|
||||||
url: "mxc://cadence.moe/zAXdQriaJuLZohDDmacwWWDR",
|
url: "mxc://cadence.moe/zAXdQriaJuLZohDDmacwWWDR",
|
||||||
body: "Screenshot_20231001_034036.jpg",
|
body: "Screenshot_20231001_034036.jpg",
|
||||||
external_url: "https://cdn.discordapp.com/attachments/176333891320283136/1157854643037163610/Screenshot_20231001_034036.jpg?ex=651a1faa&is=6518ce2a&hm=eb5ca80a3fa7add8765bf404aea2028a28a2341e4a62435986bcdcf058da82f3&",
|
external_url: "https://bridge.example.org/download/discordcdn/176333891320283136/1157854643037163610/Screenshot_20231001_034036.jpg",
|
||||||
filename: "Screenshot_20231001_034036.jpg",
|
filename: "Screenshot_20231001_034036.jpg",
|
||||||
info: {
|
info: {
|
||||||
h: 1170,
|
h: 1170,
|
||||||
|
|
|
@ -103,7 +103,7 @@ const embedTitleParser = markdown.markdownEngine.parserFor({
|
||||||
* @param {DiscordTypes.APIAttachment} attachment
|
* @param {DiscordTypes.APIAttachment} attachment
|
||||||
*/
|
*/
|
||||||
async function attachmentToEvent(mentions, attachment) {
|
async function attachmentToEvent(mentions, attachment) {
|
||||||
const publicURL = dUtils.getPublicUrlForCdn(attachment.url)
|
const external_url = dUtils.getPublicUrlForCdn(attachment.url)
|
||||||
const emoji =
|
const emoji =
|
||||||
attachment.content_type?.startsWith("image/jp") ? "📸"
|
attachment.content_type?.startsWith("image/jp") ? "📸"
|
||||||
: attachment.content_type?.startsWith("image/") ? "🖼️"
|
: attachment.content_type?.startsWith("image/") ? "🖼️"
|
||||||
|
@ -117,9 +117,9 @@ async function attachmentToEvent(mentions, attachment) {
|
||||||
$type: "m.room.message",
|
$type: "m.room.message",
|
||||||
"m.mentions": mentions,
|
"m.mentions": mentions,
|
||||||
msgtype: "m.text",
|
msgtype: "m.text",
|
||||||
body: `${emoji} Uploaded SPOILER file: ${publicURL} (${pb(attachment.size)})`,
|
body: `${emoji} Uploaded SPOILER file: ${external_url} (${pb(attachment.size)})`,
|
||||||
format: "org.matrix.custom.html",
|
format: "org.matrix.custom.html",
|
||||||
formatted_body: `<blockquote>${emoji} Uploaded SPOILER file: <a href="${publicURL}">${publicURL}</a> (${pb(attachment.size)})</blockquote>`
|
formatted_body: `<blockquote>${emoji} Uploaded SPOILER file: <a href="${external_url}">${external_url}</a> (${pb(attachment.size)})</blockquote>`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// for large files, always link them instead of uploading so I don't use up all the space in the content repo
|
// for large files, always link them instead of uploading so I don't use up all the space in the content repo
|
||||||
|
@ -128,9 +128,9 @@ async function attachmentToEvent(mentions, attachment) {
|
||||||
$type: "m.room.message",
|
$type: "m.room.message",
|
||||||
"m.mentions": mentions,
|
"m.mentions": mentions,
|
||||||
msgtype: "m.text",
|
msgtype: "m.text",
|
||||||
body: `${emoji} Uploaded file: ${publicURL} (${pb(attachment.size)})`,
|
body: `${emoji} Uploaded file: ${external_url} (${pb(attachment.size)})`,
|
||||||
format: "org.matrix.custom.html",
|
format: "org.matrix.custom.html",
|
||||||
formatted_body: `${emoji} Uploaded file: <a href="${publicURL}">${attachment.filename}</a> (${pb(attachment.size)})`
|
formatted_body: `${emoji} Uploaded file: <a href="${external_url}">${attachment.filename}</a> (${pb(attachment.size)})`
|
||||||
}
|
}
|
||||||
} else if (attachment.content_type?.startsWith("image/") && attachment.width && attachment.height) {
|
} else if (attachment.content_type?.startsWith("image/") && attachment.width && attachment.height) {
|
||||||
return {
|
return {
|
||||||
|
@ -138,7 +138,7 @@ async function attachmentToEvent(mentions, attachment) {
|
||||||
"m.mentions": mentions,
|
"m.mentions": mentions,
|
||||||
msgtype: "m.image",
|
msgtype: "m.image",
|
||||||
url: await file.uploadDiscordFileToMxc(attachment.url),
|
url: await file.uploadDiscordFileToMxc(attachment.url),
|
||||||
external_url: attachment.url,
|
external_url,
|
||||||
body: attachment.description || attachment.filename,
|
body: attachment.description || attachment.filename,
|
||||||
filename: attachment.filename,
|
filename: attachment.filename,
|
||||||
info: {
|
info: {
|
||||||
|
@ -154,7 +154,7 @@ async function attachmentToEvent(mentions, attachment) {
|
||||||
"m.mentions": mentions,
|
"m.mentions": mentions,
|
||||||
msgtype: "m.video",
|
msgtype: "m.video",
|
||||||
url: await file.uploadDiscordFileToMxc(attachment.url),
|
url: await file.uploadDiscordFileToMxc(attachment.url),
|
||||||
external_url: attachment.url,
|
external_url,
|
||||||
body: attachment.description || attachment.filename,
|
body: attachment.description || attachment.filename,
|
||||||
filename: attachment.filename,
|
filename: attachment.filename,
|
||||||
info: {
|
info: {
|
||||||
|
@ -170,7 +170,7 @@ async function attachmentToEvent(mentions, attachment) {
|
||||||
"m.mentions": mentions,
|
"m.mentions": mentions,
|
||||||
msgtype: "m.audio",
|
msgtype: "m.audio",
|
||||||
url: await file.uploadDiscordFileToMxc(attachment.url),
|
url: await file.uploadDiscordFileToMxc(attachment.url),
|
||||||
external_url: attachment.url,
|
external_url,
|
||||||
body: attachment.description || attachment.filename,
|
body: attachment.description || attachment.filename,
|
||||||
filename: attachment.filename,
|
filename: attachment.filename,
|
||||||
info: {
|
info: {
|
||||||
|
@ -185,7 +185,7 @@ async function attachmentToEvent(mentions, attachment) {
|
||||||
"m.mentions": mentions,
|
"m.mentions": mentions,
|
||||||
msgtype: "m.file",
|
msgtype: "m.file",
|
||||||
url: await file.uploadDiscordFileToMxc(attachment.url),
|
url: await file.uploadDiscordFileToMxc(attachment.url),
|
||||||
external_url: attachment.url,
|
external_url,
|
||||||
body: attachment.description || attachment.filename,
|
body: attachment.description || attachment.filename,
|
||||||
filename: attachment.filename,
|
filename: attachment.filename,
|
||||||
info: {
|
info: {
|
||||||
|
@ -197,11 +197,12 @@ async function attachmentToEvent(mentions, attachment) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {import("discord-api-types/v10").APIMessage} message
|
* @param {DiscordTypes.APIMessage} message
|
||||||
* @param {import("discord-api-types/v10").APIGuild} guild
|
* @param {DiscordTypes.APIGuild} guild
|
||||||
* @param {{includeReplyFallback?: boolean, includeEditFallbackStar?: boolean}} options default values:
|
* @param {{includeReplyFallback?: boolean, includeEditFallbackStar?: boolean, alwaysReturnFormattedBody?: boolean}} options default values:
|
||||||
* - includeReplyFallback: true
|
* - includeReplyFallback: true
|
||||||
* - includeEditFallbackStar: false
|
* - includeEditFallbackStar: false
|
||||||
|
* - alwaysReturnFormattedBody: false - formatted_body will be skipped if it is the same as body because the message is plaintext. if you want the formatted_body to be returned anyway, for example to merge it with another message, then set this to true.
|
||||||
* @param {{api: import("../../matrix/api")}} di simple-as-nails dependency injection for the matrix API
|
* @param {{api: import("../../matrix/api")}} di simple-as-nails dependency injection for the matrix API
|
||||||
*/
|
*/
|
||||||
async function messageToEvent(message, guild, options = {}, di) {
|
async function messageToEvent(message, guild, options = {}, di) {
|
||||||
|
@ -236,8 +237,11 @@ async function messageToEvent(message, guild, options = {}, di) {
|
||||||
const interaction = message.interaction_metadata || message.interaction
|
const interaction = message.interaction_metadata || message.interaction
|
||||||
if (message.type === DiscordTypes.MessageType.ChatInputCommand && interaction && "name" in interaction) {
|
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.
|
// Commands are sent by the responding bot. Need to attach the metadata of the person using the command at the top.
|
||||||
if (message.content) message.content = `\n${message.content}`
|
let content = message.content
|
||||||
message.content = `> ↪️ <@${interaction.user.id}> used \`/${interaction.name}\`${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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -425,8 +429,13 @@ async function messageToEvent(message, guild, options = {}, di) {
|
||||||
return {body, html}
|
return {body, html}
|
||||||
}
|
}
|
||||||
|
|
||||||
// FIXME: What was the scanMentions parameter supposed to activate? It's unused.
|
/**
|
||||||
async function addTextEvent(body, html, msgtype, {scanMentions}) {
|
* After converting Discord content to Matrix plaintext and HTML content, post-process the bodies and push the resulting text event
|
||||||
|
* @param {string} body matrix event plaintext body
|
||||||
|
* @param {string} html matrix event HTML body
|
||||||
|
* @param {string} msgtype matrix event msgtype (maybe m.text or m.notice)
|
||||||
|
*/
|
||||||
|
async function addTextEvent(body, html, msgtype) {
|
||||||
// Star * prefix for fallback edits
|
// Star * prefix for fallback edits
|
||||||
if (options.includeEditFallbackStar) {
|
if (options.includeEditFallbackStar) {
|
||||||
body = "* " + body
|
body = "* " + body
|
||||||
|
@ -434,7 +443,7 @@ async function messageToEvent(message, guild, options = {}, di) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const flags = message.flags || 0
|
const flags = message.flags || 0
|
||||||
if (flags & 2) {
|
if (flags & DiscordTypes.MessageFlags.IsCrosspost) {
|
||||||
body = `[🔀 ${message.author.username}]\n` + body
|
body = `[🔀 ${message.author.username}]\n` + body
|
||||||
html = `🔀 <strong>${message.author.username}</strong><br>` + html
|
html = `🔀 <strong>${message.author.username}</strong><br>` + html
|
||||||
}
|
}
|
||||||
|
@ -488,7 +497,7 @@ async function messageToEvent(message, guild, options = {}, di) {
|
||||||
|
|
||||||
const isPlaintext = body === html
|
const isPlaintext = body === html
|
||||||
|
|
||||||
if (!isPlaintext) {
|
if (!isPlaintext || options.alwaysReturnFormattedBody) {
|
||||||
Object.assign(newTextMessageEvent, {
|
Object.assign(newTextMessageEvent, {
|
||||||
format: "org.matrix.custom.html",
|
format: "org.matrix.custom.html",
|
||||||
formatted_body: html
|
formatted_body: html
|
||||||
|
@ -506,7 +515,58 @@ async function messageToEvent(message, guild, options = {}, di) {
|
||||||
message.content = "changed the channel name to **" + message.content + "**"
|
message.content = "changed the channel name to **" + message.content + "**"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Forwarded content appears first
|
||||||
|
if (message.message_reference?.type === DiscordTypes.MessageReferenceType.Forward && message.message_snapshots?.length) {
|
||||||
|
// Forwarded notice
|
||||||
|
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)
|
||||||
|
const via = await getViaServersMemo(room.room_id)
|
||||||
|
if (eventID) {
|
||||||
|
forwardedNotice.addLine(
|
||||||
|
`[🔀 Forwarded from #${roomName}]`,
|
||||||
|
tag`🔀 <em>Forwarded from <a href="https://matrix.to/#/${room.room_id}/${eventID}?${via}">${roomName}</a></em>`
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
forwardedNotice.addLine(
|
||||||
|
`[🔀 Forwarded from #${roomName}]`,
|
||||||
|
tag`🔀 <em>Forwarded from <a href="https://matrix.to/#/${room.room_id}?${via}">${roomName}</a></em>`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
forwardedNotice.addLine(
|
||||||
|
`[🔀 Forwarded message]`,
|
||||||
|
tag`🔀 <em>Forwarded message</em>`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forwarded content
|
||||||
|
// @ts-ignore
|
||||||
|
const forwardedEvents = await messageToEvent(message.message_snapshots[0].message, guild, {includeReplyFallback: false, includeEditFallbackStar: false, alwaysReturnFormattedBody: true}, 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>`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to merge the forwarded content with the forwarded notice
|
||||||
|
let {body, formatted_body} = forwardedNotice.get()
|
||||||
|
if (forwardedEvents.length >= 1 && ["m.text", "m.notice"].includes(forwardedEvents[0].msgtype)) { // Try to merge the forwarded content and the forwarded notice
|
||||||
|
forwardedEvents[0].body = body + "\n" + forwardedEvents[0].body
|
||||||
|
forwardedEvents[0].formatted_body = formatted_body + "<br>" + forwardedEvents[0].formatted_body
|
||||||
|
} else {
|
||||||
|
await addTextEvent(body, formatted_body, "m.notice")
|
||||||
|
}
|
||||||
|
events.push(...forwardedEvents)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then text content
|
||||||
if (message.content) {
|
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.
|
// Mentions scenario 3: scan the message content for written @mentions of matrix users. Allows for up to one space between @ and mention.
|
||||||
const matches = [...message.content.matchAll(/@ ?([a-z0-9._]+)\b/gi)]
|
const matches = [...message.content.matchAll(/@ ?([a-z0-9._]+)\b/gi)]
|
||||||
|
@ -525,9 +585,8 @@ async function messageToEvent(message, guild, options = {}, di) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Text content appears first
|
|
||||||
const {body, html} = await transformContent(message.content)
|
const {body, html} = await transformContent(message.content)
|
||||||
await addTextEvent(body, html, msgtype, {scanMentions: true})
|
await addTextEvent(body, html, msgtype)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Then attachments
|
// Then attachments
|
||||||
|
@ -599,9 +658,9 @@ async function messageToEvent(message, guild, options = {}, di) {
|
||||||
let chosenImage = embed.image?.url
|
let chosenImage = embed.image?.url
|
||||||
// the thumbnail seems to be used for "article" type but displayed big at the bottom by discord
|
// the thumbnail seems to be used for "article" type but displayed big at the bottom by discord
|
||||||
if (embed.type === "article" && embed.thumbnail?.url && !chosenImage) chosenImage = embed.thumbnail.url
|
if (embed.type === "article" && embed.thumbnail?.url && !chosenImage) chosenImage = embed.thumbnail.url
|
||||||
if (chosenImage) rep.addParagraph(`📸 ${chosenImage}`)
|
if (chosenImage) rep.addParagraph(`📸 ${dUtils.getPublicUrlForCdn(chosenImage)}`)
|
||||||
|
|
||||||
if (embed.video?.url) rep.addParagraph(`🎞️ ${embed.video.url}`)
|
if (embed.video?.url) rep.addParagraph(`🎞️ ${dUtils.getPublicUrlForCdn(embed.video.url)}`)
|
||||||
|
|
||||||
if (embed.footer?.text) rep.addLine(`— ${embed.footer.text}`, tag`— ${embed.footer.text}`)
|
if (embed.footer?.text) rep.addLine(`— ${embed.footer.text}`, tag`— ${embed.footer.text}`)
|
||||||
let {body, formatted_body: html} = rep.get()
|
let {body, formatted_body: html} = rep.get()
|
||||||
|
@ -609,7 +668,7 @@ async function messageToEvent(message, guild, options = {}, di) {
|
||||||
html = `<blockquote>${html}</blockquote>`
|
html = `<blockquote>${html}</blockquote>`
|
||||||
|
|
||||||
// Send as m.notice to apply the usual automated/subtle appearance, showing this wasn't actually typed by the person
|
// Send as m.notice to apply the usual automated/subtle appearance, showing this wasn't actually typed by the person
|
||||||
await addTextEvent(body, html, "m.notice", {scanMentions: false})
|
await addTextEvent(body, html, "m.notice")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Then stickers
|
// Then stickers
|
||||||
|
|
|
@ -337,7 +337,7 @@ test("message2event: attachment with no content", async t => {
|
||||||
msgtype: "m.image",
|
msgtype: "m.image",
|
||||||
url: "mxc://cadence.moe/qXoZktDqNtEGuOCZEADAMvhM",
|
url: "mxc://cadence.moe/qXoZktDqNtEGuOCZEADAMvhM",
|
||||||
body: "image.png",
|
body: "image.png",
|
||||||
external_url: "https://cdn.discordapp.com/attachments/497161332244742154/1124628646431297546/image.png",
|
external_url: "https://bridge.example.org/download/discordcdn/497161332244742154/1124628646431297546/image.png",
|
||||||
filename: "image.png",
|
filename: "image.png",
|
||||||
info: {
|
info: {
|
||||||
mimetype: "image/png",
|
mimetype: "image/png",
|
||||||
|
@ -373,7 +373,7 @@ test("message2event: stickers", async t => {
|
||||||
msgtype: "m.image",
|
msgtype: "m.image",
|
||||||
url: "mxc://cadence.moe/ZDCNYnkPszxGKgObUIFmvjus",
|
url: "mxc://cadence.moe/ZDCNYnkPszxGKgObUIFmvjus",
|
||||||
body: "image.png",
|
body: "image.png",
|
||||||
external_url: "https://cdn.discordapp.com/attachments/122155380120748034/1106366167486038016/image.png",
|
external_url: "https://bridge.example.org/download/discordcdn/122155380120748034/1106366167486038016/image.png",
|
||||||
filename: "image.png",
|
filename: "image.png",
|
||||||
info: {
|
info: {
|
||||||
mimetype: "image/png",
|
mimetype: "image/png",
|
||||||
|
@ -427,7 +427,7 @@ test("message2event: skull webp attachment with content", async t => {
|
||||||
mimetype: "image/webp",
|
mimetype: "image/webp",
|
||||||
size: 74290
|
size: 74290
|
||||||
},
|
},
|
||||||
external_url: "https://cdn.discordapp.com/attachments/112760669178241024/1128084747910918195/skull.webp",
|
external_url: "https://bridge.example.org/download/discordcdn/112760669178241024/1128084747910918195/skull.webp",
|
||||||
filename: "skull.webp",
|
filename: "skull.webp",
|
||||||
url: "mxc://cadence.moe/sDxWmDErBhYBxtDcJQgBETes"
|
url: "mxc://cadence.moe/sDxWmDErBhYBxtDcJQgBETes"
|
||||||
}])
|
}])
|
||||||
|
@ -461,7 +461,7 @@ test("message2event: reply to skull webp attachment with content", async t => {
|
||||||
mimetype: "image/jpeg",
|
mimetype: "image/jpeg",
|
||||||
size: 85906
|
size: 85906
|
||||||
},
|
},
|
||||||
external_url: "https://cdn.discordapp.com/attachments/112760669178241024/1128084851023675515/RDT_20230704_0936184915846675925224905.jpg",
|
external_url: "https://bridge.example.org/download/discordcdn/112760669178241024/1128084851023675515/RDT_20230704_0936184915846675925224905.jpg",
|
||||||
filename: "RDT_20230704_0936184915846675925224905.jpg",
|
filename: "RDT_20230704_0936184915846675925224905.jpg",
|
||||||
url: "mxc://cadence.moe/WlAbFSiNRIHPDEwKdyPeGywa"
|
url: "mxc://cadence.moe/WlAbFSiNRIHPDEwKdyPeGywa"
|
||||||
}])
|
}])
|
||||||
|
@ -551,7 +551,7 @@ test("message2event: reply with a video", async t => {
|
||||||
body: "Ins_1960637570.mp4",
|
body: "Ins_1960637570.mp4",
|
||||||
filename: "Ins_1960637570.mp4",
|
filename: "Ins_1960637570.mp4",
|
||||||
url: "mxc://cadence.moe/kMqLycqMURhVpwleWkmASpnU",
|
url: "mxc://cadence.moe/kMqLycqMURhVpwleWkmASpnU",
|
||||||
external_url: "https://cdn.discordapp.com/attachments/112760669178241024/1197621094786531358/Ins_1960637570.mp4?ex=65bbee8f&is=65a9798f&hm=ae14f7824c3d526c5e11c162e012e1ee405fd5776e1e9302ed80ccd86503cfda&",
|
external_url: "https://bridge.example.org/download/discordcdn/112760669178241024/1197621094786531358/Ins_1960637570.mp4",
|
||||||
info: {
|
info: {
|
||||||
h: 854,
|
h: 854,
|
||||||
mimetype: "video/mp4",
|
mimetype: "video/mp4",
|
||||||
|
@ -572,7 +572,7 @@ test("message2event: voice message", async t => {
|
||||||
t.deepEqual(events, [{
|
t.deepEqual(events, [{
|
||||||
$type: "m.room.message",
|
$type: "m.room.message",
|
||||||
body: "voice-message.ogg",
|
body: "voice-message.ogg",
|
||||||
external_url: "https://cdn.discordapp.com/attachments/1099031887500034088/1112476845502365786/voice-message.ogg?ex=65c92d4c&is=65b6b84c&hm=0654bab5027474cbe23875954fa117cf44d8914c144cd151879590fa1baf8b1c&",
|
external_url: "https://bridge.example.org/download/discordcdn/1099031887500034088/1112476845502365786/voice-message.ogg",
|
||||||
filename: "voice-message.ogg",
|
filename: "voice-message.ogg",
|
||||||
info: {
|
info: {
|
||||||
duration: 3960.0000381469727,
|
duration: 3960.0000381469727,
|
||||||
|
@ -595,7 +595,7 @@ test("message2event: misc file", async t => {
|
||||||
}, {
|
}, {
|
||||||
$type: "m.room.message",
|
$type: "m.room.message",
|
||||||
body: "the.yml",
|
body: "the.yml",
|
||||||
external_url: "https://cdn.discordapp.com/attachments/122155380120748034/1174514575220158545/the.yml?ex=65cd6270&is=65baed70&hm=8c5f1b571784e3c7f99628492298815884e351ae0dc7c2ae40dd22d97caf27d9&",
|
external_url: "https://bridge.example.org/download/discordcdn/122155380120748034/1174514575220158545/the.yml",
|
||||||
filename: "the.yml",
|
filename: "the.yml",
|
||||||
info: {
|
info: {
|
||||||
mimetype: "text/plain; charset=utf-8",
|
mimetype: "text/plain; charset=utf-8",
|
||||||
|
@ -1014,3 +1014,123 @@ test("message2event: @everyone within a link", async t => {
|
||||||
"m.mentions": {}
|
"m.mentions": {}
|
||||||
}])
|
}])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("message2event: forwarded image", async t => {
|
||||||
|
const events = await messageToEvent(data.message.forwarded_image)
|
||||||
|
t.deepEqual(events, [
|
||||||
|
{
|
||||||
|
$type: "m.room.message",
|
||||||
|
body: "[🔀 Forwarded message]",
|
||||||
|
format: "org.matrix.custom.html",
|
||||||
|
formatted_body: "🔀 <em>Forwarded message</em>",
|
||||||
|
"m.mentions": {},
|
||||||
|
msgtype: "m.notice",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$type: "m.room.message",
|
||||||
|
body: "100km.gif",
|
||||||
|
external_url: "https://bridge.example.org/download/discordcdn/112760669178241024/1296237494987133070/100km.gif",
|
||||||
|
filename: "100km.gif",
|
||||||
|
info: {
|
||||||
|
h: 300,
|
||||||
|
mimetype: "image/gif",
|
||||||
|
size: 2965649,
|
||||||
|
w: 300,
|
||||||
|
},
|
||||||
|
"m.mentions": {},
|
||||||
|
msgtype: "m.image",
|
||||||
|
url: "mxc://cadence.moe/qDAotmebTfEIfsAIVCEZptLh",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("message2event: constructed forwarded message", async t => {
|
||||||
|
const events = await messageToEvent(data.message.constructed_forwarded_message, {}, {}, {
|
||||||
|
api: {
|
||||||
|
async getJoinedMembers() {
|
||||||
|
return {
|
||||||
|
joined: {
|
||||||
|
"@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null},
|
||||||
|
"@user:matrix.org": {display_name: null, avatar_url: null}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.deepEqual(events, [
|
||||||
|
{
|
||||||
|
$type: "m.room.message",
|
||||||
|
body: "[🔀 Forwarded from #wonderland]"
|
||||||
|
+ "\n» What's cooking, good looking? :hipposcope:",
|
||||||
|
format: "org.matrix.custom.html",
|
||||||
|
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.notice",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$type: "m.room.message",
|
||||||
|
body: "100km.gif",
|
||||||
|
external_url: "https://bridge.example.org/download/discordcdn/112760669178241024/1296237494987133070/100km.gif",
|
||||||
|
filename: "100km.gif",
|
||||||
|
info: {
|
||||||
|
h: 300,
|
||||||
|
mimetype: "image/gif",
|
||||||
|
size: 2965649,
|
||||||
|
w: 300,
|
||||||
|
},
|
||||||
|
"m.mentions": {},
|
||||||
|
msgtype: "m.image",
|
||||||
|
url: "mxc://cadence.moe/qDAotmebTfEIfsAIVCEZptLh",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$type: "m.room.message",
|
||||||
|
body: "» | ## This man"
|
||||||
|
+ "\n» | "
|
||||||
|
+ "\n» | ## This man is 100 km away from your house"
|
||||||
|
+ "\n» | "
|
||||||
|
+ "\n» | ### Distance away"
|
||||||
|
+ "\n» | 99 km"
|
||||||
|
+ "\n» | "
|
||||||
|
+ "\n» | ### Distance away"
|
||||||
|
+ "\n» | 98 km",
|
||||||
|
format: "org.matrix.custom.html",
|
||||||
|
formatted_body: "<blockquote><blockquote><p><strong>This man</strong></p><p><strong>This man is 100 km away from your house</strong></p><p><strong>Distance away</strong><br>99 km</p><p><strong>Distance away</strong><br>98 km</p></blockquote></blockquote>",
|
||||||
|
"m.mentions": {},
|
||||||
|
msgtype: "m.notice"
|
||||||
|
}
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("message2event: constructed forwarded text", async t => {
|
||||||
|
const events = await messageToEvent(data.message.constructed_forwarded_text, {}, {}, {
|
||||||
|
api: {
|
||||||
|
async getJoinedMembers() {
|
||||||
|
return {
|
||||||
|
joined: {
|
||||||
|
"@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null},
|
||||||
|
"@user:matrix.org": {display_name: null, avatar_url: null}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.deepEqual(events, [
|
||||||
|
{
|
||||||
|
$type: "m.room.message",
|
||||||
|
body: "[🔀 Forwarded from #amanda-spam]"
|
||||||
|
+ "\n» What's cooking, good looking?",
|
||||||
|
format: "org.matrix.custom.html",
|
||||||
|
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.notice",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$type: "m.room.message",
|
||||||
|
body: "What's cooking everybody ‼️",
|
||||||
|
"m.mentions": {},
|
||||||
|
msgtype: "m.text",
|
||||||
|
}
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
|
@ -4,7 +4,11 @@
|
||||||
|
|
||||||
const DiscordTypes = require("discord-api-types/v10")
|
const DiscordTypes = require("discord-api-types/v10")
|
||||||
const passthrough = require("../passthrough")
|
const passthrough = require("../passthrough")
|
||||||
const { sync } = 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 = {
|
const utils = {
|
||||||
/**
|
/**
|
||||||
|
@ -36,13 +40,16 @@ const utils = {
|
||||||
channel.guild_id = message.d.id
|
channel.guild_id = message.d.id
|
||||||
arr.push(channel.id)
|
arr.push(channel.id)
|
||||||
client.channels.set(channel.id, channel)
|
client.channels.set(channel.id, channel)
|
||||||
|
populateGuildID(message.d.id, channel.id)
|
||||||
}
|
}
|
||||||
for (const thread of message.d.threads || []) {
|
for (const thread of message.d.threads || []) {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
thread.guild_id = message.d.id
|
thread.guild_id = message.d.id
|
||||||
arr.push(thread.id)
|
arr.push(thread.id)
|
||||||
client.channels.set(thread.id, thread)
|
client.channels.set(thread.id, thread)
|
||||||
|
populateGuildID(message.d.id, thread.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (listen === "full") {
|
if (listen === "full") {
|
||||||
eventDispatcher.checkMissedExpressions(message.d)
|
eventDispatcher.checkMissedExpressions(message.d)
|
||||||
eventDispatcher.checkMissedPins(client, message.d)
|
eventDispatcher.checkMissedPins(client, message.d)
|
||||||
|
@ -91,7 +98,11 @@ const utils = {
|
||||||
|
|
||||||
} else if (message.t === "THREAD_CREATE") {
|
} else if (message.t === "THREAD_CREATE") {
|
||||||
client.channels.set(message.d.id, message.d)
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
} else if (message.t === "CHANNEL_UPDATE" || message.t === "THREAD_UPDATE") {
|
} else if (message.t === "CHANNEL_UPDATE" || message.t === "THREAD_UPDATE") {
|
||||||
client.channels.set(message.d.id, message.d)
|
client.channels.set(message.d.id, message.d)
|
||||||
|
@ -113,14 +124,15 @@ const utils = {
|
||||||
client.guildChannelMap.delete(message.d.id)
|
client.guildChannelMap.delete(message.d.id)
|
||||||
|
|
||||||
|
|
||||||
} else if (message.t === "CHANNEL_CREATE" || message.t === "CHANNEL_DELETE") {
|
} else if (message.t === "CHANNEL_CREATE") {
|
||||||
if (message.t === "CHANNEL_CREATE") {
|
|
||||||
client.channels.set(message.d.id, message.d)
|
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
|
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"])
|
const channels = client.guildChannelMap.get(message.d["guild_id"])
|
||||||
if (channels && !channels.includes(message.d.id)) channels.push(message.d.id)
|
if (channels && !channels.includes(message.d.id)) channels.push(message.d.id)
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
|
} else if (message.t === "CHANNEL_DELETE") {
|
||||||
client.channels.delete(message.d.id)
|
client.channels.delete(message.d.id)
|
||||||
if (message.d["guild_id"]) {
|
if (message.d["guild_id"]) {
|
||||||
const channels = client.guildChannelMap.get(message.d["guild_id"])
|
const channels = client.guildChannelMap.get(message.d["guild_id"])
|
||||||
|
@ -130,7 +142,6 @@ const utils = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Event dispatcher for OOYE bridge operations
|
// Event dispatcher for OOYE bridge operations
|
||||||
if (listen === "full") {
|
if (listen === "full") {
|
||||||
|
|
|
@ -191,7 +191,7 @@ module.exports = {
|
||||||
async onThreadCreate(client, thread) {
|
async onThreadCreate(client, thread) {
|
||||||
const channelID = thread.parent_id || undefined
|
const channelID = thread.parent_id || undefined
|
||||||
const parentRoomID = select("channel_room", "room_id", {channel_id: channelID}).pluck().get()
|
const parentRoomID = select("channel_room", "room_id", {channel_id: channelID}).pluck().get()
|
||||||
if (!parentRoomID) return // Not interested in a thread if we aren't interested in its wider channel
|
if (!parentRoomID) return // Not interested in a thread if we aren't interested in its wider channel (won't autocreate)
|
||||||
const threadRoomID = await createRoom.syncRoom(thread.id) // Create room (will share the same inflight as the initial message to the thread)
|
const threadRoomID = await createRoom.syncRoom(thread.id) // Create room (will share the same inflight as the initial message to the thread)
|
||||||
await announceThread.announceThread(parentRoomID, threadRoomID, thread)
|
await announceThread.announceThread(parentRoomID, threadRoomID, thread)
|
||||||
},
|
},
|
||||||
|
@ -249,6 +249,7 @@ module.exports = {
|
||||||
if (message.author.username === "Deleted User") return // Nothing we can do for deleted users.
|
if (message.author.username === "Deleted User") return // Nothing we can do for deleted users.
|
||||||
const channel = client.channels.get(message.channel_id)
|
const channel = client.channels.get(message.channel_id)
|
||||||
if (!channel || !("guild_id" in channel) || !channel.guild_id) return // Nothing we can do in direct messages.
|
if (!channel || !("guild_id" in channel) || !channel.guild_id) return // Nothing we can do in direct messages.
|
||||||
|
|
||||||
const guild = client.guilds.get(channel.guild_id)
|
const guild = client.guilds.get(channel.guild_id)
|
||||||
assert(guild)
|
assert(guild)
|
||||||
|
|
||||||
|
@ -259,11 +260,13 @@ module.exports = {
|
||||||
|
|
||||||
if (dUtils.isEphemeralMessage(message)) return // Ephemeral messages are for the eyes of the receiver only!
|
if (dUtils.isEphemeralMessage(message)) return // Ephemeral messages are for the eyes of the receiver only!
|
||||||
|
|
||||||
|
if (!createRoom.existsOrAutocreatable(channel, guild.id)) return // Check that the sending-to room exists or is autocreatable
|
||||||
|
|
||||||
const {affected, row} = await speedbump.maybeDoSpeedbump(message.channel_id, message.id)
|
const {affected, row} = await speedbump.maybeDoSpeedbump(message.channel_id, message.id)
|
||||||
if (affected) return
|
if (affected) return
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
await sendMessage.sendMessage(message, channel, guild, row),
|
await sendMessage.sendMessage(message, channel, guild, row)
|
||||||
|
|
||||||
retrigger.messageFinishedBridging(message.id)
|
retrigger.messageFinishedBridging(message.id)
|
||||||
},
|
},
|
||||||
|
@ -278,9 +281,6 @@ module.exports = {
|
||||||
// Otherwise, if there are embeds, then the system generated URL preview embeds.
|
// Otherwise, if there are embeds, then the system generated URL preview embeds.
|
||||||
if (!(typeof data.content === "string" || "embeds" in data)) return
|
if (!(typeof data.content === "string" || "embeds" in data)) return
|
||||||
|
|
||||||
// Deal with Eventual Consistency(TM)
|
|
||||||
if (retrigger.eventNotFoundThenRetrigger(data.id, module.exports.onMessageUpdate, client, data)) return
|
|
||||||
|
|
||||||
if (data.webhook_id) {
|
if (data.webhook_id) {
|
||||||
const row = select("webhook", "webhook_id", {webhook_id: data.webhook_id}).pluck().get()
|
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 (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.
|
||||||
|
@ -292,16 +292,19 @@ module.exports = {
|
||||||
const {affected, row} = await speedbump.maybeDoSpeedbump(data.channel_id, data.id)
|
const {affected, row} = await speedbump.maybeDoSpeedbump(data.channel_id, data.id)
|
||||||
if (affected) return
|
if (affected) return
|
||||||
|
|
||||||
|
// Check that the sending-to room exists, and deal with Eventual Consistency(TM)
|
||||||
|
if (retrigger.eventNotFoundThenRetrigger(data.id, module.exports.onMessageUpdate, client, data)) return
|
||||||
|
|
||||||
/** @type {DiscordTypes.GatewayMessageCreateDispatchData} */
|
/** @type {DiscordTypes.GatewayMessageCreateDispatchData} */
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const message = data
|
const message = data
|
||||||
|
|
||||||
const channel = client.channels.get(message.channel_id)
|
const channel = client.channels.get(message.channel_id)
|
||||||
if (!channel || !("guild_id" in channel) || !channel.guild_id) return // Nothing we can do in direct messages.
|
if (!channel || !("guild_id" in channel) || !channel.guild_id) return // Nothing we can do in direct messages.
|
||||||
const guild = client.guilds.get(channel.guild_id)
|
const guild = client.guilds.get(channel.guild_id)
|
||||||
assert(guild)
|
assert(guild)
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
await editMessage.editMessage(message, guild, row)
|
await retrigger.pauseChanges(message.id, editMessage.editMessage(message, guild, row))
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -3,6 +3,7 @@ module.exports = async function(db) {
|
||||||
const contents = db.prepare("SELECT distinct hashed_profile_content FROM sim_member WHERE hashed_profile_content IS NOT NULL").pluck().all()
|
const contents = db.prepare("SELECT distinct hashed_profile_content FROM sim_member WHERE hashed_profile_content IS NOT NULL").pluck().all()
|
||||||
const stmt = db.prepare("UPDATE sim_member SET hashed_profile_content = ? WHERE hashed_profile_content = ?")
|
const stmt = db.prepare("UPDATE sim_member SET hashed_profile_content = ? WHERE hashed_profile_content = ?")
|
||||||
db.transaction(() => {
|
db.transaction(() => {
|
||||||
|
/* c8 ignore next 6 */
|
||||||
for (let s of contents) {
|
for (let s of contents) {
|
||||||
let b = Buffer.isBuffer(s) ? Uint8Array.from(s) : Uint8Array.from(Buffer.from(s))
|
let b = Buffer.isBuffer(s) ? Uint8Array.from(s) : Uint8Array.from(Buffer.from(s))
|
||||||
const unsignedHash = hasher.h64Raw(b)
|
const unsignedHash = hasher.h64Raw(b)
|
||||||
|
|
11
src/db/migrations/0014-add-guild-active.sql
Normal file
11
src/db/migrations/0014-add-guild-active.sql
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
BEGIN TRANSACTION;
|
||||||
|
|
||||||
|
CREATE TABLE "guild_active" (
|
||||||
|
"guild_id" TEXT NOT NULL,
|
||||||
|
"autocreate" INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY("guild_id")
|
||||||
|
) WITHOUT ROWID;
|
||||||
|
|
||||||
|
INSERT INTO guild_active (guild_id, autocreate) SELECT guild_id, 1 FROM guild_space;
|
||||||
|
|
||||||
|
COMMIT;
|
|
@ -1,5 +0,0 @@
|
||||||
BEGIN TRANSACTION;
|
|
||||||
|
|
||||||
ALTER TABLE guild_space ADD COLUMN autocreate INTEGER NOT NULL DEFAULT 1;
|
|
||||||
|
|
||||||
COMMIT;
|
|
5
src/db/migrations/0015-add-guild-id-to-channel-room.sql
Normal file
5
src/db/migrations/0015-add-guild-id-to-channel-room.sql
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
BEGIN TRANSACTION;
|
||||||
|
|
||||||
|
ALTER TABLE channel_room ADD COLUMN guild_id TEXT;
|
||||||
|
|
||||||
|
COMMIT;
|
7
src/db/orm-defs.d.ts
vendored
7
src/db/orm-defs.d.ts
vendored
|
@ -31,7 +31,11 @@ export type Models = {
|
||||||
guild_id: string
|
guild_id: string
|
||||||
space_id: string
|
space_id: string
|
||||||
privacy_level: number
|
privacy_level: number
|
||||||
autocreate: number
|
}
|
||||||
|
|
||||||
|
guild_active: {
|
||||||
|
guild_id: string
|
||||||
|
autocreate: 0 | 1
|
||||||
}
|
}
|
||||||
|
|
||||||
lottie: {
|
lottie: {
|
||||||
|
@ -120,3 +124,4 @@ export type PickTypeOf<T, K extends AllKeys<T>> = T extends { [k in K]?: any } ?
|
||||||
export type Merge<U> = {[x in AllKeys<U>]: PickTypeOf<U, x>}
|
export type Merge<U> = {[x in AllKeys<U>]: PickTypeOf<U, x>}
|
||||||
export type Nullable<T> = {[k in keyof T]: T[k] | null}
|
export type Nullable<T> = {[k in keyof T]: T[k] | null}
|
||||||
export type Numberish<T> = {[k in keyof T]: T[k] extends number ? (number | bigint) : T[k]}
|
export type Numberish<T> = {[k in keyof T]: T[k] extends number ? (number | bigint) : T[k]}
|
||||||
|
export type ValueOrArray<T> = {[k in keyof T]: T[k][] | T[k]}
|
||||||
|
|
|
@ -8,7 +8,7 @@ const U = require("./orm-defs")
|
||||||
* @template {keyof U.Models[Table]} Col
|
* @template {keyof U.Models[Table]} Col
|
||||||
* @param {Table} table
|
* @param {Table} table
|
||||||
* @param {Col[] | Col} cols
|
* @param {Col[] | Col} cols
|
||||||
* @param {Partial<U.Numberish<U.Models[Table]>>} where
|
* @param {Partial<U.ValueOrArray<U.Numberish<U.Models[Table]>>>} where
|
||||||
* @param {string} [e]
|
* @param {string} [e]
|
||||||
*/
|
*/
|
||||||
function select(table, cols, where = {}, e = "") {
|
function select(table, cols, where = {}, e = "") {
|
||||||
|
|
|
@ -30,6 +30,11 @@ test("orm: select: all, where and pluck works on multiple columns", t => {
|
||||||
t.deepEqual(names, ["cadence [they]"])
|
t.deepEqual(names, ["cadence [they]"])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("orm: select: in array works", t => {
|
||||||
|
const ids = select("emoji", "emoji_id", {name: ["online", "upstinky"]}).pluck().all()
|
||||||
|
t.deepEqual(ids, ["288858540888686602", "606664341298872324"])
|
||||||
|
})
|
||||||
|
|
||||||
test("orm: from: get pluck works", t => {
|
test("orm: from: get pluck works", t => {
|
||||||
const guildID = from("guild_space").pluck("guild_id").and("WHERE space_id = ?").get("!jjWAGMeQdNrVZSSfvz:cadence.moe")
|
const guildID = from("guild_space").pluck("guild_id").and("WHERE space_id = ?").get("!jjWAGMeQdNrVZSSfvz:cadence.moe")
|
||||||
t.equal(guildID, data.guild.general.id)
|
t.equal(guildID, data.guild.general.id)
|
||||||
|
@ -53,3 +58,13 @@ test("orm: from: join direction works", t => {
|
||||||
const hasNoOwnerInner = from("sim").join("sim_proxy", "user_id", "inner").select("user_id", "proxy_owner_id").where({sim_name: "crunch_god"}).get()
|
const hasNoOwnerInner = from("sim").join("sim_proxy", "user_id", "inner").select("user_id", "proxy_owner_id").where({sim_name: "crunch_god"}).get()
|
||||||
t.deepEqual(hasNoOwnerInner, undefined)
|
t.deepEqual(hasNoOwnerInner, undefined)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("orm: select unsafe works (to select complex column names that can't be type verified)", t => {
|
||||||
|
const results = from("member_cache")
|
||||||
|
.join("member_power", "mxid")
|
||||||
|
.join("channel_room", "room_id") // only include rooms that are bridged
|
||||||
|
.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, 100)
|
||||||
|
})
|
||||||
|
|
|
@ -1,115 +0,0 @@
|
||||||
// @ts-check
|
|
||||||
|
|
||||||
const DiscordTypes = require("discord-api-types/v10")
|
|
||||||
const Ty = require("../../types")
|
|
||||||
const {discord, sync, db, select, from, as} = require("../../passthrough")
|
|
||||||
const assert = require("assert/strict")
|
|
||||||
|
|
||||||
/** @type {import("../../matrix/api")} */
|
|
||||||
const api = sync.require("../../matrix/api")
|
|
||||||
|
|
||||||
/** @type {Map<string, Promise<{name: string, value: string}[]>>} spaceID -> list of rooms */
|
|
||||||
const cache = new Map()
|
|
||||||
/** @type {Map<string, string>} roomID -> spaceID */
|
|
||||||
const reverseCache = new Map()
|
|
||||||
|
|
||||||
// Manage clearing the cache
|
|
||||||
sync.addTemporaryListener(as, "type:m.room.name", /** @param {Ty.Event.StateOuter<Ty.Event.M_Room_Name>} event */ async event => {
|
|
||||||
if (event.state_key !== "") return
|
|
||||||
const roomID = event.room_id
|
|
||||||
const spaceID = reverseCache.get(roomID)
|
|
||||||
if (!spaceID) return
|
|
||||||
const childRooms = await cache.get(spaceID)
|
|
||||||
if (!childRooms) return
|
|
||||||
if (event.content.name) {
|
|
||||||
const found = childRooms.find(r => r.value === roomID)
|
|
||||||
if (!found) return
|
|
||||||
found.name = event.content.name
|
|
||||||
} else {
|
|
||||||
cache.set(spaceID, Promise.resolve(childRooms.filter(r => r.value !== roomID)))
|
|
||||||
reverseCache.delete(roomID)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Manage adding to the cache
|
|
||||||
async function getCachedHierarchy(spaceID) {
|
|
||||||
return cache.get(spaceID) || (() => {
|
|
||||||
const entry = (async () => {
|
|
||||||
const result = await api.getFullHierarchy(spaceID)
|
|
||||||
/** @type {{name: string, value: string}[]} */
|
|
||||||
const childRooms = []
|
|
||||||
for (const room of result) {
|
|
||||||
if (room.name && !room.name.match(/^\[[⛓️🔊]\]/) && room.room_type !== "m.space") {
|
|
||||||
childRooms.push({name: room.name, value: room.room_id})
|
|
||||||
reverseCache.set(room.room_id, spaceID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return childRooms
|
|
||||||
})()
|
|
||||||
cache.set(spaceID, entry)
|
|
||||||
return entry
|
|
||||||
})()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @param {DiscordTypes.APIApplicationCommandAutocompleteGuildInteraction} interaction */
|
|
||||||
async function interactAutocomplete({id, token, data, guild_id}) {
|
|
||||||
const spaceID = select("guild_space", "space_id", {guild_id}).pluck().get()
|
|
||||||
if (!spaceID) {
|
|
||||||
return discord.snow.interaction.createInteractionResponse(id, token, {
|
|
||||||
type: DiscordTypes.InteractionResponseType.ApplicationCommandAutocompleteResult,
|
|
||||||
data: {
|
|
||||||
choices: [
|
|
||||||
{
|
|
||||||
name: `Error: This server needs to be bridged somewhere first...`,
|
|
||||||
value: "baby"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
let rooms = await getCachedHierarchy(spaceID)
|
|
||||||
// @ts-ignore
|
|
||||||
rooms = rooms.filter(r => r.name.includes(data.options[0].value))
|
|
||||||
|
|
||||||
await discord.snow.interaction.createInteractionResponse(id, token, {
|
|
||||||
type: DiscordTypes.InteractionResponseType.ApplicationCommandAutocompleteResult,
|
|
||||||
data: {
|
|
||||||
choices: rooms
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @param {DiscordTypes.APIChatInputApplicationCommandGuildInteraction} interaction */
|
|
||||||
async function interactSubmit({id, token, data, guild_id}) {
|
|
||||||
const spaceID = select("guild_space", "space_id", {guild_id}).pluck().get()
|
|
||||||
if (!spaceID) {
|
|
||||||
return discord.snow.interaction.createInteractionResponse(id, token, {
|
|
||||||
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
|
|
||||||
data: {
|
|
||||||
content: "Error: This server needs to be bridged somewhere first...",
|
|
||||||
flags: DiscordTypes.MessageFlags.Ephemeral
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return discord.snow.interaction.createInteractionResponse(id, token, {
|
|
||||||
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
|
|
||||||
data: {
|
|
||||||
content: "Valid input. This would do something but it isn't implemented yet.",
|
|
||||||
flags: DiscordTypes.MessageFlags.Ephemeral
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @param {DiscordTypes.APIGuildInteraction} interaction */
|
|
||||||
async function interact(interaction) {
|
|
||||||
if (interaction.type === DiscordTypes.InteractionType.ApplicationCommandAutocomplete) {
|
|
||||||
return interactAutocomplete(interaction)
|
|
||||||
} else if (interaction.type === DiscordTypes.InteractionType.ApplicationCommand) {
|
|
||||||
// @ts-ignore
|
|
||||||
return interactSubmit(interaction)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports.interact = interact
|
|
|
@ -2,39 +2,69 @@
|
||||||
|
|
||||||
const DiscordTypes = require("discord-api-types/v10")
|
const DiscordTypes = require("discord-api-types/v10")
|
||||||
const assert = require("assert/strict")
|
const assert = require("assert/strict")
|
||||||
const {discord, sync, db, select, from} = require("../../passthrough")
|
const {InteractionMethods} = require("snowtransfer")
|
||||||
|
const {id: botID} = require("../../../addbot")
|
||||||
|
const {discord, sync, db, select} = require("../../passthrough")
|
||||||
|
|
||||||
|
/** @type {import("../../d2m/actions/create-room")} */
|
||||||
|
const createRoom = sync.require("../../d2m/actions/create-room")
|
||||||
|
/** @type {import("../../d2m/actions/create-space")} */
|
||||||
|
const createSpace = sync.require("../../d2m/actions/create-space")
|
||||||
/** @type {import("../../matrix/api")} */
|
/** @type {import("../../matrix/api")} */
|
||||||
const api = sync.require("../../matrix/api")
|
const api = sync.require("../../matrix/api")
|
||||||
|
/** @type {import("../../matrix/read-registration")} */
|
||||||
|
const {reg} = sync.require("../../matrix/read-registration")
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {DiscordTypes.APIChatInputApplicationCommandGuildInteraction} interaction
|
* @param {DiscordTypes.APIChatInputApplicationCommandGuildInteraction & {channel: DiscordTypes.APIGuildTextChannel}} interaction
|
||||||
* @returns {Promise<DiscordTypes.APIInteractionResponse>}
|
* @param {{api: typeof api}} di
|
||||||
|
* @returns {AsyncGenerator<{[k in keyof InteractionMethods]?: Parameters<InteractionMethods[k]>[2]}>}
|
||||||
*/
|
*/
|
||||||
async function _interact({data, channel, guild_id}) {
|
async function* _interact({data, channel, guild_id}, {api}) {
|
||||||
// Check guild is bridged
|
// Check guild exists - it might not exist if the application was added with applications.commands scope and not bot scope
|
||||||
const spaceID = select("guild_space", "space_id", {guild_id}).pluck().get()
|
const guild = discord.guilds.get(guild_id)
|
||||||
const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get()
|
if (!guild) return yield {createInteractionResponse: {
|
||||||
if (!spaceID || !roomID) return {
|
|
||||||
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
|
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
|
||||||
data: {
|
data: {
|
||||||
content: "This server isn't bridged to Matrix, so you can't invite Matrix users.",
|
content: `I can't perform actions in this server because there is no bot presence in the server. You should try re-adding this bot to the server, making sure that it has bot scope (not just commands).\nIf you add the bot from ${reg.ooye.bridge_origin} this should work automatically.`,
|
||||||
flags: DiscordTypes.MessageFlags.Ephemeral
|
flags: DiscordTypes.MessageFlags.Ephemeral
|
||||||
}
|
}
|
||||||
}
|
}}
|
||||||
|
|
||||||
// Get named MXID
|
// Get named MXID
|
||||||
/** @type {DiscordTypes.APIApplicationCommandInteractionDataStringOption[] | undefined} */ // @ts-ignore
|
/** @type {DiscordTypes.APIApplicationCommandInteractionDataStringOption[] | undefined} */ // @ts-ignore
|
||||||
const options = data.options
|
const options = data.options
|
||||||
const input = options?.[0].value || ""
|
const input = options?.[0]?.value || ""
|
||||||
const mxid = input.match(/@([^:]+):([a-z0-9:-]+\.[a-z0-9.:-]+)/)?.[0]
|
const mxid = input.match(/@([^:]+):([a-z0-9:-]+\.[a-z0-9.:-]+)/)?.[0]
|
||||||
if (!mxid) return {
|
if (!mxid) return yield {createInteractionResponse: {
|
||||||
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
|
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
|
||||||
data: {
|
data: {
|
||||||
content: "You have to say the Matrix ID of the person you want to invite. Matrix IDs look like this: `@username:example.org`",
|
content: "You have to say the Matrix ID of the person you want to invite. Matrix IDs look like this: `@username:example.org`",
|
||||||
flags: DiscordTypes.MessageFlags.Ephemeral
|
flags: DiscordTypes.MessageFlags.Ephemeral
|
||||||
}
|
}
|
||||||
|
}}
|
||||||
|
|
||||||
|
// Ensure guild and room are bridged
|
||||||
|
db.prepare("INSERT OR IGNORE INTO guild_active (guild_id, autocreate) VALUES (?, 1)").run(guild_id)
|
||||||
|
const existing = createRoom.existsOrAutocreatable(channel, guild_id)
|
||||||
|
if (existing === 0) return yield {createInteractionResponse: {
|
||||||
|
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
|
||||||
|
data: {
|
||||||
|
content: "This channel isn't bridged, so you can't invite Matrix users yet. Try turning on automatic room-creation or link a Matrix room in the website.",
|
||||||
|
flags: DiscordTypes.MessageFlags.Ephemeral
|
||||||
}
|
}
|
||||||
|
}}
|
||||||
|
assert(existing) // can't be null or undefined as we just inserted the guild_active row
|
||||||
|
|
||||||
|
yield {createInteractionResponse: {
|
||||||
|
type: DiscordTypes.InteractionResponseType.DeferredChannelMessageWithSource,
|
||||||
|
data: {
|
||||||
|
flags: DiscordTypes.MessageFlags.Ephemeral
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
|
||||||
|
const spaceID = await createSpace.ensureSpace(guild)
|
||||||
|
const roomID = await createRoom.ensureRoom(channel.id)
|
||||||
|
|
||||||
// Check for existing invite to the space
|
// Check for existing invite to the space
|
||||||
let spaceMember
|
let spaceMember
|
||||||
|
@ -42,24 +72,17 @@ async function _interact({data, channel, guild_id}) {
|
||||||
spaceMember = await api.getStateEvent(spaceID, "m.room.member", mxid)
|
spaceMember = await api.getStateEvent(spaceID, "m.room.member", mxid)
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
if (spaceMember && spaceMember.membership === "invite") {
|
if (spaceMember && spaceMember.membership === "invite") {
|
||||||
return {
|
return yield {editOriginalInteractionResponse: {
|
||||||
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
|
|
||||||
data: {
|
|
||||||
content: `\`${mxid}\` already has an invite, which they haven't accepted yet.`,
|
content: `\`${mxid}\` already has an invite, which they haven't accepted yet.`,
|
||||||
flags: DiscordTypes.MessageFlags.Ephemeral
|
}}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Invite Matrix user if not in space
|
// Invite Matrix user if not in space
|
||||||
if (!spaceMember || spaceMember.membership !== "join") {
|
if (!spaceMember || spaceMember.membership !== "join") {
|
||||||
await api.inviteToRoom(spaceID, mxid)
|
await api.inviteToRoom(spaceID, mxid)
|
||||||
return {
|
return yield {editOriginalInteractionResponse: {
|
||||||
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
|
|
||||||
data: {
|
|
||||||
content: `You invited \`${mxid}\` to the server.`
|
content: `You invited \`${mxid}\` to the server.`
|
||||||
}
|
}}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The Matrix user *is* in the space, maybe we want to invite them to this channel?
|
// The Matrix user *is* in the space, maybe we want to invite them to this channel?
|
||||||
|
@ -68,11 +91,8 @@ async function _interact({data, channel, guild_id}) {
|
||||||
roomMember = await api.getStateEvent(roomID, "m.room.member", mxid)
|
roomMember = await api.getStateEvent(roomID, "m.room.member", mxid)
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
if (!roomMember || (roomMember.membership !== "join" && roomMember.membership !== "invite")) {
|
if (!roomMember || (roomMember.membership !== "join" && roomMember.membership !== "invite")) {
|
||||||
return {
|
return yield {editOriginalInteractionResponse: {
|
||||||
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
|
|
||||||
data: {
|
|
||||||
content: `\`${mxid}\` is already in this server. Would you like to additionally invite them to this specific channel?`,
|
content: `\`${mxid}\` is already in this server. Would you like to additionally invite them to this specific channel?`,
|
||||||
flags: DiscordTypes.MessageFlags.Ephemeral,
|
|
||||||
components: [{
|
components: [{
|
||||||
type: DiscordTypes.ComponentType.ActionRow,
|
type: DiscordTypes.ComponentType.ActionRow,
|
||||||
components: [{
|
components: [{
|
||||||
|
@ -82,25 +102,21 @@ async function _interact({data, channel, guild_id}) {
|
||||||
label: "Sure",
|
label: "Sure",
|
||||||
}]
|
}]
|
||||||
}]
|
}]
|
||||||
}
|
}}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The Matrix user *is* in the space and in the channel.
|
// The Matrix user *is* in the space and in the channel.
|
||||||
return {
|
return yield {editOriginalInteractionResponse: {
|
||||||
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
|
|
||||||
data: {
|
|
||||||
content: `\`${mxid}\` is already in this server and this channel.`,
|
content: `\`${mxid}\` is already in this server and this channel.`,
|
||||||
flags: DiscordTypes.MessageFlags.Ephemeral
|
}}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {DiscordTypes.APIMessageComponentGuildInteraction} interaction
|
* @param {DiscordTypes.APIMessageComponentGuildInteraction} interaction
|
||||||
|
* @param {{api: typeof api}} di
|
||||||
* @returns {Promise<DiscordTypes.APIInteractionResponse>}
|
* @returns {Promise<DiscordTypes.APIInteractionResponse>}
|
||||||
*/
|
*/
|
||||||
async function _interactButton({channel, message}) {
|
async function _interactButton({channel, message}, {api}) {
|
||||||
const mxid = message.content.match(/`(@(?:[^:]+):(?:[a-z0-9:-]+\.[a-z0-9.:-]+))`/)?.[1]
|
const mxid = message.content.match(/`(@(?:[^:]+):(?:[a-z0-9:-]+\.[a-z0-9.:-]+))`/)?.[1]
|
||||||
assert(mxid)
|
assert(mxid)
|
||||||
const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get()
|
const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get()
|
||||||
|
@ -115,14 +131,23 @@ async function _interactButton({channel, message}) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param {DiscordTypes.APIChatInputApplicationCommandGuildInteraction} interaction */
|
/* c8 ignore start */
|
||||||
|
|
||||||
|
/** @param {DiscordTypes.APIChatInputApplicationCommandGuildInteraction & {channel: DiscordTypes.APIGuildTextChannel}} interaction */
|
||||||
async function interact(interaction) {
|
async function interact(interaction) {
|
||||||
await discord.snow.interaction.createInteractionResponse(interaction.id, interaction.token, await _interact(interaction))
|
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)
|
||||||
|
} else if (response.editOriginalInteractionResponse) {
|
||||||
|
await discord.snow.interaction.editOriginalInteractionResponse(botID, interaction.token, response.editOriginalInteractionResponse)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param {DiscordTypes.APIMessageComponentGuildInteraction} interaction */
|
/** @param {DiscordTypes.APIMessageComponentGuildInteraction} interaction */
|
||||||
async function interactButton(interaction) {
|
async function interactButton(interaction) {
|
||||||
await discord.snow.interaction.createInteractionResponse(interaction.id, interaction.token, await _interactButton(interaction))
|
await discord.snow.interaction.createInteractionResponse(interaction.id, interaction.token, await _interactButton(interaction, {api}))
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports.interact = interact
|
module.exports.interact = interact
|
||||||
|
|
256
src/discord/interactions/invite.test.js
Normal file
256
src/discord/interactions/invite.test.js
Normal file
|
@ -0,0 +1,256 @@
|
||||||
|
const {test} = require("supertape")
|
||||||
|
const DiscordTypes = require("discord-api-types/v10")
|
||||||
|
const {db, discord} = require("../../passthrough")
|
||||||
|
const {MatrixServerError} = require("../../matrix/mreq")
|
||||||
|
const {_interact, _interactButton} = require("./invite")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @template T
|
||||||
|
* @param {AsyncIterable<T>} ai
|
||||||
|
* @returns {Promise<T[]>}
|
||||||
|
*/
|
||||||
|
async function fromAsync(ai) {
|
||||||
|
const result = []
|
||||||
|
for await (const value of ai) {
|
||||||
|
result.push(value)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
test("invite: checks for missing matrix ID", async t => {
|
||||||
|
const msgs = await fromAsync(_interact({
|
||||||
|
data: {
|
||||||
|
options: []
|
||||||
|
},
|
||||||
|
channel: discord.channels.get("0"),
|
||||||
|
guild_id: "112760669178241024"
|
||||||
|
}, {}))
|
||||||
|
t.equal(msgs[0].createInteractionResponse.data.content, "You have to say the Matrix ID of the person you want to invite. Matrix IDs look like this: `@username:example.org`")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("invite: checks for invalid matrix ID", async t => {
|
||||||
|
const msgs = await fromAsync(_interact({
|
||||||
|
data: {
|
||||||
|
options: [{
|
||||||
|
name: "user",
|
||||||
|
type: DiscordTypes.ApplicationCommandOptionType.String,
|
||||||
|
value: "@cadence"
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
channel: discord.channels.get("0"),
|
||||||
|
guild_id: "112760669178241024"
|
||||||
|
}, {}))
|
||||||
|
t.equal(msgs[0].createInteractionResponse.data.content, "You have to say the Matrix ID of the person you want to invite. Matrix IDs look like this: `@username:example.org`")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("invite: checks if guild exists", async t => { // it might not exist if the application was added with applications.commands scope and not bot scope
|
||||||
|
const msgs = await fromAsync(_interact({
|
||||||
|
data: {
|
||||||
|
options: [{
|
||||||
|
name: "user",
|
||||||
|
type: DiscordTypes.ApplicationCommandOptionType.String,
|
||||||
|
value: "@cadence:cadence.moe"
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
channel: discord.channels.get("0"),
|
||||||
|
guild_id: "0"
|
||||||
|
}, {}))
|
||||||
|
t.match(msgs[0].createInteractionResponse.data.content, /there is no bot presence in the server/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("invite: checks if channel exists or is autocreatable", async t => {
|
||||||
|
db.prepare("UPDATE guild_active SET autocreate = 0").run()
|
||||||
|
const msgs = await fromAsync(_interact({
|
||||||
|
data: {
|
||||||
|
options: [{
|
||||||
|
name: "user",
|
||||||
|
type: DiscordTypes.ApplicationCommandOptionType.String,
|
||||||
|
value: "@cadence:cadence.moe"
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
channel: discord.channels.get("498323546729086986"),
|
||||||
|
guild_id: "112760669178241024"
|
||||||
|
}, {}))
|
||||||
|
t.equal(msgs[0].createInteractionResponse.data.content, "This channel isn't bridged, so you can't invite Matrix users yet. Try turning on automatic room-creation or link a Matrix room in the website.")
|
||||||
|
db.prepare("UPDATE guild_active SET autocreate = 1").run()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("invite: checks if user is already invited to space", async t => {
|
||||||
|
let called = 0
|
||||||
|
const msgs = await fromAsync(_interact({
|
||||||
|
data: {
|
||||||
|
options: [{
|
||||||
|
name: "user",
|
||||||
|
type: DiscordTypes.ApplicationCommandOptionType.String,
|
||||||
|
value: "@cadence:cadence.moe"
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
channel: discord.channels.get("112760669178241024"),
|
||||||
|
guild_id: "112760669178241024"
|
||||||
|
}, {
|
||||||
|
api: {
|
||||||
|
getStateEvent: async (roomID, type, stateKey) => {
|
||||||
|
called++
|
||||||
|
t.equal(roomID, "!jjWAGMeQdNrVZSSfvz:cadence.moe") // space ID
|
||||||
|
t.equal(type, "m.room.member")
|
||||||
|
t.equal(stateKey, "@cadence:cadence.moe")
|
||||||
|
return {
|
||||||
|
displayname: "cadence",
|
||||||
|
membership: "invite"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.equal(msgs[1].editOriginalInteractionResponse.content, "`@cadence:cadence.moe` already has an invite, which they haven't accepted yet.")
|
||||||
|
t.equal(called, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("invite: invites if user is not in space", async t => {
|
||||||
|
let called = 0
|
||||||
|
const msgs = await fromAsync(_interact({
|
||||||
|
data: {
|
||||||
|
options: [{
|
||||||
|
name: "user",
|
||||||
|
type: DiscordTypes.ApplicationCommandOptionType.String,
|
||||||
|
value: "@cadence:cadence.moe"
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
channel: discord.channels.get("112760669178241024"),
|
||||||
|
guild_id: "112760669178241024"
|
||||||
|
}, {
|
||||||
|
api: {
|
||||||
|
getStateEvent: async (roomID, type, stateKey) => {
|
||||||
|
called++
|
||||||
|
t.equal(roomID, "!jjWAGMeQdNrVZSSfvz:cadence.moe") // space ID
|
||||||
|
t.equal(type, "m.room.member")
|
||||||
|
t.equal(stateKey, "@cadence:cadence.moe")
|
||||||
|
throw new MatrixServerError("State event doesn't exist or something")
|
||||||
|
},
|
||||||
|
inviteToRoom: async (roomID, mxid) => {
|
||||||
|
called++
|
||||||
|
t.equal(roomID, "!jjWAGMeQdNrVZSSfvz:cadence.moe") // space ID
|
||||||
|
t.equal(mxid, "@cadence:cadence.moe")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.equal(msgs[1].editOriginalInteractionResponse.content, "You invited `@cadence:cadence.moe` to the server.")
|
||||||
|
t.equal(called, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("invite: prompts to invite to room (if never joined)", async t => {
|
||||||
|
let called = 0
|
||||||
|
const msgs = await fromAsync(_interact({
|
||||||
|
data: {
|
||||||
|
options: [{
|
||||||
|
name: "user",
|
||||||
|
type: DiscordTypes.ApplicationCommandOptionType.String,
|
||||||
|
value: "@cadence:cadence.moe"
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
channel: discord.channels.get("112760669178241024"),
|
||||||
|
guild_id: "112760669178241024"
|
||||||
|
}, {
|
||||||
|
api: {
|
||||||
|
getStateEvent: async (roomID, type, stateKey) => {
|
||||||
|
called++
|
||||||
|
t.equal(type, "m.room.member")
|
||||||
|
t.equal(stateKey, "@cadence:cadence.moe")
|
||||||
|
if (roomID === "!jjWAGMeQdNrVZSSfvz:cadence.moe") { // space ID
|
||||||
|
return {
|
||||||
|
displayname: "cadence",
|
||||||
|
membership: "join"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new MatrixServerError("State event doesn't exist or something")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.equal(msgs[1].editOriginalInteractionResponse.content, "`@cadence:cadence.moe` is already in this server. Would you like to additionally invite them to this specific channel?")
|
||||||
|
t.equal(called, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("invite: prompts to invite to room (if left)", async t => {
|
||||||
|
let called = 0
|
||||||
|
const msgs = await fromAsync(_interact({
|
||||||
|
data: {
|
||||||
|
options: [{
|
||||||
|
name: "user",
|
||||||
|
type: DiscordTypes.ApplicationCommandOptionType.String,
|
||||||
|
value: "@cadence:cadence.moe"
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
channel: discord.channels.get("112760669178241024"),
|
||||||
|
guild_id: "112760669178241024"
|
||||||
|
}, {
|
||||||
|
api: {
|
||||||
|
getStateEvent: async (roomID, type, stateKey) => {
|
||||||
|
called++
|
||||||
|
t.equal(type, "m.room.member")
|
||||||
|
t.equal(stateKey, "@cadence:cadence.moe")
|
||||||
|
if (roomID === "!jjWAGMeQdNrVZSSfvz:cadence.moe") { // space ID
|
||||||
|
return {
|
||||||
|
displayname: "cadence",
|
||||||
|
membership: "join"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
displayname: "cadence",
|
||||||
|
membership: "leave"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.equal(msgs[1].editOriginalInteractionResponse.content, "`@cadence:cadence.moe` is already in this server. Would you like to additionally invite them to this specific channel?")
|
||||||
|
t.equal(called, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("invite button: invites to room when button clicked", async t => {
|
||||||
|
let called = 0
|
||||||
|
const msg = await _interactButton({
|
||||||
|
channel: discord.channels.get("112760669178241024"),
|
||||||
|
message: {
|
||||||
|
content: "`@cadence:cadence.moe` is already in this server. Would you like to additionally invite them to this specific channel?"
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
api: {
|
||||||
|
inviteToRoom: async (roomID, mxid) => {
|
||||||
|
called++
|
||||||
|
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") // room ID
|
||||||
|
t.equal(mxid, "@cadence:cadence.moe")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.equal(msg.data.content, "You invited `@cadence:cadence.moe` to the channel.")
|
||||||
|
t.equal(called, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("invite: no-op if in room and space", async t => {
|
||||||
|
let called = 0
|
||||||
|
const msgs = await fromAsync(_interact({
|
||||||
|
data: {
|
||||||
|
options: [{
|
||||||
|
name: "user",
|
||||||
|
type: DiscordTypes.ApplicationCommandOptionType.String,
|
||||||
|
value: "@cadence:cadence.moe"
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
channel: discord.channels.get("112760669178241024"),
|
||||||
|
guild_id: "112760669178241024"
|
||||||
|
}, {
|
||||||
|
api: {
|
||||||
|
getStateEvent: async (roomID, type, stateKey) => {
|
||||||
|
called++
|
||||||
|
t.equal(type, "m.room.member")
|
||||||
|
t.equal(stateKey, "@cadence:cadence.moe")
|
||||||
|
return {
|
||||||
|
displayname: "cadence",
|
||||||
|
membership: "join"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.equal(msgs[1].editOriginalInteractionResponse.content, "`@cadence:cadence.moe` is already in this server and this channel.")
|
||||||
|
t.equal(called, 2)
|
||||||
|
})
|
|
@ -1,51 +1,63 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const DiscordTypes = require("discord-api-types/v10")
|
const DiscordTypes = require("discord-api-types/v10")
|
||||||
const {discord, sync, db, select, from} = require("../../passthrough")
|
const {discord, sync, from} = require("../../passthrough")
|
||||||
|
|
||||||
/** @type {import("../../matrix/api")} */
|
/** @type {import("../../matrix/api")} */
|
||||||
const api = sync.require("../../matrix/api")
|
const api = sync.require("../../matrix/api")
|
||||||
|
|
||||||
/** @param {DiscordTypes.APIContextMenuGuildInteraction} interaction */
|
/**
|
||||||
/** @param {DiscordTypes.APIMessageApplicationCommandGuildInteraction} interaction */
|
* @param {DiscordTypes.APIMessageApplicationCommandGuildInteraction} interaction
|
||||||
async function interact({id, token, guild_id, channel, data}) {
|
* @param {{api: typeof api}} di
|
||||||
|
* @returns {Promise<DiscordTypes.APIInteractionResponse>}
|
||||||
|
*/
|
||||||
|
async function _interact({guild_id, data}, {api}) {
|
||||||
const message = from("event_message").join("message_channel", "message_id").join("channel_room", "channel_id")
|
const message = from("event_message").join("message_channel", "message_id").join("channel_room", "channel_id")
|
||||||
.select("name", "nick", "source", "room_id", "event_id").where({message_id: data.target_id}).get()
|
.select("name", "nick", "source", "channel_id", "room_id", "event_id").where({message_id: data.target_id, part: 0}).get()
|
||||||
|
|
||||||
if (!message) {
|
if (!message) {
|
||||||
return discord.snow.interaction.createInteractionResponse(id, token, {
|
return {
|
||||||
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
|
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
|
||||||
data: {
|
data: {
|
||||||
content: "This message hasn't been bridged to Matrix.",
|
content: "This message hasn't been bridged to Matrix.",
|
||||||
flags: DiscordTypes.MessageFlags.Ephemeral
|
flags: DiscordTypes.MessageFlags.Ephemeral
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const idInfo = `\n-# Room ID: \`${message.room_id}\`\n-# Event ID: \`${message.event_id}\``
|
const idInfo = `\n-# Room ID: \`${message.room_id}\`\n-# Event ID: \`${message.event_id}\``
|
||||||
|
const roomName = message.nick || message.name
|
||||||
|
|
||||||
if (message.source === 1) { // from Discord
|
if (message.source === 1) { // from Discord
|
||||||
const userID = data.resolved.messages[data.target_id].author.id
|
const userID = data.resolved.messages[data.target_id].author.id
|
||||||
return discord.snow.interaction.createInteractionResponse(id, token, {
|
return {
|
||||||
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
|
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
|
||||||
data: {
|
data: {
|
||||||
content: `Bridged <@${userID}> https://discord.com/channels/${guild_id}/${channel.id}/${data.target_id} on Discord to [${message.nick || message.name}](<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,
|
+ idInfo,
|
||||||
flags: DiscordTypes.MessageFlags.Ephemeral
|
flags: DiscordTypes.MessageFlags.Ephemeral
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// from Matrix
|
// from Matrix
|
||||||
const event = await api.getEvent(message.room_id, message.event_id)
|
const event = await api.getEvent(message.room_id, message.event_id)
|
||||||
return discord.snow.interaction.createInteractionResponse(id, token, {
|
return {
|
||||||
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
|
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
|
||||||
data: {
|
data: {
|
||||||
content: `Bridged [${event.sender}](<https://matrix.to/#/${event.sender}>)'s message in [${message.nick || message.name}](<https://matrix.to/#/${message.room_id}/${message.event_id}>) on Matrix to https://discord.com/channels/${guild_id}/${channel.id}/${data.target_id} on Discord.`
|
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,
|
+ idInfo,
|
||||||
flags: DiscordTypes.MessageFlags.Ephemeral
|
flags: DiscordTypes.MessageFlags.Ephemeral
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* c8 ignore start */
|
||||||
|
|
||||||
|
/** @param {DiscordTypes.APIMessageApplicationCommandGuildInteraction} interaction */
|
||||||
|
async function interact(interaction) {
|
||||||
|
await discord.snow.interaction.createInteractionResponse(interaction.id, interaction.token, await _interact(interaction, {api}))
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports.interact = interact
|
module.exports.interact = interact
|
||||||
|
module.exports._interact = _interact
|
||||||
|
|
73
src/discord/interactions/matrix-info.test.js
Normal file
73
src/discord/interactions/matrix-info.test.js
Normal file
|
@ -0,0 +1,73 @@
|
||||||
|
const {test} = require("supertape")
|
||||||
|
const data = require("../../../test/data")
|
||||||
|
const {_interact} = require("./matrix-info")
|
||||||
|
|
||||||
|
test("matrix info: checks if message is bridged", async t => {
|
||||||
|
const msg = await _interact({
|
||||||
|
data: {
|
||||||
|
target_id: "0"
|
||||||
|
},
|
||||||
|
guild_id: "112760669178241024"
|
||||||
|
}, {})
|
||||||
|
t.equal(msg.data.content, "This message hasn't been bridged to Matrix.")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("matrix info: shows info for discord source message", async t => {
|
||||||
|
const msg = await _interact({
|
||||||
|
data: {
|
||||||
|
target_id: "1141619794500649020",
|
||||||
|
resolved: {
|
||||||
|
messages: {
|
||||||
|
"1141619794500649020": data.message_update.edit_by_webhook
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
guild_id: "497159726455455754"
|
||||||
|
}, {})
|
||||||
|
t.equal(
|
||||||
|
msg.data.content,
|
||||||
|
"Bridged <@700285844094845050> https://discord.com/channels/497159726455455754/497161350934560778/1141619794500649020 on Discord to [amanda-spam](<https://matrix.to/#/!CzvdIdUQXgUjDVKxeU:cadence.moe/$zXSlyI78DQqQwwfPUSzZ1b-nXzbUrCDljJgnGDdoI10>) on Matrix."
|
||||||
|
+ "\n-# Room ID: `!CzvdIdUQXgUjDVKxeU:cadence.moe`"
|
||||||
|
+ "\n-# Event ID: `$zXSlyI78DQqQwwfPUSzZ1b-nXzbUrCDljJgnGDdoI10`"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("matrix info: shows info for matrix source message", async t => {
|
||||||
|
let called = 0
|
||||||
|
const msg = await _interact({
|
||||||
|
data: {
|
||||||
|
target_id: "1128118177155526666",
|
||||||
|
resolved: {
|
||||||
|
messages: {
|
||||||
|
"1141501302736695316": data.message.simple_reply_to_matrix_user
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
guild_id: "112760669178241024"
|
||||||
|
}, {
|
||||||
|
api: {
|
||||||
|
async getEvent(roomID, eventID) {
|
||||||
|
called++
|
||||||
|
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
|
||||||
|
t.equal(eventID, "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4")
|
||||||
|
return {
|
||||||
|
event_id: eventID,
|
||||||
|
room_id: roomID,
|
||||||
|
type: "m.room.message",
|
||||||
|
content: {
|
||||||
|
msgtype: "m.text",
|
||||||
|
body: "so can you reply to my webhook uwu"
|
||||||
|
},
|
||||||
|
sender: "@cadence:cadence.moe"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.equal(
|
||||||
|
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)
|
||||||
|
})
|
|
@ -2,34 +2,41 @@
|
||||||
|
|
||||||
const DiscordTypes = require("discord-api-types/v10")
|
const DiscordTypes = require("discord-api-types/v10")
|
||||||
const Ty = require("../../types")
|
const Ty = require("../../types")
|
||||||
const {discord, sync, db, select, from} = require("../../passthrough")
|
const {discord, sync, select, from} = require("../../passthrough")
|
||||||
const assert = require("assert/strict")
|
const assert = require("assert/strict")
|
||||||
|
const {id: botID} = require("../../../addbot")
|
||||||
|
const {InteractionMethods} = require("snowtransfer")
|
||||||
|
|
||||||
/** @type {import("../../matrix/api")} */
|
/** @type {import("../../matrix/api")} */
|
||||||
const api = sync.require("../../matrix/api")
|
const api = sync.require("../../matrix/api")
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {DiscordTypes.APIContextMenuGuildInteraction} interaction
|
* @param {DiscordTypes.APIContextMenuGuildInteraction} interaction
|
||||||
* @returns {Promise<DiscordTypes.APIInteractionResponse>}
|
* @param {{api: typeof api}} di
|
||||||
|
* @returns {AsyncGenerator<{[k in keyof InteractionMethods]?: Parameters<InteractionMethods[k]>[2]}>}
|
||||||
*/
|
*/
|
||||||
async function _interact({data, channel, guild_id}) {
|
async function* _interact({data, guild_id}, {api}) {
|
||||||
const row = select("event_message", ["event_id", "source"], {message_id: data.target_id}).get()
|
// Get message info
|
||||||
assert(row)
|
const row = from("event_message")
|
||||||
|
.join("message_channel", "message_id")
|
||||||
|
.select("event_id", "source", "channel_id")
|
||||||
|
.where({message_id: data.target_id})
|
||||||
|
.get()
|
||||||
|
|
||||||
// Can't operate on Discord users
|
// Can't operate on Discord users
|
||||||
if (row.source === 1) { // discord
|
if (!row || row.source === 1) { // not bridged or sent by a discord user
|
||||||
return {
|
return yield {createInteractionResponse: {
|
||||||
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
|
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
|
||||||
data: {
|
data: {
|
||||||
content: `This command is only meaningful for Matrix users.`,
|
content: `The permissions command can only be used on Matrix users.`,
|
||||||
flags: DiscordTypes.MessageFlags.Ephemeral
|
flags: DiscordTypes.MessageFlags.Ephemeral
|
||||||
}
|
}
|
||||||
}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the message sender, the person that will be inspected/edited
|
// Get the message sender, the person that will be inspected/edited
|
||||||
const eventID = row.event_id
|
const eventID = row.event_id
|
||||||
const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get()
|
const roomID = select("channel_room", "room_id", {channel_id: row.channel_id}).pluck().get()
|
||||||
assert(roomID)
|
assert(roomID)
|
||||||
const event = await api.getEvent(roomID, eventID)
|
const event = await api.getEvent(roomID, eventID)
|
||||||
const sender = event.sender
|
const sender = event.sender
|
||||||
|
@ -45,16 +52,16 @@ async function _interact({data, channel, guild_id}) {
|
||||||
|
|
||||||
// Administrators equal to the bot cannot be demoted
|
// Administrators equal to the bot cannot be demoted
|
||||||
if (userPower >= 100) {
|
if (userPower >= 100) {
|
||||||
return {
|
return yield {createInteractionResponse: {
|
||||||
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
|
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
|
||||||
data: {
|
data: {
|
||||||
content: `\`${sender}\` has administrator permissions. This cannot be edited.`,
|
content: `\`${sender}\` has administrator permissions. This cannot be edited.`,
|
||||||
flags: DiscordTypes.MessageFlags.Ephemeral
|
flags: DiscordTypes.MessageFlags.Ephemeral
|
||||||
}
|
}
|
||||||
}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
yield {createInteractionResponse: {
|
||||||
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
|
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
|
||||||
data: {
|
data: {
|
||||||
content: `Showing permissions for \`${sender}\`. Click to edit.`,
|
content: `Showing permissions for \`${sender}\`. Click to edit.`,
|
||||||
|
@ -82,13 +89,15 @@ async function _interact({data, channel, guild_id}) {
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {DiscordTypes.APIMessageComponentSelectMenuInteraction} interaction
|
* @param {DiscordTypes.APIMessageComponentSelectMenuInteraction} interaction
|
||||||
|
* @param {{api: typeof api}} di
|
||||||
|
* @returns {AsyncGenerator<{[k in keyof InteractionMethods]?: Parameters<InteractionMethods[k]>[2]}>}
|
||||||
*/
|
*/
|
||||||
async function interactEdit({data, id, token, guild_id, message}) {
|
async function* _interactEdit({data, guild_id, message}, {api}) {
|
||||||
// Get the person that will be inspected/edited
|
// Get the person that will be inspected/edited
|
||||||
const mxid = message.content.match(/`(@(?:[^:]+):(?:[a-z0-9:-]+\.[a-z0-9.:-]+))`/)?.[1]
|
const mxid = message.content.match(/`(@(?:[^:]+):(?:[a-z0-9:-]+\.[a-z0-9.:-]+))`/)?.[1]
|
||||||
assert(mxid)
|
assert(mxid)
|
||||||
|
@ -96,13 +105,13 @@ async function interactEdit({data, id, token, guild_id, message}) {
|
||||||
const permission = data.values[0]
|
const permission = data.values[0]
|
||||||
const power = permission === "moderator" ? 50 : 0
|
const power = permission === "moderator" ? 50 : 0
|
||||||
|
|
||||||
await discord.snow.interaction.createInteractionResponse(id, token, {
|
yield {createInteractionResponse: {
|
||||||
type: DiscordTypes.InteractionResponseType.UpdateMessage,
|
type: DiscordTypes.InteractionResponseType.UpdateMessage,
|
||||||
data: {
|
data: {
|
||||||
content: `Updating \`${mxid}\` to **${permission}**, please wait...`,
|
content: `Updating \`${mxid}\` to **${permission}**, please wait...`,
|
||||||
components: []
|
components: []
|
||||||
}
|
}
|
||||||
})
|
}}
|
||||||
|
|
||||||
// Get the space, where the power levels will be inspected/edited
|
// Get the space, where the power levels will be inspected/edited
|
||||||
const spaceID = select("guild_space", "space_id", {guild_id}).pluck().get()
|
const spaceID = select("guild_space", "space_id", {guild_id}).pluck().get()
|
||||||
|
@ -112,17 +121,40 @@ async function interactEdit({data, id, token, guild_id, message}) {
|
||||||
await api.setUserPowerCascade(spaceID, mxid, power)
|
await api.setUserPowerCascade(spaceID, mxid, power)
|
||||||
|
|
||||||
// ACK
|
// ACK
|
||||||
await discord.snow.interaction.editOriginalInteractionResponse(discord.application.id, token, {
|
yield {editOriginalInteractionResponse: {
|
||||||
content: `Updated \`${mxid}\` to **${permission}**.`,
|
content: `Updated \`${mxid}\` to **${permission}**.`,
|
||||||
components: []
|
components: []
|
||||||
})
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* c8 ignore start */
|
||||||
|
|
||||||
/** @param {DiscordTypes.APIContextMenuGuildInteraction} interaction */
|
/** @param {DiscordTypes.APIContextMenuGuildInteraction} interaction */
|
||||||
async function interact(interaction) {
|
async function interact(interaction) {
|
||||||
await discord.snow.interaction.createInteractionResponse(interaction.id, interaction.token, await _interact(interaction))
|
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)
|
||||||
|
} else if (response.editOriginalInteractionResponse) {
|
||||||
|
await discord.snow.interaction.editOriginalInteractionResponse(botID, interaction.token, response.editOriginalInteractionResponse)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {DiscordTypes.APIMessageComponentSelectMenuInteraction} interaction */
|
||||||
|
async function interactEdit(interaction) {
|
||||||
|
for await (const response of _interactEdit(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)
|
||||||
|
} else if (response.editOriginalInteractionResponse) {
|
||||||
|
await discord.snow.interaction.editOriginalInteractionResponse(botID, interaction.token, response.editOriginalInteractionResponse)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports.interact = interact
|
module.exports.interact = interact
|
||||||
module.exports.interactEdit = interactEdit
|
module.exports.interactEdit = interactEdit
|
||||||
module.exports._interact = _interact
|
module.exports._interact = _interact
|
||||||
|
module.exports._interactEdit = _interactEdit
|
||||||
|
|
199
src/discord/interactions/permissions.test.js
Normal file
199
src/discord/interactions/permissions.test.js
Normal file
|
@ -0,0 +1,199 @@
|
||||||
|
const {test} = require("supertape")
|
||||||
|
const DiscordTypes = require("discord-api-types/v10")
|
||||||
|
const {select, db} = require("../../passthrough")
|
||||||
|
const {_interact, _interactEdit} = require("./permissions")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @template T
|
||||||
|
* @param {AsyncIterable<T>} ai
|
||||||
|
* @returns {Promise<T[]>}
|
||||||
|
*/
|
||||||
|
async function fromAsync(ai) {
|
||||||
|
const result = []
|
||||||
|
for await (const value of ai) {
|
||||||
|
result.push(value)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
test("permissions: checks if message is bridged", async t => {
|
||||||
|
const msgs = await fromAsync(_interact({
|
||||||
|
data: {
|
||||||
|
target_id: "0"
|
||||||
|
},
|
||||||
|
guild_id: "0"
|
||||||
|
}, {}))
|
||||||
|
t.equal(msgs.length, 1)
|
||||||
|
t.equal(msgs[0].createInteractionResponse.data.content, "The permissions command can only be used on Matrix users.")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("permissions: checks if message is sent by a matrix user", async t => {
|
||||||
|
const msgs = await fromAsync(_interact({
|
||||||
|
data: {
|
||||||
|
target_id: "1126786462646550579"
|
||||||
|
},
|
||||||
|
guild_id: "112760669178241024"
|
||||||
|
}, {}))
|
||||||
|
t.equal(msgs.length, 1)
|
||||||
|
t.equal(msgs[0].createInteractionResponse.data.content, "The permissions command can only be used on Matrix users.")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("permissions: reports permissions of selected matrix user (implicit default)", async t => {
|
||||||
|
let called = 0
|
||||||
|
const msgs = await fromAsync(_interact({
|
||||||
|
data: {
|
||||||
|
target_id: "1128118177155526666"
|
||||||
|
},
|
||||||
|
guild_id: "112760669178241024"
|
||||||
|
}, {
|
||||||
|
api: {
|
||||||
|
async getEvent(roomID, eventID) {
|
||||||
|
called++
|
||||||
|
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") // room ID
|
||||||
|
t.equal(eventID, "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4")
|
||||||
|
return {
|
||||||
|
sender: "@cadence:cadence.moe"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async getStateEvent(roomID, type, key) {
|
||||||
|
called++
|
||||||
|
t.equal(roomID, "!jjWAGMeQdNrVZSSfvz: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, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("permissions: reports permissions of selected matrix user (moderator)", async t => {
|
||||||
|
let called = 0
|
||||||
|
const msgs = await fromAsync(_interact({
|
||||||
|
data: {
|
||||||
|
target_id: "1128118177155526666"
|
||||||
|
},
|
||||||
|
guild_id: "112760669178241024"
|
||||||
|
}, {
|
||||||
|
api: {
|
||||||
|
async getEvent(roomID, eventID) {
|
||||||
|
called++
|
||||||
|
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") // room ID
|
||||||
|
t.equal(eventID, "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4")
|
||||||
|
return {
|
||||||
|
sender: "@cadence:cadence.moe"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async getStateEvent(roomID, type, key) {
|
||||||
|
called++
|
||||||
|
t.equal(roomID, "!jjWAGMeQdNrVZSSfvz: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, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("permissions: reports permissions of selected matrix user (admin)", async t => {
|
||||||
|
let called = 0
|
||||||
|
const msgs = await fromAsync(_interact({
|
||||||
|
data: {
|
||||||
|
target_id: "1128118177155526666"
|
||||||
|
},
|
||||||
|
guild_id: "112760669178241024"
|
||||||
|
}, {
|
||||||
|
api: {
|
||||||
|
async getEvent(roomID, eventID) {
|
||||||
|
called++
|
||||||
|
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") // room ID
|
||||||
|
t.equal(eventID, "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4")
|
||||||
|
return {
|
||||||
|
sender: "@cadence:cadence.moe"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async getStateEvent(roomID, type, key) {
|
||||||
|
called++
|
||||||
|
t.equal(roomID, "!jjWAGMeQdNrVZSSfvz:cadence.moe") // space ID
|
||||||
|
t.equal(type, "m.room.power_levels")
|
||||||
|
t.equal(key, "")
|
||||||
|
return {
|
||||||
|
users: {
|
||||||
|
"@cadence:cadence.moe": 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
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, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("permissions: can update user to moderator", async t => {
|
||||||
|
let called = 0
|
||||||
|
const msgs = await fromAsync(_interactEdit({
|
||||||
|
data: {
|
||||||
|
target_id: "1128118177155526666",
|
||||||
|
values: ["moderator"]
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
content: "Showing permissions for `@cadence:cadence.moe`. Click to edit."
|
||||||
|
},
|
||||||
|
guild_id: "112760669178241024"
|
||||||
|
}, {
|
||||||
|
api: {
|
||||||
|
async setUserPowerCascade(roomID, mxid, power) {
|
||||||
|
called++
|
||||||
|
t.equal(roomID, "!jjWAGMeQdNrVZSSfvz: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.equal(called, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("permissions: can update user to default", async t => {
|
||||||
|
let called = 0
|
||||||
|
const msgs = await fromAsync(_interactEdit({
|
||||||
|
data: {
|
||||||
|
target_id: "1128118177155526666",
|
||||||
|
values: ["default"]
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
content: "Showing permissions for `@cadence:cadence.moe`. Click to edit."
|
||||||
|
},
|
||||||
|
guild_id: "112760669178241024"
|
||||||
|
}, {
|
||||||
|
api: {
|
||||||
|
async setUserPowerCascade(roomID, mxid, power) {
|
||||||
|
called++
|
||||||
|
t.equal(roomID, "!jjWAGMeQdNrVZSSfvz: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.equal(called, 1)
|
||||||
|
})
|
|
@ -3,32 +3,37 @@
|
||||||
const DiscordTypes = require("discord-api-types/v10")
|
const DiscordTypes = require("discord-api-types/v10")
|
||||||
const {discord, sync, db, select} = require("../../passthrough")
|
const {discord, sync, db, select} = require("../../passthrough")
|
||||||
const {id: botID} = require("../../../addbot")
|
const {id: botID} = require("../../../addbot")
|
||||||
|
const {InteractionMethods} = require("snowtransfer")
|
||||||
|
|
||||||
/** @type {import("../../d2m/actions/create-space")} */
|
/** @type {import("../../d2m/actions/create-space")} */
|
||||||
const createSpace = sync.require("../../d2m/actions/create-space")
|
const createSpace = sync.require("../../d2m/actions/create-space")
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {DiscordTypes.APIChatInputApplicationCommandGuildInteraction} interaction
|
* @param {DiscordTypes.APIChatInputApplicationCommandGuildInteraction} interaction
|
||||||
|
* @param {{createSpace: typeof createSpace}} di
|
||||||
|
* @returns {AsyncGenerator<{[k in keyof InteractionMethods]?: Parameters<InteractionMethods[k]>[2]}>}
|
||||||
*/
|
*/
|
||||||
async function interact({id, token, data, guild_id}) {
|
async function* _interact({data, guild_id}, {createSpace}) {
|
||||||
// Check guild is bridged
|
// Check guild is bridged
|
||||||
const current = select("guild_space", "privacy_level", {guild_id}).pluck().get()
|
const current = select("guild_space", "privacy_level", {guild_id}).pluck().get()
|
||||||
if (current == null) return {
|
if (current == null) {
|
||||||
|
return yield {createInteractionResponse: {
|
||||||
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
|
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
|
||||||
data: {
|
data: {
|
||||||
content: "This server isn't bridged to Matrix, so you can't set the Matrix privacy level.",
|
content: "This server isn't bridged to Matrix, so you can't set the Matrix privacy level.",
|
||||||
flags: DiscordTypes.MessageFlags.Ephemeral
|
flags: DiscordTypes.MessageFlags.Ephemeral
|
||||||
}
|
}
|
||||||
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get input level
|
// Get input level
|
||||||
/** @type {DiscordTypes.APIApplicationCommandInteractionDataStringOption[] | undefined} */ // @ts-ignore
|
/** @type {DiscordTypes.APIApplicationCommandInteractionDataStringOption[] | undefined} */ // @ts-ignore
|
||||||
const options = data.options
|
const options = data.options
|
||||||
const input = options?.[0].value || ""
|
const input = options?.[0]?.value || ""
|
||||||
const levels = ["invite", "link", "directory"]
|
const levels = ["invite", "link", "directory"]
|
||||||
const level = levels.findIndex(x => input === x)
|
const level = levels.findIndex(x => input === x)
|
||||||
if (level === -1) {
|
if (level === -1) {
|
||||||
return discord.snow.interaction.createInteractionResponse(id, token, {
|
return yield {createInteractionResponse: {
|
||||||
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
|
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
|
||||||
data: {
|
data: {
|
||||||
content: "**Usage: `/privacy <level>`**. This will set who can join the space on Matrix-side. There are three levels:"
|
content: "**Usage: `/privacy <level>`**. This will set who can join the space on Matrix-side. There are three levels:"
|
||||||
|
@ -38,22 +43,37 @@ async function interact({id, token, data, guild_id}) {
|
||||||
+ `\n**Current privacy level: \`${levels[current]}\`**`,
|
+ `\n**Current privacy level: \`${levels[current]}\`**`,
|
||||||
flags: DiscordTypes.MessageFlags.Ephemeral
|
flags: DiscordTypes.MessageFlags.Ephemeral
|
||||||
}
|
}
|
||||||
})
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
await discord.snow.interaction.createInteractionResponse(id, token, {
|
yield {createInteractionResponse: {
|
||||||
type: DiscordTypes.InteractionResponseType.DeferredChannelMessageWithSource,
|
type: DiscordTypes.InteractionResponseType.DeferredChannelMessageWithSource,
|
||||||
data: {
|
data: {
|
||||||
flags: DiscordTypes.MessageFlags.Ephemeral
|
flags: DiscordTypes.MessageFlags.Ephemeral
|
||||||
}
|
}
|
||||||
})
|
}}
|
||||||
|
|
||||||
db.prepare("UPDATE guild_space SET privacy_level = ? WHERE guild_id = ?").run(level, guild_id)
|
db.prepare("UPDATE guild_space SET privacy_level = ? WHERE guild_id = ?").run(level, guild_id)
|
||||||
await createSpace.syncSpaceFully(guild_id) // this is inefficient but OK to call infrequently on user request
|
await createSpace.syncSpaceFully(guild_id) // this is inefficient but OK to call infrequently on user request
|
||||||
|
|
||||||
await discord.snow.interaction.editOriginalInteractionResponse(botID, token, {
|
yield {editOriginalInteractionResponse: {
|
||||||
content: `Privacy level updated to \`${levels[level]}\`.`
|
content: `Privacy level updated to \`${levels[level]}\`.`
|
||||||
})
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* c8 ignore start */
|
||||||
|
|
||||||
|
/** @param {DiscordTypes.APIChatInputApplicationCommandGuildInteraction} interaction */
|
||||||
|
async function interact(interaction) {
|
||||||
|
for await (const response of _interact(interaction, {createSpace})) {
|
||||||
|
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)
|
||||||
|
} else if (response.editOriginalInteractionResponse) {
|
||||||
|
await discord.snow.interaction.editOriginalInteractionResponse(botID, interaction.token, response.editOriginalInteractionResponse)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports.interact = interact
|
module.exports.interact = interact
|
||||||
|
module.exports._interact = _interact
|
||||||
|
|
86
src/discord/interactions/privacy.test.js
Normal file
86
src/discord/interactions/privacy.test.js
Normal file
|
@ -0,0 +1,86 @@
|
||||||
|
const {test} = require("supertape")
|
||||||
|
const DiscordTypes = require("discord-api-types/v10")
|
||||||
|
const {select, db} = require("../../passthrough")
|
||||||
|
const {_interact} = require("./privacy")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @template T
|
||||||
|
* @param {AsyncIterable<T>} ai
|
||||||
|
* @returns {Promise<T[]>}
|
||||||
|
*/
|
||||||
|
async function fromAsync(ai) {
|
||||||
|
const result = []
|
||||||
|
for await (const value of ai) {
|
||||||
|
result.push(value)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
test("privacy: checks if guild is bridged", async t => {
|
||||||
|
const msgs = await fromAsync(_interact({
|
||||||
|
data: {
|
||||||
|
options: []
|
||||||
|
},
|
||||||
|
guild_id: "0"
|
||||||
|
}, {}))
|
||||||
|
t.equal(msgs.length, 1)
|
||||||
|
t.equal(msgs[0].createInteractionResponse.data.content, "This server isn't bridged to Matrix, so you can't set the Matrix privacy level.")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("privacy: reports usage if there is no parameter", async t => {
|
||||||
|
const msgs = await fromAsync(_interact({
|
||||||
|
data: {
|
||||||
|
options: []
|
||||||
|
},
|
||||||
|
guild_id: "112760669178241024"
|
||||||
|
}, {}))
|
||||||
|
t.equal(msgs.length, 1)
|
||||||
|
t.match(msgs[0].createInteractionResponse.data.content, /Usage: `\/privacy/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("privacy: reports usage for invalid parameter", async t => {
|
||||||
|
const msgs = await fromAsync(_interact({
|
||||||
|
data: {
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
name: "level",
|
||||||
|
type: DiscordTypes.ApplicationCommandOptionType.String,
|
||||||
|
value: "info"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
guild_id: "112760669178241024"
|
||||||
|
}, {}))
|
||||||
|
t.equal(msgs.length, 1)
|
||||||
|
t.match(msgs[0].createInteractionResponse.data.content, /Usage: `\/privacy/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("privacy: updates setting and calls syncSpace for valid parameter", async t => {
|
||||||
|
let called = 0
|
||||||
|
const msgs = await fromAsync(_interact({
|
||||||
|
data: {
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
name: "level",
|
||||||
|
type: DiscordTypes.ApplicationCommandOptionType.String,
|
||||||
|
value: "directory"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
guild_id: "112760669178241024"
|
||||||
|
}, {
|
||||||
|
createSpace: {
|
||||||
|
async syncSpaceFully(guildID) {
|
||||||
|
called++
|
||||||
|
t.equal(guildID, "112760669178241024")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.equal(msgs.length, 2)
|
||||||
|
t.equal(msgs[0].createInteractionResponse.type, DiscordTypes.InteractionResponseType.DeferredChannelMessageWithSource)
|
||||||
|
t.equal(msgs[1].editOriginalInteractionResponse.content, "Privacy level updated to `directory`.")
|
||||||
|
t.equal(called, 1)
|
||||||
|
t.equal(select("guild_space", "privacy_level", {guild_id: "112760669178241024"}).pluck().get(), 2)
|
||||||
|
// Undo database changes
|
||||||
|
db.prepare("UPDATE guild_space SET privacy_level = 0 WHERE guild_id = ?").run("112760669178241024")
|
||||||
|
})
|
|
@ -1,26 +1,29 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const DiscordTypes = require("discord-api-types/v10")
|
const DiscordTypes = require("discord-api-types/v10")
|
||||||
const {discord, sync, db, select, from} = require("../../passthrough")
|
const {discord, sync, select, from} = require("../../passthrough")
|
||||||
|
|
||||||
/** @type {import("../../matrix/api")} */
|
/** @type {import("../../matrix/api")} */
|
||||||
const api = sync.require("../../matrix/api")
|
const api = sync.require("../../matrix/api")
|
||||||
/** @type {import("../../m2d/converters/utils")} */
|
/** @type {import("../../m2d/converters/utils")} */
|
||||||
const utils = sync.require("../../m2d/converters/utils")
|
const utils = sync.require("../../m2d/converters/utils")
|
||||||
|
|
||||||
/** @param {DiscordTypes.APIContextMenuGuildInteraction} interaction */
|
/**
|
||||||
/** @param {DiscordTypes.APIMessageApplicationCommandGuildInteraction} interaction */
|
* @param {DiscordTypes.APIMessageApplicationCommandGuildInteraction} interaction
|
||||||
async function interact({id, token, data}) {
|
* @param {{api: typeof api}} di
|
||||||
|
* @returns {Promise<DiscordTypes.APIInteractionResponse>}
|
||||||
|
*/
|
||||||
|
async function _interact({data}, {api}) {
|
||||||
const row = from("event_message").join("message_channel", "message_id").join("channel_room", "channel_id")
|
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()
|
.select("event_id", "room_id").where({message_id: data.target_id}).get()
|
||||||
if (!row) {
|
if (!row) {
|
||||||
return discord.snow.interaction.createInteractionResponse(id, token, {
|
return {
|
||||||
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
|
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
|
||||||
data: {
|
data: {
|
||||||
content: "This message hasn't been bridged to Matrix.",
|
content: "This message hasn't been bridged to Matrix.",
|
||||||
flags: DiscordTypes.MessageFlags.Ephemeral
|
flags: DiscordTypes.MessageFlags.Ephemeral
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const reactions = await api.getFullRelations(row.room_id, row.event_id, "m.annotation")
|
const reactions = await api.getFullRelations(row.room_id, row.event_id, "m.annotation")
|
||||||
|
@ -37,22 +40,30 @@ async function interact({id, token, data}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (inverted.size === 0) {
|
if (inverted.size === 0) {
|
||||||
return discord.snow.interaction.createInteractionResponse(id, token, {
|
return {
|
||||||
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
|
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
|
||||||
data: {
|
data: {
|
||||||
content: "Nobody from Matrix reacted to this message.",
|
content: "Nobody from Matrix reacted to this message.",
|
||||||
flags: DiscordTypes.MessageFlags.Ephemeral
|
flags: DiscordTypes.MessageFlags.Ephemeral
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return discord.snow.interaction.createInteractionResponse(id, token, {
|
return {
|
||||||
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
|
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
|
||||||
data: {
|
data: {
|
||||||
content: [...inverted.entries()].map(([key, value]) => `${key} ⮞ ${value.join(" ⬩ ")}`).join("\n"),
|
content: [...inverted.entries()].map(([key, value]) => `${key} ⮞ ${value.join(" ⬩ ")}`).join("\n"),
|
||||||
flags: DiscordTypes.MessageFlags.Ephemeral
|
flags: DiscordTypes.MessageFlags.Ephemeral
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* c8 ignore start */
|
||||||
|
|
||||||
|
/** @param {DiscordTypes.APIMessageApplicationCommandGuildInteraction} interaction */
|
||||||
|
async function interact(interaction) {
|
||||||
|
await discord.snow.interaction.createInteractionResponse(interaction.id, interaction.token, await _interact(interaction, {api}))
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports.interact = interact
|
module.exports.interact = interact
|
||||||
|
module.exports._interact = _interact
|
||||||
|
|
83
src/discord/interactions/reactions.test.js
Normal file
83
src/discord/interactions/reactions.test.js
Normal file
|
@ -0,0 +1,83 @@
|
||||||
|
const {test} = require("supertape")
|
||||||
|
const {_interact} = require("./reactions")
|
||||||
|
|
||||||
|
test("reactions: checks if message is bridged", async t => {
|
||||||
|
const msg = await _interact({
|
||||||
|
data: {
|
||||||
|
target_id: "0"
|
||||||
|
}
|
||||||
|
}, {})
|
||||||
|
t.equal(msg.data.content, "This message hasn't been bridged to Matrix.")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("reactions: different response if nobody reacted", async t => {
|
||||||
|
const msg = await _interact({
|
||||||
|
data: {
|
||||||
|
target_id: "1126786462646550579"
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
api: {
|
||||||
|
async getFullRelations(roomID, eventID) {
|
||||||
|
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
|
||||||
|
t.equal(eventID, "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg")
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.equal(msg.data.content, "Nobody from Matrix reacted to this message.")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("reactions: shows reactions if there are some, ignoring discord users", async t => {
|
||||||
|
let called = 1
|
||||||
|
const msg = await _interact({
|
||||||
|
data: {
|
||||||
|
target_id: "1126786462646550579"
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
api: {
|
||||||
|
async getFullRelations(roomID, eventID) {
|
||||||
|
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
|
||||||
|
t.equal(eventID, "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg")
|
||||||
|
return [{
|
||||||
|
sender: "@cadence:cadence.moe",
|
||||||
|
content: {
|
||||||
|
"m.relates_to": {
|
||||||
|
key: "🐈",
|
||||||
|
rel_type: "m.annotation"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
sender: "@rnl:cadence.moe",
|
||||||
|
content: {
|
||||||
|
"m.relates_to": {
|
||||||
|
key: "🐈",
|
||||||
|
rel_type: "m.annotation"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
sender: "@cadence:cadence.moe",
|
||||||
|
content: {
|
||||||
|
"m.relates_to": {
|
||||||
|
key: "🐈⬛",
|
||||||
|
rel_type: "m.annotation"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
sender: "@_ooye_rnl:cadence.moe",
|
||||||
|
content: {
|
||||||
|
"m.relates_to": {
|
||||||
|
key: "🐈",
|
||||||
|
rel_type: "m.annotation"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.equal(
|
||||||
|
msg.data.content,
|
||||||
|
"🐈 ⮞ cadence [they] ⬩ @rnl:cadence.moe"
|
||||||
|
+ "\n🐈⬛ ⮞ cadence [they]"
|
||||||
|
)
|
||||||
|
t.equal(called, 1)
|
||||||
|
})
|
|
@ -7,7 +7,6 @@ const {id} = require("../../addbot")
|
||||||
const matrixInfo = sync.require("./interactions/matrix-info.js")
|
const matrixInfo = sync.require("./interactions/matrix-info.js")
|
||||||
const invite = sync.require("./interactions/invite.js")
|
const invite = sync.require("./interactions/invite.js")
|
||||||
const permissions = sync.require("./interactions/permissions.js")
|
const permissions = sync.require("./interactions/permissions.js")
|
||||||
const bridge = sync.require("./interactions/bridge.js")
|
|
||||||
const reactions = sync.require("./interactions/reactions.js")
|
const reactions = sync.require("./interactions/reactions.js")
|
||||||
const privacy = sync.require("./interactions/privacy.js")
|
const privacy = sync.require("./interactions/privacy.js")
|
||||||
|
|
||||||
|
@ -39,20 +38,6 @@ discord.snow.interaction.bulkOverwriteApplicationCommands(id, [{
|
||||||
name: "user"
|
name: "user"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}, {
|
|
||||||
name: "bridge",
|
|
||||||
contexts: [DiscordTypes.InteractionContextType.Guild],
|
|
||||||
type: DiscordTypes.ApplicationCommandType.ChatInput,
|
|
||||||
description: "Start bridging this channel to a Matrix room",
|
|
||||||
default_member_permissions: String(DiscordTypes.PermissionFlagsBits.ManageChannels),
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
type: DiscordTypes.ApplicationCommandOptionType.String,
|
|
||||||
description: "Destination room to bridge to",
|
|
||||||
name: "room",
|
|
||||||
autocomplete: true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}, {
|
}, {
|
||||||
name: "privacy",
|
name: "privacy",
|
||||||
contexts: [DiscordTypes.InteractionContextType.Guild],
|
contexts: [DiscordTypes.InteractionContextType.Guild],
|
||||||
|
@ -94,8 +79,6 @@ async function dispatchInteraction(interaction) {
|
||||||
await permissions.interact(interaction)
|
await permissions.interact(interaction)
|
||||||
} else if (interactionId === "permissions_edit") {
|
} else if (interactionId === "permissions_edit") {
|
||||||
await permissions.interactEdit(interaction)
|
await permissions.interactEdit(interaction)
|
||||||
} else if (interactionId === "bridge") {
|
|
||||||
await bridge.interact(interaction)
|
|
||||||
} else if (interactionId === "Reactions") {
|
} else if (interactionId === "Reactions") {
|
||||||
await reactions.interact(interaction)
|
await reactions.interact(interaction)
|
||||||
} else if (interactionId === "privacy") {
|
} else if (interactionId === "privacy") {
|
||||||
|
|
|
@ -113,7 +113,7 @@ function isWebhookMessage(message) {
|
||||||
* @param {Pick<DiscordTypes.APIMessage, "flags">} message
|
* @param {Pick<DiscordTypes.APIMessage, "flags">} message
|
||||||
*/
|
*/
|
||||||
function isEphemeralMessage(message) {
|
function isEphemeralMessage(message) {
|
||||||
return message.flags && (message.flags & DiscordTypes.MessageFlags.Ephemeral)
|
return Boolean(message.flags && (message.flags & DiscordTypes.MessageFlags.Ephemeral))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param {string} snowflake */
|
/** @param {string} snowflake */
|
||||||
|
|
|
@ -84,6 +84,67 @@ test("getPermissions: channel overwrite to allow role works", t => {
|
||||||
t.equal((permissions & want), want)
|
t.equal((permissions & want), want)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("getPermissions: channel overwrite to allow user works", t => {
|
||||||
|
const guildRoles = [
|
||||||
|
{
|
||||||
|
version: 1695412489043,
|
||||||
|
unicode_emoji: null,
|
||||||
|
tags: {},
|
||||||
|
position: 0,
|
||||||
|
permissions: "559623605571137",
|
||||||
|
name: "@everyone",
|
||||||
|
mentionable: false,
|
||||||
|
managed: false,
|
||||||
|
id: "1154868424724463687",
|
||||||
|
icon: null,
|
||||||
|
hoist: false,
|
||||||
|
flags: 0,
|
||||||
|
color: 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 1695412604262,
|
||||||
|
unicode_emoji: null,
|
||||||
|
tags: { bot_id: "466378653216014359" },
|
||||||
|
position: 1,
|
||||||
|
permissions: "536995904",
|
||||||
|
name: "PluralKit",
|
||||||
|
mentionable: false,
|
||||||
|
managed: true,
|
||||||
|
id: "1154868908336099444",
|
||||||
|
icon: null,
|
||||||
|
hoist: false,
|
||||||
|
flags: 0,
|
||||||
|
color: 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 1698778936921,
|
||||||
|
unicode_emoji: null,
|
||||||
|
tags: {},
|
||||||
|
position: 1,
|
||||||
|
permissions: "536870912",
|
||||||
|
name: "web hookers",
|
||||||
|
mentionable: false,
|
||||||
|
managed: false,
|
||||||
|
id: "1168988246680801360",
|
||||||
|
icon: null,
|
||||||
|
hoist: false,
|
||||||
|
flags: 0,
|
||||||
|
color: 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
const userRoles = []
|
||||||
|
const userID = "353373325575323648"
|
||||||
|
const overwrites = [
|
||||||
|
{ type: 0, id: "1154868908336099444", deny: "0", allow: "1024" },
|
||||||
|
{ type: 0, id: "1154868424724463687", deny: "1024", allow: "0" },
|
||||||
|
{ type: 0, id: "1168988246680801360", deny: "0", allow: "1024" },
|
||||||
|
{ type: 1, id: "353373325575323648", deny: "0", allow: "1024" }
|
||||||
|
]
|
||||||
|
const permissions = utils.getPermissions(userRoles, guildRoles, userID, overwrites)
|
||||||
|
const want = BigInt(1 << 10 | 1 << 16)
|
||||||
|
t.equal((permissions & want), want)
|
||||||
|
})
|
||||||
|
|
||||||
test("hasSomePermissions: detects the permission", t => {
|
test("hasSomePermissions: detects the permission", t => {
|
||||||
const userPermissions = DiscordTypes.PermissionFlagsBits.MentionEveryone | DiscordTypes.PermissionFlagsBits.BanMembers
|
const userPermissions = DiscordTypes.PermissionFlagsBits.MentionEveryone | DiscordTypes.PermissionFlagsBits.BanMembers
|
||||||
const canRemoveMembers = utils.hasSomePermissions(userPermissions, ["KickMembers", "BanMembers"])
|
const canRemoveMembers = utils.hasSomePermissions(userPermissions, ["KickMembers", "BanMembers"])
|
||||||
|
@ -107,3 +168,15 @@ test("hasAllPermissions: doesn't detect not the permissions", t => {
|
||||||
const canRemoveMembers = utils.hasAllPermissions(userPermissions, ["KickMembers", "BanMembers"])
|
const canRemoveMembers = utils.hasAllPermissions(userPermissions, ["KickMembers", "BanMembers"])
|
||||||
t.equal(canRemoveMembers, false)
|
t.equal(canRemoveMembers, false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("isEphemeralMessage: detects ephemeral message", t => {
|
||||||
|
t.equal(utils.isEphemeralMessage(data.special_message.ephemeral_message), true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("isEphemeralMessage: doesn't detect normal message", t => {
|
||||||
|
t.equal(utils.isEphemeralMessage(data.message.simple_plaintext), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("getPublicUrlForCdn: no-op on non-discord URL", t => {
|
||||||
|
t.equal(utils.getPublicUrlForCdn("https://cadence.moe"), "https://cadence.moe")
|
||||||
|
})
|
||||||
|
|
|
@ -13,7 +13,7 @@ const utils = sync.require("../converters/utils")
|
||||||
*/
|
*/
|
||||||
async function deleteMessage(event) {
|
async function deleteMessage(event) {
|
||||||
const rows = from("event_message").join("message_channel", "message_id").select("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()
|
||||||
db.prepare("DELETE FROM event_message WHERE event_id = ?").run(event.event_id)
|
db.prepare("DELETE FROM event_message WHERE event_id = ?").run(event.redacts)
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
db.prepare("DELETE FROM message_channel WHERE message_id = ?").run(row.message_id)
|
db.prepare("DELETE FROM message_channel WHERE message_id = ?").run(row.message_id)
|
||||||
await discord.snow.channel.deleteMessage(row.channel_id, row.message_id, event.content.reason)
|
await discord.snow.channel.deleteMessage(row.channel_id, row.message_id, event.content.reason)
|
||||||
|
|
|
@ -30,9 +30,7 @@ const NEWLINE_ELEMENTS = BLOCK_ELEMENTS.concat(["BR"])
|
||||||
*/
|
*/
|
||||||
function eventSenderIsFromDiscord(sender) {
|
function eventSenderIsFromDiscord(sender) {
|
||||||
// If it's from a user in the bridge's namespace, then it originated from discord
|
// If it's from a user in the bridge's namespace, then it originated from discord
|
||||||
// This includes messages sent by the appservice's bot user, because that is what's used for webhooks
|
// This could include messages sent by the appservice's bot user, because that is what's used for webhooks
|
||||||
// TODO: It would be nice if bridge system messages wouldn't trigger this check and could be bridged from matrix to discord, while webhook reflections would remain ignored...
|
|
||||||
// TODO that only applies to the above todo: But you'd have to watch out for the /icon command, where the bridge bot would set the room avatar, and that shouldn't be reflected into the room a second time.
|
|
||||||
if (userRegex.some(x => sender.match(x))) {
|
if (userRegex.some(x => sender.match(x))) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
|
@ -60,7 +60,7 @@ async function createRoom(content) {
|
||||||
*/
|
*/
|
||||||
async function joinRoom(roomIDOrAlias, mxid) {
|
async function joinRoom(roomIDOrAlias, mxid) {
|
||||||
/** @type {Ty.R.RoomJoined} */
|
/** @type {Ty.R.RoomJoined} */
|
||||||
const root = await mreq.mreq("POST", path(`/client/v3/join/${roomIDOrAlias}`, mxid))
|
const root = await mreq.mreq("POST", path(`/client/v3/join/${roomIDOrAlias}`, mxid), {})
|
||||||
return root.room_id
|
return root.room_id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -123,6 +123,17 @@ function getJoinedMembers(roomID) {
|
||||||
return mreq.mreq("GET", `/client/v3/rooms/${roomID}/joined_members`)
|
return mreq.mreq("GET", `/client/v3/rooms/${roomID}/joined_members`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Get the list of members for this room." This includes joined, invited, knocked, left, and banned members unless a filter is provided.
|
||||||
|
* The endpoint also supports `at` and `not_membership` URL parameters, but they are not exposed in this wrapper yet.
|
||||||
|
* @param {string} roomID
|
||||||
|
* @param {"join" | "invite" | "knock" | "leave" | "ban"} [membership] The kind of membership to filter for. Only one choice allowed.
|
||||||
|
* @returns {Promise<{chunk: Ty.Event.Outer<Ty.Event.M_Room_Member>[]}>}
|
||||||
|
*/
|
||||||
|
function getMembers(roomID, membership) {
|
||||||
|
return mreq.mreq("GET", `/client/v3/rooms/${roomID}/members`, undefined, {membership})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} roomID
|
* @param {string} roomID
|
||||||
* @param {{from?: string, limit?: any}} pagination
|
* @param {{from?: string, limit?: any}} pagination
|
||||||
|
@ -339,6 +350,7 @@ module.exports.getEventForTimestamp = getEventForTimestamp
|
||||||
module.exports.getAllState = getAllState
|
module.exports.getAllState = getAllState
|
||||||
module.exports.getStateEvent = getStateEvent
|
module.exports.getStateEvent = getStateEvent
|
||||||
module.exports.getJoinedMembers = getJoinedMembers
|
module.exports.getJoinedMembers = getJoinedMembers
|
||||||
|
module.exports.getMembers = getMembers
|
||||||
module.exports.getHierarchy = getHierarchy
|
module.exports.getHierarchy = getHierarchy
|
||||||
module.exports.getFullHierarchy = getFullHierarchy
|
module.exports.getFullHierarchy = getFullHierarchy
|
||||||
module.exports.getRelations = getRelations
|
module.exports.getRelations = getRelations
|
||||||
|
|
|
@ -5,7 +5,7 @@ const mixin = require("@cloudrac3r/mixin-deep")
|
||||||
const stream = require("stream")
|
const stream = require("stream")
|
||||||
const getStream = require("get-stream")
|
const getStream = require("get-stream")
|
||||||
|
|
||||||
const {reg} = require("./read-registration.js")
|
const {reg, writeRegistration} = require("./read-registration.js")
|
||||||
|
|
||||||
const baseUrl = `${reg.ooye.server_origin}/_matrix`
|
const baseUrl = `${reg.ooye.server_origin}/_matrix`
|
||||||
|
|
||||||
|
@ -45,13 +45,15 @@ async function mreq(method, url, body, extra = {}) {
|
||||||
const root = await res.json()
|
const root = await res.json()
|
||||||
|
|
||||||
if (!res.ok || root.errcode) {
|
if (!res.ok || root.errcode) {
|
||||||
if (root.error?.includes("Content-Length")) {
|
if (root.error?.includes("Content-Length") && !reg.ooye.content_length_workaround) {
|
||||||
console.error(`OOYE cannot stream uploads to Synapse. Please choose one of these workarounds:`
|
reg.ooye.content_length_workaround = true
|
||||||
+ `\n * Run an nginx reverse proxy to Synapse, and point registration.yaml's`
|
const root = await mreq(method, url, body, extra)
|
||||||
+ `\n \`server_origin\` to nginx`
|
console.error("OOYE cannot stream uploads to Synapse. The `content_length_workaround` option"
|
||||||
+ `\n * Set \`content_length_workaround: true\` in registration.yaml (this will`
|
+ "\nhas been activated in registration.yaml, which works around the problem, but"
|
||||||
+ `\n halve the speed of bridging d->m files)`)
|
+ "\nhalves the speed of bridging d->m files. A better way to resolve this problem"
|
||||||
throw new Error("Synapse is not accepting stream uploads, see the message above.")
|
+ "\nis to run an nginx reverse proxy to Synapse and re-run OOYE setup.")
|
||||||
|
writeRegistration(reg)
|
||||||
|
return root
|
||||||
}
|
}
|
||||||
delete opts.headers.Authorization
|
delete opts.headers.Authorization
|
||||||
throw new MatrixServerError(root, {baseUrl, url, ...opts})
|
throw new MatrixServerError(root, {baseUrl, url, ...opts})
|
||||||
|
|
|
@ -1,12 +0,0 @@
|
||||||
// @ts-check
|
|
||||||
|
|
||||||
const {test} = require("supertape")
|
|
||||||
const power = require("./power")
|
|
||||||
|
|
||||||
test("power: get affected rooms", t => {
|
|
||||||
t.deepEqual(power._getAffectedRooms(), [{
|
|
||||||
mxid: "@test_auto_invite:example.org",
|
|
||||||
power_level: 100,
|
|
||||||
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe",
|
|
||||||
}])
|
|
||||||
})
|
|
|
@ -9,7 +9,7 @@ const registrationFilePath = path.join(process.cwd(), "registration.yaml")
|
||||||
|
|
||||||
/** @param {import("../types").AppServiceRegistrationConfig} reg */
|
/** @param {import("../types").AppServiceRegistrationConfig} reg */
|
||||||
function checkRegistration(reg) {
|
function checkRegistration(reg) {
|
||||||
reg["ooye"].invite = (reg.ooye.invite || []).filter(mxid => mxid.endsWith(`:${reg.ooye.server_name}`)) // one day I will understand why typescript disagrees with dot notation on this line
|
reg["ooye"].invite = reg.ooye.invite.filter(mxid => mxid.endsWith(`:${reg.ooye.server_name}`)) // one day I will understand why typescript disagrees with dot notation on this line
|
||||||
assert(reg.ooye?.max_file_size)
|
assert(reg.ooye?.max_file_size)
|
||||||
assert(reg.ooye?.namespace_prefix)
|
assert(reg.ooye?.namespace_prefix)
|
||||||
assert(reg.ooye?.server_name)
|
assert(reg.ooye?.server_name)
|
||||||
|
@ -19,13 +19,18 @@ function checkRegistration(reg) {
|
||||||
assert.match(reg.url, /^https?:/, "url must start with http:// or https://")
|
assert.match(reg.url, /^https?:/, "url must start with http:// or https://")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* c8 ignore next 4 */
|
||||||
/** @param {import("../types").AppServiceRegistrationConfig} reg */
|
/** @param {import("../types").AppServiceRegistrationConfig} reg */
|
||||||
function writeRegistration(reg) {
|
function writeRegistration(reg) {
|
||||||
fs.writeFileSync(registrationFilePath, JSON.stringify(reg, null, 2))
|
fs.writeFileSync(registrationFilePath, JSON.stringify(reg, null, 2))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @returns {import("../types").InitialAppServiceRegistrationConfig} reg */
|
/**
|
||||||
function getTemplateRegistration() {
|
* @param {string} serverName
|
||||||
|
* @returns {import("../types").InitialAppServiceRegistrationConfig} reg
|
||||||
|
*/
|
||||||
|
function getTemplateRegistration(serverName) {
|
||||||
|
const namespace_prefix = "_ooye_"
|
||||||
return {
|
return {
|
||||||
id: "ooye",
|
id: "ooye",
|
||||||
as_token: crypto.randomBytes(32).toString("hex"),
|
as_token: crypto.randomBytes(32).toString("hex"),
|
||||||
|
@ -33,21 +38,22 @@ function getTemplateRegistration() {
|
||||||
namespaces: {
|
namespaces: {
|
||||||
users: [{
|
users: [{
|
||||||
exclusive: true,
|
exclusive: true,
|
||||||
regex: "@_ooye_.*:cadence.moe"
|
regex: `@${namespace_prefix}.*:${serverName}`
|
||||||
}],
|
}],
|
||||||
aliases: [{
|
aliases: [{
|
||||||
exclusive: true,
|
exclusive: true,
|
||||||
regex: "#_ooye_.*:cadence.moe"
|
regex: `#${namespace_prefix}.*:${serverName}`
|
||||||
}]
|
}]
|
||||||
},
|
},
|
||||||
protocols: [
|
protocols: [
|
||||||
"discord"
|
"discord"
|
||||||
],
|
],
|
||||||
sender_localpart: "_ooye_bot",
|
sender_localpart: `${namespace_prefix}bot`,
|
||||||
rate_limited: false,
|
rate_limited: false,
|
||||||
socket: 6693,
|
socket: 6693,
|
||||||
ooye: {
|
ooye: {
|
||||||
namespace_prefix: "_ooye_",
|
namespace_prefix,
|
||||||
|
server_name: serverName,
|
||||||
max_file_size: 5000000,
|
max_file_size: 5000000,
|
||||||
content_length_workaround: false,
|
content_length_workaround: false,
|
||||||
include_user_id_in_mxid: false,
|
include_user_id_in_mxid: false,
|
||||||
|
@ -62,6 +68,8 @@ function readRegistration() {
|
||||||
try {
|
try {
|
||||||
const content = fs.readFileSync(registrationFilePath, "utf8")
|
const content = fs.readFileSync(registrationFilePath, "utf8")
|
||||||
result = JSON.parse(content)
|
result = JSON.parse(content)
|
||||||
|
result.ooye.invite ||= []
|
||||||
|
/* c8 ignore next */
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,8 @@
|
||||||
|
// @ts-check
|
||||||
|
|
||||||
|
const tryToCatch = require("try-to-catch")
|
||||||
const {test} = require("supertape")
|
const {test} = require("supertape")
|
||||||
const {reg} = require("./read-registration")
|
const {reg, checkRegistration, getTemplateRegistration} = require("./read-registration")
|
||||||
|
|
||||||
test("reg: has necessary parameters", t => {
|
test("reg: has necessary parameters", t => {
|
||||||
const propertiesToCheck = ["sender_localpart", "id", "as_token", "ooye"]
|
const propertiesToCheck = ["sender_localpart", "id", "as_token", "ooye"]
|
||||||
|
@ -8,3 +11,19 @@ test("reg: has necessary parameters", t => {
|
||||||
propertiesToCheck
|
propertiesToCheck
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("check: passes on sample", t => {
|
||||||
|
checkRegistration(reg)
|
||||||
|
t.pass("all assertions passed")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("check: fails on template as template is missing some required values that are gathered during setup", t => {
|
||||||
|
let err
|
||||||
|
try {
|
||||||
|
// @ts-ignore
|
||||||
|
checkRegistration(getTemplateRegistration("cadence.moe"))
|
||||||
|
} catch (e) {
|
||||||
|
err = e
|
||||||
|
}
|
||||||
|
t.ok(err, "one of the assertions failed as expected")
|
||||||
|
})
|
||||||
|
|
7
src/types.d.ts
vendored
7
src/types.d.ts
vendored
|
@ -55,9 +55,10 @@ export type InitialAppServiceRegistrationConfig = {
|
||||||
socket?: string | number,
|
socket?: string | number,
|
||||||
ooye: {
|
ooye: {
|
||||||
namespace_prefix: string
|
namespace_prefix: string
|
||||||
max_file_size: number,
|
server_name: string
|
||||||
content_length_workaround: boolean,
|
max_file_size: number
|
||||||
invite: string[],
|
content_length_workaround: boolean
|
||||||
|
invite: string[]
|
||||||
include_user_id_in_mxid: boolean
|
include_user_id_in_mxid: boolean
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -45,6 +45,8 @@ mixin matrix(row, radio=false, badge="")
|
||||||
else
|
else
|
||||||
.s-user-card--link.fs-body1
|
.s-user-card--link.fs-body1
|
||||||
a(href=`https://matrix.to/#/${row.room_id}`)= row.nick || row.name
|
a(href=`https://matrix.to/#/${row.room_id}`)= row.nick || row.name
|
||||||
|
if row.join_rule === "invite"
|
||||||
|
+badge-private
|
||||||
|
|
||||||
block body
|
block body
|
||||||
if !guild_id && session.data.managedGuilds
|
if !guild_id && session.data.managedGuilds
|
||||||
|
@ -91,12 +93,15 @@ block body
|
||||||
div
|
div
|
||||||
-
|
-
|
||||||
let size = 105
|
let size = 105
|
||||||
let src = new URL(`https://api.qrserver.com/v1/create-qr-code/?qzone=1&format=svg&size=${size}x${size}`)
|
let p = new URLSearchParams()
|
||||||
src.searchParams.set("data", `https://bridge.cadence.moe/invite?nonce=${nonce}`)
|
p.set("data", `https://bridge.cadence.moe/invite?nonce=${nonce}`)
|
||||||
img(width=size height=size src=src.toString())
|
img(width=size height=size src=`/qr?${p}`)
|
||||||
|
|
||||||
h2.mt48.fs-headline1 Linked channels
|
h2.mt48.fs-headline1 Moderation
|
||||||
|
|
||||||
|
h2.mt48.fs-headline1 Matrix setup
|
||||||
|
|
||||||
|
h3.mt32.fs-category Linked channels
|
||||||
-
|
-
|
||||||
function getPosition(channel) {
|
function getPosition(channel) {
|
||||||
let position = 0
|
let position = 0
|
||||||
|
@ -118,6 +123,12 @@ block body
|
||||||
let unlinkedChannelIDs = channelIDs.filter(c => !linkedChannelIDs.includes(c))
|
let unlinkedChannelIDs = channelIDs.filter(c => !linkedChannelIDs.includes(c))
|
||||||
let unlinkedChannels = unlinkedChannelIDs.map(c => discord.channels.get(c)).filter(c => [0, 5].includes(c.type))
|
let unlinkedChannels = unlinkedChannelIDs.map(c => discord.channels.get(c)).filter(c => [0, 5].includes(c.type))
|
||||||
unlinkedChannels.sort((a, b) => getPosition(a) - getPosition(b))
|
unlinkedChannels.sort((a, b) => getPosition(a) - getPosition(b))
|
||||||
|
|
||||||
|
let linkedRoomIDs = linkedChannels.map(c => c.room_id)
|
||||||
|
let unlinkedRooms = rooms.filter(r => !linkedRoomIDs.includes(r.room_id) && !r.room_type)
|
||||||
|
// https://discord.com/developers/docs/topics/threads#active-archived-threads
|
||||||
|
// need to filter out linked archived threads from unlinkedRooms, will just do that by comparing against the name
|
||||||
|
unlinkedRooms = unlinkedRooms.filter(r => !r.name.match(/^\[(🔒)?⛓️\]/))
|
||||||
.s-card.bs-sm.p0
|
.s-card.bs-sm.p0
|
||||||
.s-table-container
|
.s-table-container
|
||||||
table.s-table.s-table__bx-simple
|
table.s-table.s-table__bx-simple
|
||||||
|
@ -136,14 +147,65 @@ block body
|
||||||
form.d-flex.ai-center.g8
|
form.d-flex.ai-center.g8
|
||||||
label.s-label.fl-grow1(for="autocreate")
|
label.s-label.fl-grow1(for="autocreate")
|
||||||
| Create new Matrix rooms automatically
|
| Create new Matrix rooms automatically
|
||||||
p.s-description If you want, OOYE can automatically create new Matrix rooms and link them when a new Discord channel is spoken in.
|
p.s-description If you want, OOYE can automatically create new Matrix rooms and link them when an unlinked Discord channel is spoken in.
|
||||||
- let value = select("guild_space", "autocreate", {guild_id}).pluck().get()
|
- let value = !!select("guild_active", "autocreate", {guild_id}).pluck().get()
|
||||||
input(type="hidden" name="guild_id" value=guild_id)
|
input(type="hidden" name="guild_id" value=guild_id)
|
||||||
input.s-toggle-switch.order-last#autocreate(name="autocreate" type="checkbox" hx-post="/api/autocreate" hx-indicator="#autocreate-loading" hx-disabled-elt="this" checked=value)
|
input.s-toggle-switch.order-last#autocreate(name="autocreate" type="checkbox" hx-post="/api/autocreate" hx-indicator="#autocreate-loading" hx-disabled-elt="this" checked=value)
|
||||||
.is-loading#autocreate-loading
|
.is-loading#autocreate-loading
|
||||||
|
|
||||||
|
h3.mt32.fs-category Privacy level
|
||||||
|
.s-card
|
||||||
|
form(hx-post="/api/privacy-level" hx-trigger="change" hx-indicator="#privacy-level-loading" hx-disabled-elt="this")
|
||||||
|
input(type="hidden" name="guild_id" value=guild_id)
|
||||||
|
.d-flex.ai-center.mb4
|
||||||
|
label.s-label.fl-grow1
|
||||||
|
| How people can join on Matrix
|
||||||
|
span.is-loading#privacy-level-loading
|
||||||
|
.s-toggle-switch.s-toggle-switch__multiple.s-toggle-switch__incremental.d-grid.gx16.ai-center(style="grid-template-columns: auto 1fr")
|
||||||
|
input(type="radio" name="level" value="directory" id="privacy-level-directory" checked=(privacy_level === 2))
|
||||||
|
label.d-flex.gx8.jc-center.grid--row-start3(for="privacy-level-directory")
|
||||||
|
!= icons.Icons.IconPlusSm
|
||||||
|
!= icons.Icons.IconInternationalSm
|
||||||
|
.fl-grow1 Directory
|
||||||
|
|
||||||
|
input(type="radio" name="level" value="link" id="privacy-level-link" checked=(privacy_level === 1))
|
||||||
|
label.d-flex.gx8.jc-center.grid--row-start2(for="privacy-level-link")
|
||||||
|
!= icons.Icons.IconPlusSm
|
||||||
|
!= icons.Icons.IconLinkSm
|
||||||
|
.fl-grow1 Link
|
||||||
|
|
||||||
|
input(type="radio" name="level" value="invite" id="privacy-level-invite" checked=(privacy_level === 0))
|
||||||
|
label.d-flex.gx8.jc-center.grid--row-start1(for="privacy-level-invite")
|
||||||
|
svg.svg-icon(width="14" height="14" viewBox="0 0 14 14")
|
||||||
|
!= icons.Icons.IconLockSm
|
||||||
|
.fl-grow1 Invite
|
||||||
|
|
||||||
|
p.s-description.m0 In-app direct invite from another user
|
||||||
|
p.s-description.m0 Shareable invite links, like Discord
|
||||||
|
p.s-description.m0 Publicly listed in directory, like Discord server discovery
|
||||||
|
|
||||||
|
|
||||||
|
//-
|
||||||
|
fieldset.s-check-group
|
||||||
|
legend.s-label How people can join on Matrix
|
||||||
|
.s-check-control
|
||||||
|
input.s-radio(type="radio" name="privacy-level" id="privacy-level-invite" value="invite" checked)
|
||||||
|
label.s-label(for="privacy-level-invite")
|
||||||
|
| Invite
|
||||||
|
p.s-description In-app direct invite on Matrix; invite command on Discord; invite form on web
|
||||||
|
.s-check-control
|
||||||
|
input.s-radio(type="radio" name="privacy-level" id="privacy-level-link" value="link")
|
||||||
|
label.s-label(for="privacy-level-link")
|
||||||
|
| Link
|
||||||
|
p.s-description All of the above, and shareable invite links (like Discord)
|
||||||
|
.s-check-control
|
||||||
|
input.s-radio(type="radio" name="privacy-level" id="privacy-level-directory" value="directory")
|
||||||
|
label.s-label(for="privacy-level-directory")
|
||||||
|
| Public
|
||||||
|
p.s-description All of the above, and publicly visible in the Matrix space directory (like Server Discovery)
|
||||||
|
|
||||||
h3.mt32.fs-category Manually link channels
|
h3.mt32.fs-category Manually link channels
|
||||||
form.d-flex.g16.ai-start(method="post" action="/api/link")
|
form.d-flex.g16.ai-start(hx-post="/api/privacy-level" hx-trigger="submit" hx-disabled-elt="this")
|
||||||
.fl-grow2.s-btn-group.fd-column.w40
|
.fl-grow2.s-btn-group.fd-column.w40
|
||||||
each channel in unlinkedChannels
|
each channel in unlinkedChannels
|
||||||
input.s-btn--radio(type="radio" name="discord" id=channel.id value=channel.id)
|
input.s-btn--radio(type="radio" name="discord" id=channel.id value=channel.id)
|
||||||
|
@ -152,8 +214,14 @@ block body
|
||||||
else
|
else
|
||||||
.s-empty-state.p8 All Discord channels are linked.
|
.s-empty-state.p8 All Discord channels are linked.
|
||||||
.fl-grow1.s-btn-group.fd-column.w30
|
.fl-grow1.s-btn-group.fd-column.w30
|
||||||
.s-empty-state.p8 I don't know how to get the Matrix room list yet...
|
each room in unlinkedRooms
|
||||||
|
input.s-btn--radio(type="radio" name="matrix" id=room.room_id value=room.room_id)
|
||||||
|
label.s-btn.s-btn__muted.ta-left.truncate(for=room.room_id)
|
||||||
|
+matrix(room, true)
|
||||||
|
else
|
||||||
|
.s-empty-state.p8 All Matrix rooms are linked.
|
||||||
|
input(type="hidden" name="guild_id" value=guild_id)
|
||||||
div
|
div
|
||||||
button.s-btn.s-btn__icon.s-btn__filled
|
button.s-btn.s-btn__icon.s-btn__filled.htmx-indicator
|
||||||
!= icons.Icons.IconLink
|
!= icons.Icons.IconMerge
|
||||||
= ` Connect`
|
= ` Link`
|
||||||
|
|
|
@ -4,7 +4,7 @@ block body
|
||||||
.s-page-title.mb24
|
.s-page-title.mb24
|
||||||
h1.s-page-title--header Bridge a Discord server
|
h1.s-page-title--header Bridge a Discord server
|
||||||
|
|
||||||
.d-grid.grid__2.g24
|
.d-grid.g24.grid__2(class="sm:grid__1")
|
||||||
.s-card.bs-md.d-flex.fd-column
|
.s-card.bs-md.d-flex.fd-column
|
||||||
h2 Easy mode
|
h2 Easy mode
|
||||||
p Add the bot to your Discord server.
|
p Add the bot to your Discord server.
|
||||||
|
|
|
@ -13,6 +13,7 @@ doctype html
|
||||||
html(lang="en")
|
html(lang="en")
|
||||||
head
|
head
|
||||||
title Out Of Your Element
|
title Out Of Your Element
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
link(rel="stylesheet" type="text/css" href="/static/stacks.min.css")
|
link(rel="stylesheet" type="text/css" href="/static/stacks.min.css")
|
||||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 80%22><text y=%22.83em%22 font-size=%2283%22>💬</text></svg>">
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 80%22><text y=%22.83em%22 font-size=%2283%22>💬</text></svg>">
|
||||||
meta(name="htmx-config" content='{"indicatorClass":"is-loading"}')
|
meta(name="htmx-config" content='{"indicatorClass":"is-loading"}')
|
||||||
|
@ -25,6 +26,10 @@ html(lang="en")
|
||||||
--theme-dark-primary-color-s: 53%;
|
--theme-dark-primary-color-s: 53%;
|
||||||
--theme-dark-primary-color-l: 63%;
|
--theme-dark-primary-color-l: 63%;
|
||||||
}
|
}
|
||||||
|
.s-toggle-switch.s-toggle-switch__multiple.s-toggle-switch__incremental input[type="radio"]:checked ~ label:not(.s-toggle-switch--label-off) {
|
||||||
|
--_ts-multiple-bg: var(--green-400);
|
||||||
|
--_ts-multiple-fc: var(--white);
|
||||||
|
}
|
||||||
body.themed.theme-system
|
body.themed.theme-system
|
||||||
header.s-topbar
|
header.s-topbar
|
||||||
.s-topbar--skip-link(href="#content") Skip to main content
|
.s-topbar--skip-link(href="#content") Skip to main content
|
||||||
|
@ -52,7 +57,7 @@ html(lang="en")
|
||||||
li(role="menuitem")
|
li(role="menuitem")
|
||||||
a.s-topbar--item.s-user-card.d-flex.p4(href=`/guild?guild_id=${guild.id}`)
|
a.s-topbar--item.s-user-card.d-flex.p4(href=`/guild?guild_id=${guild.id}`)
|
||||||
+guild(guild)
|
+guild(guild)
|
||||||
.mx-auto.w100.wmx9.py24#content
|
.mx-auto.w100.wmx9.py24.px8.fs-body1#content
|
||||||
block body
|
block body
|
||||||
script.
|
script.
|
||||||
document.querySelectorAll("[popovertarget]").forEach(e => {
|
document.querySelectorAll("[popovertarget]").forEach(e => {
|
||||||
|
|
|
@ -3,7 +3,7 @@ extends includes/template.pug
|
||||||
block body
|
block body
|
||||||
if !isValid
|
if !isValid
|
||||||
.s-empty-state.wmx4.p48
|
.s-empty-state.wmx4.p48
|
||||||
!= icons.Spots.SpotAlertXL
|
!= icons.Spots.SpotExpireXL
|
||||||
p This QR code has expired.
|
p This QR code has expired.
|
||||||
p Refresh the guild management page to generate a new one.
|
p Refresh the guild management page to generate a new one.
|
||||||
|
|
||||||
|
|
|
@ -1,15 +1,25 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
|
const assert = require("assert/strict")
|
||||||
const {z} = require("zod")
|
const {z} = require("zod")
|
||||||
const {defineEventHandler, sendRedirect, useSession, createError, readValidatedBody} = require("h3")
|
const {defineEventHandler, sendRedirect, useSession, createError, readValidatedBody} = require("h3")
|
||||||
|
|
||||||
const {as, db} = require("../../passthrough")
|
const {as, db, sync} = require("../../passthrough")
|
||||||
const {reg} = require("../../matrix/read-registration")
|
const {reg} = require("../../matrix/read-registration")
|
||||||
|
|
||||||
|
/** @type {import("../../d2m/actions/create-space")} */
|
||||||
|
const createSpace = sync.require("../../d2m/actions/create-space")
|
||||||
|
|
||||||
|
/** @type {["invite", "link", "directory"]} */
|
||||||
|
const levels = ["invite", "link", "directory"]
|
||||||
const schema = {
|
const schema = {
|
||||||
autocreate: z.object({
|
autocreate: z.object({
|
||||||
guild_id: z.string(),
|
guild_id: z.string(),
|
||||||
autocreate: z.string().optional()
|
autocreate: z.string().optional()
|
||||||
|
}),
|
||||||
|
privacyLevel: z.object({
|
||||||
|
guild_id: z.string(),
|
||||||
|
level: z.enum(levels)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -18,6 +28,18 @@ as.router.post("/api/autocreate", defineEventHandler(async event => {
|
||||||
const session = await useSession(event, {password: reg.as_token})
|
const session = await useSession(event, {password: reg.as_token})
|
||||||
if (!(session.data.managedGuilds || []).includes(parsedBody.guild_id)) throw createError({status: 403, message: "Forbidden", data: "Can't change settings for a guild you don't have Manage Server permissions in"})
|
if (!(session.data.managedGuilds || []).includes(parsedBody.guild_id)) throw createError({status: 403, message: "Forbidden", data: "Can't change settings for a guild you don't have Manage Server permissions in"})
|
||||||
|
|
||||||
db.prepare("UPDATE guild_space SET autocreate = ? WHERE guild_id = ?").run(+!!parsedBody.autocreate, parsedBody.guild_id)
|
db.prepare("UPDATE guild_active SET autocreate = ? WHERE guild_id = ?").run(+!!parsedBody.autocreate, parsedBody.guild_id)
|
||||||
return sendRedirect(event, `/guild?guild_id=${parsedBody.guild_id}`, 302)
|
return null // 204
|
||||||
|
}))
|
||||||
|
|
||||||
|
as.router.post("/api/privacy-level", defineEventHandler(async event => {
|
||||||
|
const parsedBody = await readValidatedBody(event, schema.privacyLevel.parse)
|
||||||
|
const session = await useSession(event, {password: reg.as_token})
|
||||||
|
if (!(session.data.managedGuilds || []).includes(parsedBody.guild_id)) throw createError({status: 403, message: "Forbidden", data: "Can't change settings for a guild you don't have Manage Server permissions in"})
|
||||||
|
|
||||||
|
const i = levels.indexOf(parsedBody.level)
|
||||||
|
assert.notEqual(i, -1)
|
||||||
|
db.prepare("UPDATE guild_space SET privacy_level = ? WHERE guild_id = ?").run(i, parsedBody.guild_id)
|
||||||
|
await createSpace.syncSpaceFully(parsedBody.guild_id) // this is inefficient but OK to call infrequently on user request
|
||||||
|
return null // 204
|
||||||
}))
|
}))
|
||||||
|
|
|
@ -9,6 +9,8 @@ const {LRUCache} = require("lru-cache")
|
||||||
const {discord, as, sync, select} = require("../../passthrough")
|
const {discord, as, sync, select} = require("../../passthrough")
|
||||||
/** @type {import("../pug-sync")} */
|
/** @type {import("../pug-sync")} */
|
||||||
const pugSync = sync.require("../pug-sync")
|
const pugSync = sync.require("../pug-sync")
|
||||||
|
/** @type {import("../../d2m/actions/create-space")} */
|
||||||
|
const createSpace = sync.require("../../d2m/actions/create-space")
|
||||||
const {reg} = require("../../matrix/read-registration")
|
const {reg} = require("../../matrix/read-registration")
|
||||||
|
|
||||||
/** @type {import("../../matrix/api")} */
|
/** @type {import("../../matrix/api")} */
|
||||||
|
@ -34,14 +36,18 @@ const validNonce = new LRUCache({max: 200})
|
||||||
|
|
||||||
as.router.get("/guild", defineEventHandler(async event => {
|
as.router.get("/guild", defineEventHandler(async event => {
|
||||||
const {guild_id} = await getValidatedQuery(event, schema.guild.parse)
|
const {guild_id} = await getValidatedQuery(event, schema.guild.parse)
|
||||||
const nonce = randomUUID()
|
const session = await useSession(event, {password: reg.as_token})
|
||||||
if (guild_id) {
|
const row = select("guild_space", ["space_id", "privacy_level"], {guild_id}).get()
|
||||||
// Security note: the nonce alone is valid for updating the guild
|
if (!guild_id || !row || !discord.guilds.has(guild_id) || !session.data.managedGuilds || !session.data.managedGuilds.includes(guild_id)) {
|
||||||
// We have not verified the user has sufficient permissions in the guild at generation time
|
return pugSync.render(event, "guild.pug", {guild_id})
|
||||||
// These permissions are checked later during page rendering and the generated nonce is only revealed if the permissions are sufficient
|
|
||||||
validNonce.set(nonce, guild_id)
|
|
||||||
}
|
}
|
||||||
return pugSync.render(event, "guild.pug", {nonce})
|
|
||||||
|
const nonce = randomUUID()
|
||||||
|
validNonce.set(nonce, guild_id)
|
||||||
|
const mods = await api.getStateEvent(row.space_id, "m.room.power_levels", "")
|
||||||
|
const banned = await api.getMembers(row.space_id, "ban")
|
||||||
|
const rooms = await api.getFullHierarchy(row.space_id)
|
||||||
|
return pugSync.render(event, "guild.pug", {guild_id, nonce, mods, banned, rooms, ...row})
|
||||||
}))
|
}))
|
||||||
|
|
||||||
as.router.get("/invite", defineEventHandler(async event => {
|
as.router.get("/invite", defineEventHandler(async event => {
|
||||||
|
@ -71,20 +77,20 @@ as.router.post("/api/invite", defineEventHandler(async event => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check guild is bridged
|
// Check guild is bridged
|
||||||
const spaceID = select("guild_space", "space_id", {guild_id: guild_id}).pluck().get()
|
const guild = discord.guilds.get(guild_id)
|
||||||
if (!spaceID) throw createError({status: 428, message: "Server not bridged", data: "You can only invite Matrix users to servers that are bridged to Matrix."})
|
assert(guild)
|
||||||
|
const spaceID = await createSpace.ensureSpace(guild)
|
||||||
|
|
||||||
// Check for existing invite to the space
|
// Check for existing invite to the space
|
||||||
let spaceMember
|
let spaceMember
|
||||||
try {
|
try {
|
||||||
spaceMember = await api.getStateEvent(spaceID, "m.room.member", parsedBody.mxid)
|
spaceMember = await api.getStateEvent(spaceID, "m.room.member", parsedBody.mxid)
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
if (spaceMember && (spaceMember.membership === "invite" || spaceMember.membership === "join")) {
|
|
||||||
return sendRedirect(event, `/guild?guild_id=${guild_id}`, 302)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (!spaceMember || spaceMember.membership !== "invite" || spaceMember.membership !== "join") {
|
||||||
// Invite
|
// Invite
|
||||||
await api.inviteToRoom(spaceID, parsedBody.mxid)
|
await api.inviteToRoom(spaceID, parsedBody.mxid)
|
||||||
|
}
|
||||||
|
|
||||||
// Permissions
|
// Permissions
|
||||||
if (parsedBody.permissions === "moderator") {
|
if (parsedBody.permissions === "moderator") {
|
||||||
|
|
63
src/web/routes/link.js
Normal file
63
src/web/routes/link.js
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
// @ts-check
|
||||||
|
|
||||||
|
const {z} = require("zod")
|
||||||
|
const {defineEventHandler, useSession, createError, readValidatedBody} = require("h3")
|
||||||
|
const Ty = require("../../types")
|
||||||
|
|
||||||
|
const {discord, db, as, sync, select, from} = require("../../passthrough")
|
||||||
|
/** @type {import("../../d2m/actions/create-space")} */
|
||||||
|
const createSpace = sync.require("../../d2m/actions/create-space")
|
||||||
|
/** @type {import("../../d2m/actions/create-room")} */
|
||||||
|
const createRoom = sync.require("../../d2m/actions/create-room")
|
||||||
|
const {reg} = require("../../matrix/read-registration")
|
||||||
|
|
||||||
|
/** @type {import("../../matrix/api")} */
|
||||||
|
const api = sync.require("../../matrix/api")
|
||||||
|
|
||||||
|
const schema = {
|
||||||
|
link: z.object({
|
||||||
|
guild_id: z.string(),
|
||||||
|
matrix: z.string(),
|
||||||
|
discord: z.string()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
as.router.post("/api/link", defineEventHandler(async event => {
|
||||||
|
const parsedBody = await readValidatedBody(event, schema.link.parse)
|
||||||
|
const session = await useSession(event, {password: reg.as_token})
|
||||||
|
|
||||||
|
// Check guild ID or nonce
|
||||||
|
const guildID = parsedBody.guild_id
|
||||||
|
if (!(session.data.managedGuilds || []).includes(guildID)) throw createError({status: 403, message: "Forbidden", data: "Can't edit a guild you don't have Manage Server permissions in"})
|
||||||
|
|
||||||
|
// Check guild is bridged
|
||||||
|
const guild = discord.guilds.get(guildID)
|
||||||
|
if (!guild) throw createError({status: 400, message: "Bad Request", data: "Discord guild does not exist or bot has not joined it"})
|
||||||
|
const spaceID = await createSpace.ensureSpace(guild)
|
||||||
|
|
||||||
|
// Check channel exists
|
||||||
|
const channel = discord.channels.get(parsedBody.discord)
|
||||||
|
if (!channel) throw createError({status: 400, message: "Bad Request", data: "Discord channel does not exist"})
|
||||||
|
|
||||||
|
// Check channel and room are not already bridged
|
||||||
|
const row = from("channel_room").select("channel_id", "room_id").and("WHERE channel_id = ? OR room_id = ?").get(parsedBody.discord, parsedBody.matrix)
|
||||||
|
if (row) throw createError({status: 400, message: "Bad Request", data: `Channel ID ${row.channel_id} and room ID ${row.room_id} are already bridged and cannot be reused`})
|
||||||
|
|
||||||
|
// Check room exists and bridge is joined and bridge has PL 100
|
||||||
|
const self = `@${reg.sender_localpart}:${reg.ooye.server_name}`
|
||||||
|
/** @type {Ty.Event.M_Room_Member} */
|
||||||
|
const memberEvent = await api.getStateEvent(parsedBody.matrix, "m.room.member", self)
|
||||||
|
if (memberEvent.membership !== "join") throw createError({status: 400, message: "Bad Request", data: "Matrix room does not exist"})
|
||||||
|
/** @type {Ty.Event.M_Power_Levels} */
|
||||||
|
const powerLevelsStateContent = await api.getStateEvent(parsedBody.matrix, "m.room.power_levels", "")
|
||||||
|
const selfPowerLevel = powerLevelsStateContent.users?.[self] || powerLevelsStateContent.users_default || 0
|
||||||
|
if (selfPowerLevel < (powerLevelsStateContent.state_default || 50) || selfPowerLevel < 100) throw createError({status: 400, message: "Bad Request", data: "OOYE needs power level 100 (admin) in the target Matrix room"})
|
||||||
|
|
||||||
|
// Insert database entry
|
||||||
|
db.prepare("INSERT INTO channel_room (channel_id, room_id, name, guild_id) VALUES (?, ?, ?, ?)").run(parsedBody.discord, parsedBody.matrix, channel.name, guildID)
|
||||||
|
|
||||||
|
// Sync room data and space child
|
||||||
|
createRoom.syncRoom(parsedBody.discord)
|
||||||
|
|
||||||
|
return null // 204
|
||||||
|
}))
|
|
@ -7,7 +7,7 @@ const {SnowTransfer} = require("snowtransfer")
|
||||||
const DiscordTypes = require("discord-api-types/v10")
|
const DiscordTypes = require("discord-api-types/v10")
|
||||||
const fetch = require("node-fetch")
|
const fetch = require("node-fetch")
|
||||||
|
|
||||||
const {as} = require("../../passthrough")
|
const {as, db} = require("../../passthrough")
|
||||||
const {id} = require("../../../addbot")
|
const {id} = require("../../../addbot")
|
||||||
const {reg} = require("../../matrix/read-registration")
|
const {reg} = require("../../matrix/read-registration")
|
||||||
|
|
||||||
|
@ -77,14 +77,19 @@ as.router.get("/oauth", defineEventHandler(async event => {
|
||||||
const client = new SnowTransfer(`Bearer ${parsedToken.data.access_token}`)
|
const client = new SnowTransfer(`Bearer ${parsedToken.data.access_token}`)
|
||||||
try {
|
try {
|
||||||
const guilds = await client.user.getGuilds()
|
const guilds = await client.user.getGuilds()
|
||||||
const managedGuilds = guilds.filter(g => BigInt(g.permissions) & DiscordTypes.PermissionFlagsBits.ManageGuild).map(g => g.id)
|
var managedGuilds = guilds.filter(g => BigInt(g.permissions) & DiscordTypes.PermissionFlagsBits.ManageGuild).map(g => g.id)
|
||||||
await session.update({managedGuilds})
|
await session.update({managedGuilds})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw createError({status: 502, message: "API call failed", data: e.message})
|
throw createError({status: 502, message: "API call failed", data: e.message})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set auto-create for the guild
|
||||||
|
// @ts-ignore
|
||||||
|
if (managedGuilds.includes(parsedQuery.data.guild_id)) {
|
||||||
|
db.prepare("INSERT OR IGNORE INTO guild_active (guild_id, autocreate) VALUES (?, ?)").run(parsedQuery.data.guild_id, +!session.data.selfService)
|
||||||
|
}
|
||||||
|
|
||||||
if (parsedQuery.data.guild_id) {
|
if (parsedQuery.data.guild_id) {
|
||||||
// TODO: we probably need to create a matrix space and database entry immediately here so that self-service settings apply and so matrix users can be invited
|
|
||||||
return sendRedirect(event, `/guild?guild_id=${parsedQuery.data.guild_id}`, 302)
|
return sendRedirect(event, `/guild?guild_id=${parsedQuery.data.guild_id}`, 302)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
19
src/web/routes/qr.js
Normal file
19
src/web/routes/qr.js
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
// @ts-check
|
||||||
|
|
||||||
|
const {z} = require("zod")
|
||||||
|
const {defineEventHandler, getValidatedQuery} = require("h3")
|
||||||
|
|
||||||
|
const {as} = require("../../passthrough")
|
||||||
|
|
||||||
|
const uqr = require("uqr")
|
||||||
|
|
||||||
|
const schema = {
|
||||||
|
qr: z.object({
|
||||||
|
data: z.string().max(128)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
as.router.get("/qr", defineEventHandler(async event => {
|
||||||
|
const {data} = await getValidatedQuery(event, schema.qr.parse)
|
||||||
|
return new Response(uqr.renderSVG(data, {pixelSize: 3}), {headers: {"content-type": "image/svg+xml"}})
|
||||||
|
}))
|
|
@ -23,9 +23,11 @@ pugSync.createRoute(as.router, "/ok", "ok.pug")
|
||||||
|
|
||||||
sync.require("./routes/download-matrix")
|
sync.require("./routes/download-matrix")
|
||||||
sync.require("./routes/download-discord")
|
sync.require("./routes/download-discord")
|
||||||
sync.require("./routes/invite")
|
|
||||||
sync.require("./routes/guild-settings")
|
sync.require("./routes/guild-settings")
|
||||||
|
sync.require("./routes/invite")
|
||||||
|
sync.require("./routes/link")
|
||||||
sync.require("./routes/oauth")
|
sync.require("./routes/oauth")
|
||||||
|
sync.require("./routes/qr")
|
||||||
|
|
||||||
// Files
|
// Files
|
||||||
|
|
||||||
|
|
1
start.js
1
start.js
|
@ -9,7 +9,6 @@ const {reg} = require("./src/matrix/read-registration")
|
||||||
const passthrough = require("./src/passthrough")
|
const passthrough = require("./src/passthrough")
|
||||||
const db = new sqlite("ooye.db")
|
const db = new sqlite("ooye.db")
|
||||||
|
|
||||||
/** @type {import("heatsync").default} */ // @ts-ignore
|
|
||||||
const sync = new HeatSync()
|
const sync = new HeatSync()
|
||||||
|
|
||||||
Object.assign(passthrough, {sync, db})
|
Object.assign(passthrough, {sync, db})
|
||||||
|
|
8
test/addbot.test.js
Normal file
8
test/addbot.test.js
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
// @ts-check
|
||||||
|
|
||||||
|
const {addbot} = require("../addbot")
|
||||||
|
const {test} = require("supertape")
|
||||||
|
|
||||||
|
test("addbot: returns message and invite link", t => {
|
||||||
|
t.equal(addbot(), `Open this link to add the bot to a Discord server:\nhttps://discord.com/oauth2/authorize?client_id=684280192553844747&scope=bot&permissions=1610883072 `)
|
||||||
|
})
|
238
test/data.js
238
test/data.js
|
@ -58,7 +58,7 @@ module.exports = {
|
||||||
network: {
|
network: {
|
||||||
id: "112760669178241024",
|
id: "112760669178241024",
|
||||||
displayname: "Psychonauts 3",
|
displayname: "Psychonauts 3",
|
||||||
avatar_url: "mxc://cadence.moe/zKXGZhmImMHuGQZWJEFKJbsF"
|
avatar_url: {$url: "/icons/112760669178241024/a_f83622e09ead74f0c5c527fe241f8f8c.png?size=1024"}
|
||||||
},
|
},
|
||||||
channel: {
|
channel: {
|
||||||
id: "112760669178241024",
|
id: "112760669178241024",
|
||||||
|
@ -2129,6 +2129,193 @@ module.exports = {
|
||||||
mention_everyone: false,
|
mention_everyone: false,
|
||||||
tts: false
|
tts: false
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
forwarded_image: { type: 0,
|
||||||
|
content: "",
|
||||||
|
mentions: [],
|
||||||
|
mention_roles: [],
|
||||||
|
attachments: [],
|
||||||
|
embeds: [],
|
||||||
|
timestamp: "2024-10-16T22:25:01.973000+00:00",
|
||||||
|
edited_timestamp: null,
|
||||||
|
flags: 16384,
|
||||||
|
components: [],
|
||||||
|
id: "1296237495993892916",
|
||||||
|
channel_id: "112760669178241024",
|
||||||
|
author: {
|
||||||
|
id: "113340068197859328",
|
||||||
|
username: "kumaccino",
|
||||||
|
avatar: "a8829abe66866d7797b36f0bfac01086",
|
||||||
|
discriminator: "0",
|
||||||
|
public_flags: 128,
|
||||||
|
flags: 128,
|
||||||
|
banner: null,
|
||||||
|
accent_color: null,
|
||||||
|
global_name: "kumaccino",
|
||||||
|
avatar_decoration_data: null,
|
||||||
|
banner_color: null,
|
||||||
|
clan: null
|
||||||
|
},
|
||||||
|
pinned: false,
|
||||||
|
mention_everyone: false,
|
||||||
|
tts: false,
|
||||||
|
message_reference: {
|
||||||
|
type: 1,
|
||||||
|
channel_id: "1019762340922663022",
|
||||||
|
message_id: "1019779830469894234"
|
||||||
|
},
|
||||||
|
position: 0,
|
||||||
|
message_snapshots: [
|
||||||
|
{
|
||||||
|
message: {
|
||||||
|
type: 0,
|
||||||
|
content: "",
|
||||||
|
mentions: [],
|
||||||
|
mention_roles: [],
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
id: "1296237494987133070",
|
||||||
|
filename: "100km.gif",
|
||||||
|
size: 2965649,
|
||||||
|
url: "https://cdn.discordapp.com/attachments/112760669178241024/1296237494987133070/100km.gif?ex=67118ebd&is=67103d3d&hm=8ed76d424f92f11366989f2ebc713d4f8206706ef712571e934da45b59944f77&", proxy_url: "https://media.discordapp.net/attachments/112760669178241024/1296237494987133070/100km.gif?ex=67118ebd&is=67103d3d&hm=8ed76d424f92f11366989f2ebc713d4f8206706ef712571e934da45b59944f77&", width: 300,
|
||||||
|
height: 300,
|
||||||
|
content_type: "image/gif"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
embeds: [],
|
||||||
|
timestamp: "2022-09-15T01:20:58.177000+00:00",
|
||||||
|
edited_timestamp: null,
|
||||||
|
flags: 0,
|
||||||
|
components: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
constructed_forwarded_message: { type: 0,
|
||||||
|
content: "",
|
||||||
|
mentions: [],
|
||||||
|
mention_roles: [],
|
||||||
|
attachments: [],
|
||||||
|
embeds: [],
|
||||||
|
timestamp: "2024-10-16T22:25:01.973000+00:00",
|
||||||
|
edited_timestamp: null,
|
||||||
|
flags: 16384,
|
||||||
|
components: [],
|
||||||
|
id: "1296237495993892916",
|
||||||
|
channel_id: "112760669178241024",
|
||||||
|
author: {
|
||||||
|
id: "113340068197859328",
|
||||||
|
username: "kumaccino",
|
||||||
|
avatar: "a8829abe66866d7797b36f0bfac01086",
|
||||||
|
discriminator: "0",
|
||||||
|
public_flags: 128,
|
||||||
|
flags: 128,
|
||||||
|
banner: null,
|
||||||
|
accent_color: null,
|
||||||
|
global_name: "kumaccino",
|
||||||
|
avatar_decoration_data: null,
|
||||||
|
banner_color: null,
|
||||||
|
clan: null
|
||||||
|
},
|
||||||
|
pinned: false,
|
||||||
|
mention_everyone: false,
|
||||||
|
tts: false,
|
||||||
|
message_reference: {
|
||||||
|
type: 1,
|
||||||
|
channel_id: "176333891320283136",
|
||||||
|
message_id: "1191567971970191490"
|
||||||
|
},
|
||||||
|
position: 0,
|
||||||
|
message_snapshots: [
|
||||||
|
{
|
||||||
|
message: {
|
||||||
|
type: 0,
|
||||||
|
content: "What's cooking, good looking? <:hipposcope:393635038903926784>",
|
||||||
|
mentions: [],
|
||||||
|
mention_roles: [],
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
id: "1296237494987133070",
|
||||||
|
filename: "100km.gif",
|
||||||
|
size: 2965649,
|
||||||
|
url: "https://cdn.discordapp.com/attachments/112760669178241024/1296237494987133070/100km.gif?ex=67118ebd&is=67103d3d&hm=8ed76d424f92f11366989f2ebc713d4f8206706ef712571e934da45b59944f77&", proxy_url: "https://media.discordapp.net/attachments/112760669178241024/1296237494987133070/100km.gif?ex=67118ebd&is=67103d3d&hm=8ed76d424f92f11366989f2ebc713d4f8206706ef712571e934da45b59944f77&", width: 300,
|
||||||
|
height: 300,
|
||||||
|
content_type: "image/gif"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
embeds: [{
|
||||||
|
type: "rich",
|
||||||
|
title: "This man is 100 km away from your house",
|
||||||
|
author: {
|
||||||
|
name: "This man"
|
||||||
|
},
|
||||||
|
fields: [{
|
||||||
|
name: "Distance away",
|
||||||
|
value: "99 km"
|
||||||
|
}, {
|
||||||
|
name: "Distance away",
|
||||||
|
value: "98 km"
|
||||||
|
}]
|
||||||
|
}],
|
||||||
|
timestamp: "2022-09-15T01:20:58.177000+00:00",
|
||||||
|
edited_timestamp: null,
|
||||||
|
flags: 0,
|
||||||
|
components: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
constructed_forwarded_text: { type: 0,
|
||||||
|
content: "What's cooking everybody ‼️",
|
||||||
|
mentions: [],
|
||||||
|
mention_roles: [],
|
||||||
|
attachments: [],
|
||||||
|
embeds: [],
|
||||||
|
timestamp: "2024-10-16T22:25:01.973000+00:00",
|
||||||
|
edited_timestamp: null,
|
||||||
|
flags: 16384,
|
||||||
|
components: [],
|
||||||
|
id: "1296237495993892916",
|
||||||
|
channel_id: "112760669178241024",
|
||||||
|
author: {
|
||||||
|
id: "113340068197859328",
|
||||||
|
username: "kumaccino",
|
||||||
|
avatar: "a8829abe66866d7797b36f0bfac01086",
|
||||||
|
discriminator: "0",
|
||||||
|
public_flags: 128,
|
||||||
|
flags: 128,
|
||||||
|
banner: null,
|
||||||
|
accent_color: null,
|
||||||
|
global_name: "kumaccino",
|
||||||
|
avatar_decoration_data: null,
|
||||||
|
banner_color: null,
|
||||||
|
clan: null
|
||||||
|
},
|
||||||
|
pinned: false,
|
||||||
|
mention_everyone: false,
|
||||||
|
tts: false,
|
||||||
|
message_reference: {
|
||||||
|
type: 1,
|
||||||
|
channel_id: "497161350934560778",
|
||||||
|
message_id: "0"
|
||||||
|
},
|
||||||
|
position: 0,
|
||||||
|
message_snapshots: [
|
||||||
|
{
|
||||||
|
message: {
|
||||||
|
type: 0,
|
||||||
|
content: "What's cooking, good looking?",
|
||||||
|
mentions: [],
|
||||||
|
mention_roles: [],
|
||||||
|
attachments: [],
|
||||||
|
embeds: [],
|
||||||
|
timestamp: "2022-09-15T01:20:58.177000+00:00",
|
||||||
|
edited_timestamp: null,
|
||||||
|
flags: 0,
|
||||||
|
components: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
pk_message: {
|
pk_message: {
|
||||||
|
@ -4128,7 +4315,54 @@ module.exports = {
|
||||||
guild_id: "112760669178241024"
|
guild_id: "112760669178241024"
|
||||||
},
|
},
|
||||||
position: 0
|
position: 0
|
||||||
}
|
},
|
||||||
|
ephemeral_message: {
|
||||||
|
webhook_id: "684280192553844747",
|
||||||
|
type: 20,
|
||||||
|
tts: false,
|
||||||
|
timestamp: "2024-09-29T11:22:04.865000+00:00",
|
||||||
|
position: 0,
|
||||||
|
pinned: false,
|
||||||
|
nonce: "1289910062243905536",
|
||||||
|
mentions: [],
|
||||||
|
mention_roles: [],
|
||||||
|
mention_everyone: false,
|
||||||
|
interaction_metadata: {
|
||||||
|
user: {baby: true},
|
||||||
|
type: 2,
|
||||||
|
name: "invite",
|
||||||
|
id: "1289910063691206717",
|
||||||
|
command_type: 1,
|
||||||
|
authorizing_integration_owners: {baby: true}
|
||||||
|
},
|
||||||
|
interaction: {
|
||||||
|
user: {baby: true},
|
||||||
|
type: 2,
|
||||||
|
name: "invite",
|
||||||
|
id: "1289910063691206717"
|
||||||
|
},
|
||||||
|
id: "1289910064995504182",
|
||||||
|
flags: 64,
|
||||||
|
embeds: [],
|
||||||
|
edited_timestamp: null,
|
||||||
|
content: "`@cadence:cadence.moe` is already in this server and this channel.",
|
||||||
|
components: [],
|
||||||
|
channel_id: "1100319550446252084",
|
||||||
|
author: {
|
||||||
|
username: "Matrix Bridge",
|
||||||
|
public_flags: 0,
|
||||||
|
id: "684280192553844747",
|
||||||
|
global_name: null,
|
||||||
|
discriminator: "5728",
|
||||||
|
clan: null,
|
||||||
|
bot: true,
|
||||||
|
avatar_decoration_data: null,
|
||||||
|
avatar: "48ae3c24f2a6ec5c60c41bdabd904018"
|
||||||
|
},
|
||||||
|
attachments: [],
|
||||||
|
application_id: "684280192553844747"
|
||||||
|
},
|
||||||
|
shard_id: 0
|
||||||
},
|
},
|
||||||
interaction_message: {
|
interaction_message: {
|
||||||
thinking_interaction_without_bot_user: {
|
thinking_interaction_without_bot_user: {
|
||||||
|
|
|
@ -3,6 +3,9 @@ BEGIN TRANSACTION;
|
||||||
INSERT INTO guild_space (guild_id, space_id, privacy_level) VALUES
|
INSERT INTO guild_space (guild_id, space_id, privacy_level) VALUES
|
||||||
('112760669178241024', '!jjWAGMeQdNrVZSSfvz:cadence.moe', 0);
|
('112760669178241024', '!jjWAGMeQdNrVZSSfvz:cadence.moe', 0);
|
||||||
|
|
||||||
|
INSERT INTO guild_active (guild_id, autocreate) VALUES
|
||||||
|
('112760669178241024', 1);
|
||||||
|
|
||||||
INSERT INTO channel_room (channel_id, room_id, name, nick, thread_parent, custom_avatar) VALUES
|
INSERT INTO channel_room (channel_id, room_id, name, nick, thread_parent, custom_avatar) VALUES
|
||||||
('112760669178241024', '!kLRqKKUQXcibIMtOpl:cadence.moe', 'heave', 'main', NULL, NULL),
|
('112760669178241024', '!kLRqKKUQXcibIMtOpl:cadence.moe', 'heave', 'main', NULL, NULL),
|
||||||
('497161350934560778', '!CzvdIdUQXgUjDVKxeU:cadence.moe', 'amanda-spam', NULL, NULL, NULL),
|
('497161350934560778', '!CzvdIdUQXgUjDVKxeU:cadence.moe', 'amanda-spam', NULL, NULL, NULL),
|
||||||
|
@ -61,7 +64,8 @@ INSERT INTO message_channel (message_id, channel_id) VALUES
|
||||||
('1273204543739396116', '687028734322147344'),
|
('1273204543739396116', '687028734322147344'),
|
||||||
('1273743950028607530', '1100319550446252084'),
|
('1273743950028607530', '1100319550446252084'),
|
||||||
('1278002262400176128', '1100319550446252084'),
|
('1278002262400176128', '1100319550446252084'),
|
||||||
('1278001833876525057', '1100319550446252084');
|
('1278001833876525057', '1100319550446252084'),
|
||||||
|
('1191567971970191490', '176333891320283136');
|
||||||
|
|
||||||
INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part, reaction_part, source) VALUES
|
INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part, reaction_part, source) VALUES
|
||||||
('$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg', 'm.room.message', 'm.text', '1126786462646550579', 0, 0, 1),
|
('$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg', 'm.room.message', 'm.text', '1126786462646550579', 0, 0, 1),
|
||||||
|
@ -100,7 +104,8 @@ INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part
|
||||||
('$qmyjr-ISJtnOM5WTWLI0fT7uSlqRLgpyin2d2NCglCU', 'm.room.message', 'm.text', '1273204543739396116', 0, 0, 0),
|
('$qmyjr-ISJtnOM5WTWLI0fT7uSlqRLgpyin2d2NCglCU', 'm.room.message', 'm.text', '1273204543739396116', 0, 0, 0),
|
||||||
('$W1nsDhNIojWrcQOdnOD9RaEvrz2qyZErQoNhPRs1nK4', 'm.room.message', 'm.text', '1273743950028607530', 0, 0, 0),
|
('$W1nsDhNIojWrcQOdnOD9RaEvrz2qyZErQoNhPRs1nK4', 'm.room.message', 'm.text', '1273743950028607530', 0, 0, 0),
|
||||||
('$UTqiL3Zj3FC4qldxRLggN1fhygpKl8sZ7XGY5f9MNbF', 'm.room.message', 'm.text', '1278002262400176128', 0, 0, 1),
|
('$UTqiL3Zj3FC4qldxRLggN1fhygpKl8sZ7XGY5f9MNbF', 'm.room.message', 'm.text', '1278002262400176128', 0, 0, 1),
|
||||||
('$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM', 'm.room.message', 'm.text', '1278001833876525057', 0, 0, 1);
|
('$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM', 'm.room.message', 'm.text', '1278001833876525057', 0, 0, 1),
|
||||||
|
('$tBIT8mO7XTTCgIINyiAIy6M2MSoPAdJenRl_RLyYuaE', 'm.room.message', 'm.text', '1191567971970191490', 0, 0, 1);
|
||||||
|
|
||||||
INSERT INTO file (discord_url, mxc_url) VALUES
|
INSERT INTO file (discord_url, mxc_url) VALUES
|
||||||
('https://cdn.discordapp.com/attachments/497161332244742154/1124628646431297546/image.png', 'mxc://cadence.moe/qXoZktDqNtEGuOCZEADAMvhM'),
|
('https://cdn.discordapp.com/attachments/497161332244742154/1124628646431297546/image.png', 'mxc://cadence.moe/qXoZktDqNtEGuOCZEADAMvhM'),
|
||||||
|
@ -120,7 +125,8 @@ INSERT INTO file (discord_url, mxc_url) VALUES
|
||||||
('https://cdn.discordapp.com/emojis/288858540888686602.png', 'mxc://cadence.moe/mwZaCtRGAQQyOItagDeCocEO'),
|
('https://cdn.discordapp.com/emojis/288858540888686602.png', 'mxc://cadence.moe/mwZaCtRGAQQyOItagDeCocEO'),
|
||||||
('https://cdn.discordapp.com/attachments/112760669178241024/1197621094786531358/Ins_1960637570.mp4', 'mxc://cadence.moe/kMqLycqMURhVpwleWkmASpnU'),
|
('https://cdn.discordapp.com/attachments/112760669178241024/1197621094786531358/Ins_1960637570.mp4', 'mxc://cadence.moe/kMqLycqMURhVpwleWkmASpnU'),
|
||||||
('https://cdn.discordapp.com/attachments/1099031887500034088/1112476845502365786/voice-message.ogg', 'mxc://cadence.moe/MRRPDggXQMYkrUjTpxQbmcxB'),
|
('https://cdn.discordapp.com/attachments/1099031887500034088/1112476845502365786/voice-message.ogg', 'mxc://cadence.moe/MRRPDggXQMYkrUjTpxQbmcxB'),
|
||||||
('https://cdn.discordapp.com/attachments/122155380120748034/1174514575220158545/the.yml', 'mxc://cadence.moe/HnQIYQmmlIKwOQsbFsIGpzPP');
|
('https://cdn.discordapp.com/attachments/122155380120748034/1174514575220158545/the.yml', 'mxc://cadence.moe/HnQIYQmmlIKwOQsbFsIGpzPP'),
|
||||||
|
('https://cdn.discordapp.com/attachments/112760669178241024/1296237494987133070/100km.gif', 'mxc://cadence.moe/qDAotmebTfEIfsAIVCEZptLh');
|
||||||
|
|
||||||
INSERT INTO emoji (emoji_id, name, animated, mxc_url) VALUES
|
INSERT INTO emoji (emoji_id, name, animated, mxc_url) VALUES
|
||||||
('230201364309868544', 'hippo', 0, 'mxc://cadence.moe/qWmbXeRspZRLPcjseyLmeyXC'),
|
('230201364309868544', 'hippo', 0, 'mxc://cadence.moe/qWmbXeRspZRLPcjseyLmeyXC'),
|
||||||
|
|
10
test/test.js
10
test/test.js
|
@ -17,13 +17,13 @@ const passthrough = require("../src/passthrough")
|
||||||
const db = new sqlite(":memory:")
|
const db = new sqlite(":memory:")
|
||||||
|
|
||||||
const {reg} = require("../src/matrix/read-registration")
|
const {reg} = require("../src/matrix/read-registration")
|
||||||
|
reg.ooye.discord_token = "Njg0MjgwMTkyNTUzODQ0NzQ3.Xl3zlw.baby"
|
||||||
reg.ooye.server_origin = "https://matrix.cadence.moe" // so that tests will pass even when hard-coded
|
reg.ooye.server_origin = "https://matrix.cadence.moe" // so that tests will pass even when hard-coded
|
||||||
reg.ooye.server_name = "cadence.moe"
|
reg.ooye.server_name = "cadence.moe"
|
||||||
reg.id = "baby" // don't actually take authenticated actions on the server
|
reg.id = "baby" // don't actually take authenticated actions on the server
|
||||||
reg.as_token = "baby"
|
reg.as_token = "baby"
|
||||||
reg.hs_token = "baby"
|
reg.hs_token = "baby"
|
||||||
reg.ooye.bridge_origin = "https://bridge.example.org"
|
reg.ooye.bridge_origin = "https://bridge.example.org"
|
||||||
reg.ooye.invite = []
|
|
||||||
|
|
||||||
const sync = new HeatSync({watchFS: false})
|
const sync = new HeatSync({watchFS: false})
|
||||||
|
|
||||||
|
@ -35,6 +35,7 @@ const discord = {
|
||||||
id: "684280192553844747"
|
id: "684280192553844747"
|
||||||
},
|
},
|
||||||
channels: new Map([
|
channels: new Map([
|
||||||
|
[data.channel.general.id, data.channel.general],
|
||||||
["497161350934560778", {
|
["497161350934560778", {
|
||||||
guild_id: "497159726455455754"
|
guild_id: "497159726455455754"
|
||||||
}],
|
}],
|
||||||
|
@ -112,12 +113,12 @@ file._actuallyUploadDiscordFileToMxc = function(url, res) { throw new Error(`Not
|
||||||
|
|
||||||
db.exec(fs.readFileSync(join(__dirname, "ooye-test-data.sql"), "utf8"))
|
db.exec(fs.readFileSync(join(__dirname, "ooye-test-data.sql"), "utf8"))
|
||||||
|
|
||||||
|
require("./addbot.test")
|
||||||
require("../src/db/orm.test")
|
require("../src/db/orm.test")
|
||||||
require("../src/discord/utils.test")
|
require("../src/discord/utils.test")
|
||||||
require("../src/matrix/kstate.test")
|
require("../src/matrix/kstate.test")
|
||||||
require("../src/matrix/api.test")
|
require("../src/matrix/api.test")
|
||||||
require("../src/matrix/file.test")
|
require("../src/matrix/file.test")
|
||||||
require("../src/matrix/power.test")
|
|
||||||
require("../src/matrix/read-registration.test")
|
require("../src/matrix/read-registration.test")
|
||||||
require("../src/matrix/txnid.test")
|
require("../src/matrix/txnid.test")
|
||||||
require("../src/d2m/actions/create-room.test")
|
require("../src/d2m/actions/create-room.test")
|
||||||
|
@ -136,4 +137,9 @@ file._actuallyUploadDiscordFileToMxc = function(url, res) { throw new Error(`Not
|
||||||
require("../src/m2d/converters/event-to-message.test")
|
require("../src/m2d/converters/event-to-message.test")
|
||||||
require("../src/m2d/converters/utils.test")
|
require("../src/m2d/converters/utils.test")
|
||||||
require("../src/m2d/converters/emoji-sheet.test")
|
require("../src/m2d/converters/emoji-sheet.test")
|
||||||
|
require("../src/discord/interactions/invite.test")
|
||||||
|
require("../src/discord/interactions/matrix-info.test")
|
||||||
|
require("../src/discord/interactions/permissions.test")
|
||||||
|
require("../src/discord/interactions/privacy.test")
|
||||||
|
require("../src/discord/interactions/reactions.test")
|
||||||
})()
|
})()
|
||||||
|
|
Loading…
Reference in a new issue