Compare commits
No commits in common. "main" and "main" have entirely different histories.
108 changed files with 1717 additions and 5235 deletions
|
|
@ -89,14 +89,15 @@ Whether you read those or not, I'm more than happy to help you 1-on-1 with codin
|
||||||
|
|
||||||
# Dependency justification
|
# Dependency justification
|
||||||
|
|
||||||
Total transitive production dependencies: 144
|
Total transitive production dependencies: 134
|
||||||
|
|
||||||
### <font size="+2">🦕</font>
|
### <font size="+2">🦕</font>
|
||||||
|
|
||||||
* (35) better-sqlite3: SQLite is the best database, and this is the best library for it.
|
* (31) better-sqlite3: SQLite is the best database, and this is the best library for it.
|
||||||
* (29) sharp: Image resizing and compositing. OOYE needs this for the emoji sprite sheets. It has libvips prebuilts for each platform.
|
* (27) @cloudrac3r/pug: Language for dynamic web pages. This is my fork. (I released code that hadn't made it to npm, and removed the heavy pug-filters feature.)
|
||||||
* (26) @cloudrac3r/pug: Language for dynamic web pages. This is my fork. (I released code that hadn't made it to npm, and removed the heavy pug-filters feature.)
|
* (16) stream-mime-type@1: This seems like the best option. Version 1 is used because version 2 is ESM-only.
|
||||||
* (9) h3: Web server. OOYE needs this for the web UI, appservice listener, authmedia proxy, and more.
|
* (9) h3: Web server. OOYE needs this for the appservice listener, authmedia proxy, self-service, and more.
|
||||||
|
* (11) sharp: Image resizing and compositing. OOYE needs this for the emoji sprite sheets.
|
||||||
|
|
||||||
### <font size="-1">🪱</font>
|
### <font size="-1">🪱</font>
|
||||||
|
|
||||||
|
|
@ -107,21 +108,19 @@ Total transitive production dependencies: 144
|
||||||
* (0) @cloudrac3r/in-your-element: This is my Matrix Appservice API library. It depends on h3 and zod, which are already pulled in by OOYE.
|
* (0) @cloudrac3r/in-your-element: This is my Matrix Appservice API library. It depends on h3 and zod, which are already pulled in by OOYE.
|
||||||
* (0) @cloudrac3r/mixin-deep: This is my fork. (It fixes a bug in regular mixin-deep.)
|
* (0) @cloudrac3r/mixin-deep: This is my fork. (It fixes a bug in regular mixin-deep.)
|
||||||
* (0) @cloudrac3r/pngjs: Lottie stickers are converted to bitmaps with the vendored Rlottie WASM build, then the bitmaps are converted to PNG with pngjs.
|
* (0) @cloudrac3r/pngjs: Lottie stickers are converted to bitmaps with the vendored Rlottie WASM build, then the bitmaps are converted to PNG with pngjs.
|
||||||
* (0) @cloudrac3r/stream-type: Determine type of Matrix files that don't specify it in info. Switched from stream-mime-type to this.
|
|
||||||
* (0) @cloudrac3r/turndown: This HTML-to-Markdown converter looked the most suitable. I forked it to change the escaping logic to match the way Discord works.
|
* (0) @cloudrac3r/turndown: This HTML-to-Markdown converter looked the most suitable. I forked it to change the escaping logic to match the way Discord works.
|
||||||
* (3) @stackoverflow/stacks: Stack Overflow design language and icons.
|
* (3) @stackoverflow/stacks: Stack Overflow design language and icons.
|
||||||
* (0) ansi-colors: Helps with interactive prompting for the initial setup, and it's already pulled in by enquirer.
|
* (0) ansi-colors: Helps with interactive prompting for the initial setup, and it's already pulled in by enquirer.
|
||||||
* (1) chunk-text: It does what I want.
|
* (1) chunk-text: It does what I want.
|
||||||
* (0) cloudstorm: Discord gateway library with bring-your-own-caching that I trust.
|
* (0) cloudstorm: Discord gateway library with bring-your-own-caching that I trust.
|
||||||
* (0) denque: Much faster than using arrays as a queue.
|
|
||||||
* (0) discord-api-types: Bitfields needed at runtime and types needed for development.
|
* (0) discord-api-types: Bitfields needed at runtime and types needed for development.
|
||||||
* (0) domino: DOM implementation that's already pulled in by turndown.
|
* (0) domino: DOM implementation that's already pulled in by turndown.
|
||||||
* (2) enquirer: Interactive prompting for the initial setup rather than forcing users to edit YAML non-interactively.
|
* (1) enquirer: Interactive prompting for the initial setup rather than forcing users to edit YAML non-interactively.
|
||||||
* (0) entities: Looks fine. No dependencies.
|
* (0) entities: Looks fine. No dependencies.
|
||||||
* (0) get-relative-path: Looks fine. No dependencies.
|
* (0) get-relative-path: Looks fine. No dependencies.
|
||||||
* (1) heatsync: Module hot-reloader that I trust.
|
* (1) heatsync: Module hot-reloader that I trust.
|
||||||
* (0) lru-cache: For holding unused nonce in memory and letting them be overwritten later if never used.
|
* (0) lru-cache: For holding unused nonce in memory and letting them be overwritten later if never used.
|
||||||
* (1) mime-types: List of mime type mappings. Needed to serve static files.
|
* (0) mime-type: File extension to mime type mapping that's already pulled in by stream-mime-type.
|
||||||
* (0) prettier-bytes: It does what I want and has no dependencies.
|
* (0) prettier-bytes: It does what I want and has no dependencies.
|
||||||
* (0) snowtransfer: Discord API library with bring-your-own-caching that I trust.
|
* (0) snowtransfer: Discord API library with bring-your-own-caching that I trust.
|
||||||
* (0) try-to-catch: Not strictly necessary, but it's already pulled in by supertape, so I may as well.
|
* (0) try-to-catch: Not strictly necessary, but it's already pulled in by supertape, so I may as well.
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ I had no hand in creating these and have not used or tested them whatsoever. I m
|
||||||
|
|
||||||
***The following list is distributed for your information, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.***
|
***The following list is distributed for your information, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.***
|
||||||
|
|
||||||
- by melody: https://git.shork.ch/oci-images/out-of-your-element
|
- by melody: https://git.shork.ch/docker-images/out-of-your-element
|
||||||
- by sim2kid: https://github.com/sim2kid/ooye-docker
|
- by sim2kid: https://github.com/sim2kid/ooye-docker
|
||||||
- by Katharos Technology: https://github.com/katharostech/docker_ooye
|
- by Katharos Technology: https://github.com/katharostech/docker_ooye
|
||||||
- by Emma: https://cgit.rory.gay/nix/OOYE-module.git/tree
|
- by Emma: https://cgit.rory.gay/nix/OOYE-module.git/tree
|
||||||
|
|
|
||||||
|
|
@ -56,16 +56,8 @@ After that, to get into the rooms on your Matrix account, use the invite form on
|
||||||
|
|
||||||
I hope you enjoy Out Of Your Element!
|
I hope you enjoy Out Of Your Element!
|
||||||
|
|
||||||
# Extras
|
----
|
||||||
|
<br><br><br><br><br>
|
||||||
## /plu/ral support
|
|
||||||
|
|
||||||
To support /plu/ral webhook proxying, OOYE needs to call the /plu/ral API authenticated. I can't provide an API key for everyone to share, so you have to make your own. Here's how:
|
|
||||||
|
|
||||||
1. Open the [/plu/ral app on Discord](https://discord.com/discovery/applications/1291501048493768784) and "Add App" -> "Add to My Apps (use everywhere)"
|
|
||||||
2. Go to any text channel on Discord and use the `/api` command from /plu/ral.
|
|
||||||
3. Create a new application named Out Of Your Element. Copy the displayed token.
|
|
||||||
3. Run `node scripts/plu-ral-api.js` and paste the token.
|
|
||||||
|
|
||||||
# Appendix
|
# Appendix
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,7 @@ Discord display names for normal users are limited to 32 characters. For webhook
|
||||||
|
|
||||||
If the bridge software was restarted, it will attempt to catch up on messages missed while it was offline.
|
If the bridge software was restarted, it will attempt to catch up on messages missed while it was offline.
|
||||||
|
|
||||||
From Discord, for any given channel, if fewer than 100 messages were missed in that given channel, the bridge will catch up and transfer all of them to Matrix. If more than 100 messages were missed in that given channel, the bridge will only bridge the latest message. Happenings that aren't messages, such as edits and reactions to prior messages, might be missed during catch-up.
|
From Discord, for any given channel, if fewer than 50 messages were missed in that given channel, the bridge will catch up and transfer all of them to Matrix. If more than 50 messages were missed in that given channel, the bridge will only bridge the latest message. Happenings that aren't messages, such as edits and reactions to prior messages, might be missed during catch-up.
|
||||||
|
|
||||||
From Matrix, all events should be bridged to Discord.
|
From Matrix, all events should be bridged to Discord.
|
||||||
|
|
||||||
|
|
|
||||||
1467
package-lock.json
generated
1467
package-lock.json
generated
File diff suppressed because it is too large
Load diff
25
package.json
25
package.json
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "out-of-your-element",
|
"name": "out-of-your-element",
|
||||||
"version": "3.6.0",
|
"version": "3.4.0",
|
||||||
"description": "A bridge between Matrix and Discord",
|
"description": "A bridge between Matrix and Discord",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|
@ -19,36 +19,35 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@chriscdn/promise-semaphore": "^3.0.1",
|
"@chriscdn/promise-semaphore": "^3.0.1",
|
||||||
"@cloudrac3r/discord-markdown": "^2.7.0",
|
"@cloudrac3r/discord-markdown": "^2.6.10",
|
||||||
"@cloudrac3r/giframe": "^0.4.3",
|
"@cloudrac3r/giframe": "^0.4.3",
|
||||||
"@cloudrac3r/html-template-tag": "^5.0.1",
|
"@cloudrac3r/html-template-tag": "^5.0.1",
|
||||||
"@cloudrac3r/in-your-element": "^1.1.1",
|
"@cloudrac3r/in-your-element": "^1.1.1",
|
||||||
"@cloudrac3r/mixin-deep": "^3.0.1",
|
"@cloudrac3r/mixin-deep": "^3.0.1",
|
||||||
"@cloudrac3r/pngjs": "^7.0.3",
|
"@cloudrac3r/pngjs": "^7.0.3",
|
||||||
"@cloudrac3r/pug": "^4.0.4",
|
"@cloudrac3r/pug": "^4.0.4",
|
||||||
"@cloudrac3r/stream-type": "^1.0.0",
|
"@cloudrac3r/turndown": "^7.1.4",
|
||||||
"@cloudrac3r/turndown": "^7.1.5",
|
|
||||||
"@stackoverflow/stacks": "^2.5.4",
|
"@stackoverflow/stacks": "^2.5.4",
|
||||||
"@stackoverflow/stacks-icons": "^6.0.2",
|
"@stackoverflow/stacks-icons": "^6.0.2",
|
||||||
"ansi-colors": "^4.1.3",
|
"ansi-colors": "^4.1.3",
|
||||||
"better-sqlite3": "^12.2.0",
|
"better-sqlite3": "^12.2.0",
|
||||||
"chunk-text": "^2.0.1",
|
"chunk-text": "^2.0.1",
|
||||||
"cloudstorm": "^0.19.0",
|
"cloudstorm": "^0.15.2",
|
||||||
"denque": "^2.1.0",
|
"discord-api-types": "^0.38.38",
|
||||||
"discord-api-types": "^0.38.52",
|
|
||||||
"domino": "^2.1.6",
|
"domino": "^2.1.6",
|
||||||
"enquirer": "^2.4.1",
|
"enquirer": "^2.4.1",
|
||||||
"entities": "^5.0.0",
|
"entities": "^5.0.0",
|
||||||
"get-relative-path": "^1.0.2",
|
"get-relative-path": "^1.0.2",
|
||||||
"h3": "^1.15.10",
|
"h3": "^1.15.1",
|
||||||
"heatsync": "^2.7.2",
|
"heatsync": "^2.7.2",
|
||||||
"htmx.org": "^2.0.4",
|
"htmx.org": "^2.0.4",
|
||||||
"lru-cache": "^11.0.2",
|
"lru-cache": "^11.0.2",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
"prettier-bytes": "^1.0.4",
|
"prettier-bytes": "^1.0.4",
|
||||||
"sharp": "^0.35.3",
|
"sharp": "^0.34.5",
|
||||||
"snowtransfer": "^0.19.0",
|
"snowtransfer": "^0.17.1",
|
||||||
"try-to-catch": "^4.0.5",
|
"stream-mime-type": "^1.0.2",
|
||||||
|
"try-to-catch": "^3.0.1",
|
||||||
"uqr": "^0.1.2",
|
"uqr": "^0.1.2",
|
||||||
"xxhash-wasm": "^1.0.2",
|
"xxhash-wasm": "^1.0.2",
|
||||||
"zod": "^4.0.17"
|
"zod": "^4.0.17"
|
||||||
|
|
@ -59,9 +58,9 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@cloudrac3r/tap-dot": "^2.0.3",
|
"@cloudrac3r/tap-dot": "^2.0.3",
|
||||||
"@types/node": "^22.17.1",
|
"@types/node": "^22.17.1",
|
||||||
"c8": "^11.0.0",
|
"c8": "^10.1.2",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"supertape": "^13.2.0"
|
"supertape": "^12.0.12"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node --enable-source-maps start.js",
|
"start": "node --enable-source-maps start.js",
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ if (!channelID) {
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const assert = require("assert/strict")
|
||||||
const sqlite = require("better-sqlite3")
|
const sqlite = require("better-sqlite3")
|
||||||
const backfill = new sqlite("scripts/backfill.db")
|
const backfill = new sqlite("scripts/backfill.db")
|
||||||
backfill.prepare("CREATE TABLE IF NOT EXISTS backfill (channel_id TEXT NOT NULL, message_id INTEGER NOT NULL, PRIMARY KEY (channel_id, message_id))").run()
|
backfill.prepare("CREATE TABLE IF NOT EXISTS backfill (channel_id TEXT NOT NULL, message_id INTEGER NOT NULL, PRIMARY KEY (channel_id, message_id))").run()
|
||||||
|
|
@ -37,8 +38,12 @@ passthrough.select = orm.select
|
||||||
|
|
||||||
/** @type {import("../src/d2m/event-dispatcher")}*/
|
/** @type {import("../src/d2m/event-dispatcher")}*/
|
||||||
const eventDispatcher = sync.require("../src/d2m/event-dispatcher")
|
const eventDispatcher = sync.require("../src/d2m/event-dispatcher")
|
||||||
/** @type {import("../src/d2m/actions/create-room")} */
|
|
||||||
const createRoom = sync.require("../src/d2m/actions/create-room")
|
const roomID = passthrough.select("channel_room", "room_id", {channel_id: channelID}).pluck().get()
|
||||||
|
if (!roomID) {
|
||||||
|
console.error("Please choose a channel that's already bridged.")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
;(async () => {
|
;(async () => {
|
||||||
await discord.cloud.connect()
|
await discord.cloud.connect()
|
||||||
|
|
@ -55,8 +60,6 @@ async function event(event) {
|
||||||
if (!channel) return
|
if (!channel) return
|
||||||
const guild_id = event.d.id
|
const guild_id = event.d.id
|
||||||
|
|
||||||
try {
|
|
||||||
await createRoom.syncRoom(channelID)
|
|
||||||
let last = backfill.prepare("SELECT cast(max(message_id) as TEXT) FROM backfill WHERE channel_id = ?").pluck().get(channelID) || "0"
|
let last = backfill.prepare("SELECT cast(max(message_id) as TEXT) FROM backfill WHERE channel_id = ?").pluck().get(channelID) || "0"
|
||||||
console.log(`OK, processing messages for #${channel.name}, continuing from ${last}`)
|
console.log(`OK, processing messages for #${channel.name}, continuing from ${last}`)
|
||||||
|
|
||||||
|
|
@ -76,8 +79,4 @@ async function event(event) {
|
||||||
}
|
}
|
||||||
|
|
||||||
process.exit()
|
process.exit()
|
||||||
} catch (e) {
|
|
||||||
console.error(e)
|
|
||||||
process.exit(1) // won't exit automatically on thrown error due to living discord connection, so manual exit is necessary
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
// @ts-check
|
|
||||||
|
|
||||||
const {reg, writeRegistration} = require("../src/matrix/read-registration")
|
|
||||||
const {prompt} = require("enquirer")
|
|
||||||
|
|
||||||
;(async () => {
|
|
||||||
/** @type {{api_key: string}} */
|
|
||||||
const apiKeyResponse = await prompt({
|
|
||||||
type: "text",
|
|
||||||
name: "api_key",
|
|
||||||
message: "Paste your personal /plu/ral API key"
|
|
||||||
})
|
|
||||||
|
|
||||||
reg.ooye.plu_ral_api_key = apiKeyResponse.api_key
|
|
||||||
writeRegistration(reg)
|
|
||||||
console.log("Saved. This change should be applied instantly.")
|
|
||||||
})()
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
// @ts-check
|
|
||||||
|
|
||||||
const HeatSync = require("heatsync")
|
|
||||||
const sync = new HeatSync({watchFS: false})
|
|
||||||
|
|
||||||
const sqlite = require("better-sqlite3")
|
|
||||||
const db = new sqlite("ooye.db", {fileMustExist: true})
|
|
||||||
|
|
||||||
const passthrough = require("../src/passthrough")
|
|
||||||
Object.assign(passthrough, {db, sync})
|
|
||||||
|
|
||||||
const api = require("../src/matrix/api")
|
|
||||||
const utils = require("../src/matrix/utils")
|
|
||||||
const {reg} = require("../src/matrix/read-registration")
|
|
||||||
|
|
||||||
const rooms = db.prepare("select room_id, name, nick from channel_room").all()
|
|
||||||
|
|
||||||
;(async () => {
|
|
||||||
// Search for members starting with @_ooye_ and kick them if they are not in sim_member cache
|
|
||||||
for (const room of rooms) {
|
|
||||||
try {
|
|
||||||
const members = await api.getJoinedMembers(room.room_id)
|
|
||||||
for (const mxid of Object.keys(members.joined)) {
|
|
||||||
if (!mxid.startsWith("@" + reg.sender_localpart) && utils.eventSenderIsFromDiscord(mxid) && !db.prepare("select mxid from sim_member where mxid = ? and room_id = ?").get(mxid, room.room_id)) {
|
|
||||||
await api.leaveRoom(room.room_id, mxid)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (e.message.includes("Appservice not in room")) {
|
|
||||||
// ok
|
|
||||||
} else {
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
|
|
@ -13,5 +13,5 @@ const {prompt} = require("enquirer")
|
||||||
|
|
||||||
reg.ooye.web_password = passwordResponse.web_password
|
reg.ooye.web_password = passwordResponse.web_password
|
||||||
writeRegistration(reg)
|
writeRegistration(reg)
|
||||||
console.log("Saved. This change should be applied instantly.")
|
console.log("Saved. Restart Out Of Your Element to apply this change.")
|
||||||
})()
|
})()
|
||||||
|
|
|
||||||
|
|
@ -359,12 +359,12 @@ function defineEchoHandler() {
|
||||||
console.log("✅ Emojis are ready...")
|
console.log("✅ Emojis are ready...")
|
||||||
|
|
||||||
// set profile data on discord...
|
// set profile data on discord...
|
||||||
await discord.snow.user.editSelf({avatar: "data:image/png;base64," + avatarBuffer.toString("base64")})
|
await discord.snow.user.updateSelf({avatar: "data:image/png;base64," + avatarBuffer.toString("base64")})
|
||||||
console.log("✅ Discord profile updated...")
|
console.log("✅ Discord profile updated...")
|
||||||
|
|
||||||
// set profile data on homeserver...
|
// set profile data on homeserver...
|
||||||
console.log("⏩ Updating Matrix profile... (If you've joined lots of rooms, this is slow. Please allow at least 30 seconds.)")
|
console.log("⏩ Updating Matrix profile... (If you've joined lots of rooms, this is slow. Please allow at least 30 seconds.)")
|
||||||
await api.profileSetDisplayname(mxid, "Out Of Your Element (bot)")
|
await api.profileSetDisplayname(mxid, "Out Of Your Element")
|
||||||
await api.profileSetAvatarUrl(mxid, avatarUrl)
|
await api.profileSetAvatarUrl(mxid, avatarUrl)
|
||||||
console.log("✅ Matrix profile updated...")
|
console.log("✅ Matrix profile updated...")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -122,7 +122,7 @@ async function channelToKState(channel, guild, di) {
|
||||||
join_rules = {join_rule: PRIVACY_ENUMS.ROOM_JOIN_RULES[privacyLevel]}
|
join_rules = {join_rule: PRIVACY_ENUMS.ROOM_JOIN_RULES[privacyLevel]}
|
||||||
}
|
}
|
||||||
|
|
||||||
const everyonePermissions = dUtils.getDefaultPermissions(guild, channel.permission_overwrites)
|
const everyonePermissions = dUtils.getPermissions(guild.id, [], guild.roles, undefined, channel.permission_overwrites)
|
||||||
const everyoneCanSend = dUtils.hasPermission(everyonePermissions, DiscordTypes.PermissionFlagsBits.SendMessages)
|
const everyoneCanSend = dUtils.hasPermission(everyonePermissions, DiscordTypes.PermissionFlagsBits.SendMessages)
|
||||||
const everyoneCanMentionEveryone = dUtils.hasPermission(everyonePermissions, DiscordTypes.PermissionFlagsBits.MentionEveryone)
|
const everyoneCanMentionEveryone = dUtils.hasPermission(everyonePermissions, DiscordTypes.PermissionFlagsBits.MentionEveryone)
|
||||||
|
|
||||||
|
|
@ -193,16 +193,6 @@ async function channelToKState(channel, guild, di) {
|
||||||
// Don't overwrite room topic if the topic has been customised
|
// Don't overwrite room topic if the topic has been customised
|
||||||
if (hasCustomTopic) delete channelKState["m.room.topic/"]
|
if (hasCustomTopic) delete channelKState["m.room.topic/"]
|
||||||
|
|
||||||
// Make voice channels be a Matrix voice room (MSC3417)
|
|
||||||
if (channel.type === DiscordTypes.ChannelType.GuildVoice) {
|
|
||||||
creationContent.type = "org.matrix.msc3417.call"
|
|
||||||
channelKState["org.matrix.msc3401.call/"] = {
|
|
||||||
"m.intent": "m.room",
|
|
||||||
"m.type": "m.voice",
|
|
||||||
"m.name": customName || channel.name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't add a space parent if it's self service
|
// Don't add a space parent if it's self service
|
||||||
// (The person setting up self-service has already put it in their preferred space to be able to get this far.)
|
// (The person setting up self-service has already put it in their preferred space to be able to get this far.)
|
||||||
const autocreate = select("guild_active", "autocreate", {guild_id: guild.id}).pluck().get()
|
const autocreate = select("guild_active", "autocreate", {guild_id: guild.id}).pluck().get()
|
||||||
|
|
@ -266,7 +256,7 @@ async function createRoom(channel, guild, spaceID, kstate, privacyLevel) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handling power levels separately. The spec doesn't specify what happens, Dendrite differs,
|
* Handling power levels separately. The spec doesn't specify what happens, Dendrite differs,
|
||||||
* and Synapse does a very poorly thought out *shallow merge* of what I provide on top of what it creates.
|
* and Synapse does an absolutely insane *shallow merge* of what I provide on top of what it creates.
|
||||||
* We don't want the `events` key to be overridden completely.
|
* We don't want the `events` key to be overridden completely.
|
||||||
* https://github.com/matrix-org/synapse/blob/develop/synapse/handlers/room.py#L1170-L1210
|
* https://github.com/matrix-org/synapse/blob/develop/synapse/handlers/room.py#L1170-L1210
|
||||||
* https://github.com/matrix-org/matrix-spec/issues/492
|
* https://github.com/matrix-org/matrix-spec/issues/492
|
||||||
|
|
@ -452,9 +442,8 @@ function syncRoom(channelID) {
|
||||||
/**
|
/**
|
||||||
* @param {{id: string, topic?: string?}} channel channel-ish (just needs an id, topic is optional)
|
* @param {{id: string, topic?: string?}} channel channel-ish (just needs an id, topic is optional)
|
||||||
* @param {string} guildID
|
* @param {string} guildID
|
||||||
* @param {string} messageBeforeLeave
|
|
||||||
*/
|
*/
|
||||||
async function unbridgeChannel(channel, guildID, messageBeforeLeave = "This room was removed from the bridge.") {
|
async function unbridgeChannel(channel, guildID) {
|
||||||
const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get()
|
const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get()
|
||||||
assert.ok(roomID)
|
assert.ok(roomID)
|
||||||
const row = from("guild_space").join("guild_active", "guild_id").select("space_id", "autocreate").where({guild_id: guildID}).get()
|
const row = from("guild_space").join("guild_active", "guild_id").select("space_id", "autocreate").where({guild_id: guildID}).get()
|
||||||
|
|
@ -504,7 +493,7 @@ async function unbridgeChannel(channel, guildID, messageBeforeLeave = "This room
|
||||||
// send a notification in the room
|
// send a notification in the room
|
||||||
await api.sendEvent(roomID, "m.room.message", {
|
await api.sendEvent(roomID, "m.room.message", {
|
||||||
msgtype: "m.notice",
|
msgtype: "m.notice",
|
||||||
body: `⚠️ ${messageBeforeLeave}`
|
body: "⚠️ This room was removed from the bridge."
|
||||||
})
|
})
|
||||||
|
|
||||||
// if it is an easy mode room, clean up the room from the managed space and make it clear it's not being bridged
|
// if it is an easy mode room, clean up the room from the managed space and make it clear it's not being bridged
|
||||||
|
|
|
||||||
|
|
@ -190,17 +190,6 @@ test("channel2room: read-only discord channel", async t => {
|
||||||
t.equal(api.getCalled(), 2)
|
t.equal(api.getCalled(), 2)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("channel2room: voice channel", async t => {
|
|
||||||
const api = mockAPI(t)
|
|
||||||
const state = kstateStripConditionals(await channelToKState(testData.channel.voice, testData.guild.general, {api}).then(x => x.channelKState))
|
|
||||||
t.equal(state["m.room.create/"].type, "org.matrix.msc3417.call")
|
|
||||||
t.deepEqual(state["org.matrix.msc3401.call/"], {
|
|
||||||
"m.intent": "m.room",
|
|
||||||
"m.name": "🍞丨[8user] Piece",
|
|
||||||
"m.type": "m.voice"
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("convertNameAndTopic: custom name and topic", t => {
|
test("convertNameAndTopic: custom name and topic", t => {
|
||||||
t.deepEqual(
|
t.deepEqual(
|
||||||
_convertNameAndTopic({id: "123", name: "the-twilight-zone", topic: "Spooky stuff here. :ghost:", type: 0}, {id: "456"}, "hauntings"),
|
_convertNameAndTopic({id: "123", name: "the-twilight-zone", topic: "Spooky stuff here. :ghost:", type: 0}, {id: "456"}, "hauntings"),
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,6 @@ const {reg} = require("../../matrix/read-registration")
|
||||||
|
|
||||||
const passthrough = require("../../passthrough")
|
const passthrough = require("../../passthrough")
|
||||||
const {discord, sync, db, select} = passthrough
|
const {discord, sync, db, select} = passthrough
|
||||||
/** @type {import("../../matrix/mreq")} */
|
|
||||||
const mreq = sync.require("../../matrix/mreq")
|
|
||||||
/** @type {import("../../matrix/api")} */
|
/** @type {import("../../matrix/api")} */
|
||||||
const api = sync.require("../../matrix/api")
|
const api = sync.require("../../matrix/api")
|
||||||
/** @type {import("../../matrix/file")} */
|
/** @type {import("../../matrix/file")} */
|
||||||
|
|
@ -239,12 +237,8 @@ async function syncSpaceExpressions(data, checkBeforeSync) {
|
||||||
try {
|
try {
|
||||||
existing = await api.getStateEvent(spaceID, "im.ponies.room_emotes", eventKey)
|
existing = await api.getStateEvent(spaceID, "im.ponies.room_emotes", eventKey)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof mreq.MatrixServerError && e.httpStatus < 500) {
|
|
||||||
// State event not found. This space doesn't have any existing emojis. We create a dummy empty event for comparison's sake.
|
// State event not found. This space doesn't have any existing emojis. We create a dummy empty event for comparison's sake.
|
||||||
existing = fn([], guild)
|
existing = fn([], guild)
|
||||||
} else {
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (isDeepStrictEqual(existing, content)) return
|
if (isDeepStrictEqual(existing, content)) return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,24 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
|
const assert = require("assert").strict
|
||||||
|
|
||||||
const passthrough = require("../../passthrough")
|
const passthrough = require("../../passthrough")
|
||||||
const {sync, db, select, from} = passthrough
|
const {sync, db, select, from} = passthrough
|
||||||
const {reg} = require("../../matrix/read-registration")
|
|
||||||
/** @type {import("../converters/edit-to-changes")} */
|
/** @type {import("../converters/edit-to-changes")} */
|
||||||
const editToChanges = sync.require("../converters/edit-to-changes")
|
const editToChanges = sync.require("../converters/edit-to-changes")
|
||||||
/** @type {import("./speedbump")} */
|
/** @type {import("./register-pk-user")} */
|
||||||
const speedbump = sync.require("./speedbump")
|
const registerPkUser = sync.require("./register-pk-user")
|
||||||
/** @type {import("../../matrix/api")} */
|
/** @type {import("../../matrix/api")} */
|
||||||
const api = sync.require("../../matrix/api")
|
const api = sync.require("../../matrix/api")
|
||||||
/** @type {import("../../matrix/mreq")} */
|
/** @type {import("../../matrix/mreq")} */
|
||||||
const mreq = sync.require("../../matrix/mreq")
|
const mreq = sync.require("../../matrix/mreq")
|
||||||
/** @type {import("../../discord/utils")} */
|
|
||||||
const dUtils = sync.require("../../discord/utils")
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {import("discord-api-types/v10").GatewayMessageCreateDispatchData} message
|
* @param {import("discord-api-types/v10").GatewayMessageCreateDispatchData} message
|
||||||
* @param {import("discord-api-types/v10").APIGuild} guild
|
* @param {import("discord-api-types/v10").APIGuild} guild
|
||||||
|
* @param {{speedbump_id: string, speedbump_webhook_id: string} | null} row data about the webhook which is proxying messages in this channel
|
||||||
*/
|
*/
|
||||||
async function editMessage(message, guild) {
|
async function editMessage(message, guild, row) {
|
||||||
const historicalRoomOfMessage = from("message_room").join("historical_channel_room", "historical_room_index").where({message_id: message.id}).select("room_id").get()
|
const historicalRoomOfMessage = from("message_room").join("historical_channel_room", "historical_room_index").where({message_id: message.id}).select("room_id").get()
|
||||||
const currentRoom = from("channel_room").join("historical_channel_room", "room_id").where({channel_id: message.channel_id}).select("room_id", "historical_room_index").get()
|
const currentRoom = from("channel_room").join("historical_channel_room", "room_id").where({channel_id: message.channel_id}).select("room_id", "historical_room_index").get()
|
||||||
if (!currentRoom) return
|
if (!currentRoom) return
|
||||||
|
|
@ -27,9 +27,11 @@ async function editMessage(message, guild) {
|
||||||
|
|
||||||
let {roomID, eventsToRedact, eventsToReplace, eventsToSend, senderMxid, promotions} = await editToChanges.editToChanges(message, guild, api)
|
let {roomID, eventsToRedact, eventsToReplace, eventsToSend, senderMxid, promotions} = await editToChanges.editToChanges(message, guild, api)
|
||||||
|
|
||||||
// Sync proxy user profile (if sent by proxy)
|
if (row && row.speedbump_webhook_id === message.webhook_id) {
|
||||||
if (dUtils.isWebhookMessage(message)) {
|
// Handle the PluralKit public instance
|
||||||
senderMxid = await speedbump.getWebhookSenderId(message, guild.id, roomID)
|
if (row.speedbump_id === "466378653216014359") {
|
||||||
|
senderMxid = await registerPkUser.syncUser(message.id, message.author, roomID, true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Replace all the things.
|
// 1. Replace all the things.
|
||||||
|
|
|
||||||
|
|
@ -34,10 +34,7 @@ async function emojisToState(emojis, guild) {
|
||||||
if (e.data?.errcode === "M_TOO_LARGE") { // Very unlikely to happen. Only possible for 3x-series emojis uploaded shortly after animated emojis were introduced, when there was no 256 KB size limit.
|
if (e.data?.errcode === "M_TOO_LARGE") { // Very unlikely to happen. Only possible for 3x-series emojis uploaded shortly after animated emojis were introduced, when there was no 256 KB size limit.
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
e["emoji"] = {
|
console.error(`Trying to handle emoji ${emoji.name} (${emoji.id}), but...`)
|
||||||
name: emoji.name,
|
|
||||||
id: emoji.id
|
|
||||||
}
|
|
||||||
throw e
|
throw e
|
||||||
})
|
})
|
||||||
))
|
))
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const assert = require("assert").strict
|
const assert = require("assert")
|
||||||
const {reg} = require("../../matrix/read-registration")
|
const {reg} = require("../../matrix/read-registration")
|
||||||
const Ty = require("../../types")
|
const Ty = require("../../types")
|
||||||
|
|
||||||
|
|
@ -132,10 +132,10 @@ async function syncUser(messageID, author, roomID, shouldActuallySync) {
|
||||||
try {
|
try {
|
||||||
// API lookup
|
// API lookup
|
||||||
var pkMessage = await fetchMessage(messageID)
|
var pkMessage = await fetchMessage(messageID)
|
||||||
db.prepare("REPLACE INTO sim_proxy (user_id, proxy_owner_id, displayname, proxy_app) VALUES (?, ?, ?, 0)").run(pkMessage.member.uuid, pkMessage.sender, author.username)
|
db.prepare("REPLACE INTO sim_proxy (user_id, proxy_owner_id, displayname) VALUES (?, ?, ?)").run(pkMessage.member.uuid, pkMessage.sender, author.username)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Fall back to offline cache
|
// Fall back to offline cache
|
||||||
const senderMxid = from("sim_proxy").join("sim", "user_id").join("sim_member", "mxid").where({displayname: author.username, room_id: roomID, proxy_app: 0}).pluck("mxid").get()
|
const senderMxid = from("sim_proxy").join("sim", "user_id").join("sim_member", "mxid").where({displayname: author.username, room_id: roomID}).pluck("mxid").get()
|
||||||
if (!senderMxid) throw e
|
if (!senderMxid) throw e
|
||||||
return senderMxid
|
return senderMxid
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,170 +0,0 @@
|
||||||
// @ts-check
|
|
||||||
|
|
||||||
const assert = require("assert").strict
|
|
||||||
const {reg} = require("../../matrix/read-registration")
|
|
||||||
const Ty = require("../../types")
|
|
||||||
|
|
||||||
const passthrough = require("../../passthrough")
|
|
||||||
const {sync, db, select, from} = passthrough
|
|
||||||
/** @type {import("../../matrix/api")} */
|
|
||||||
const api = sync.require("../../matrix/api")
|
|
||||||
/** @type {import("../../matrix/file")} */
|
|
||||||
const file = sync.require("../../matrix/file")
|
|
||||||
/** @type {import("./register-user")} */
|
|
||||||
const registerUser = sync.require("./register-user")
|
|
||||||
|
|
||||||
/** @returns {Promise<Ty.PluRalWebhookMessage>} */
|
|
||||||
async function fetchMessage(channelID, messageID) {
|
|
||||||
assert(reg.ooye.plu_ral_api_key)
|
|
||||||
try {
|
|
||||||
var res = await fetch(`https://api.plural.gg/messages/${channelID}/${messageID}?member=true`, {
|
|
||||||
headers: {
|
|
||||||
Authorization: reg.ooye.plu_ral_api_key
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} catch (networkError) {
|
|
||||||
// Network issue, raise a more readable message
|
|
||||||
throw new Error(`Failed to connect to /plu/ral API: ${networkError.toString()}`)
|
|
||||||
}
|
|
||||||
if (!res.ok) throw new Error(`/plu/ral API returned an error: ${await res.text()}`)
|
|
||||||
/** @type {any} */
|
|
||||||
const root = await res.json()
|
|
||||||
if (!root.member) throw new Error(`/plu/ral API didn't return member data: ${JSON.stringify(root)}`)
|
|
||||||
return root
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Using the same sim names and fake user IDs for /plu/ral members, since unlike PluralKit they don't have a short and a long ID.
|
|
||||||
* @param {Ty.PluRalWebhookMessage} pluRalMessage
|
|
||||||
*/
|
|
||||||
function getSimName(pluRalMessage) {
|
|
||||||
return `_pl_${pluRalMessage.member_id}`
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A sim is an account that is being simulated by the bridge to copy events from the other side.
|
|
||||||
* @param {Ty.PluRalWebhookMessage} pluRalMessage
|
|
||||||
* @returns mxid
|
|
||||||
*/
|
|
||||||
async function createSim(pluRalMessage) {
|
|
||||||
// Choose sim name
|
|
||||||
const simName = getSimName(pluRalMessage)
|
|
||||||
const localpart = reg.ooye.namespace_prefix + simName
|
|
||||||
const mxid = `@${localpart}:${reg.ooye.server_name}`
|
|
||||||
|
|
||||||
// Save chosen name in the database forever
|
|
||||||
db.prepare("INSERT INTO sim (user_id, username, sim_name, mxid) VALUES (?, ?, ?, ?)").run(simName, simName, simName, mxid)
|
|
||||||
|
|
||||||
// Register matrix user with that name
|
|
||||||
try {
|
|
||||||
await api.register(localpart)
|
|
||||||
} catch (e) {
|
|
||||||
// If user creation fails, manually undo the database change. Still isn't perfect, but should help.
|
|
||||||
// (I would prefer a transaction, but it's not safe to leave transactions open across event loop ticks.)
|
|
||||||
db.prepare("DELETE FROM sim WHERE user_id = ?").run(simName)
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
return mxid
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensure a sim is registered for the user.
|
|
||||||
* If there is already a sim, use that one. If there isn't one yet, register a new sim.
|
|
||||||
* @param {Ty.PluRalWebhookMessage} pluRalMessage
|
|
||||||
* @returns {Promise<string>} mxid
|
|
||||||
*/
|
|
||||||
async function ensureSim(pluRalMessage) {
|
|
||||||
let mxid = null
|
|
||||||
const existing = select("sim", "mxid", {user_id: getSimName(pluRalMessage)}).pluck().get()
|
|
||||||
if (existing) {
|
|
||||||
mxid = existing
|
|
||||||
} else {
|
|
||||||
mxid = await createSim(pluRalMessage)
|
|
||||||
}
|
|
||||||
return mxid
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensure a sim is registered for the user and is joined to the room.
|
|
||||||
* @param {Ty.PluRalWebhookMessage} pluRalMessage
|
|
||||||
* @param {string} roomID
|
|
||||||
* @returns {Promise<string>} mxid
|
|
||||||
*/
|
|
||||||
async function ensureSimJoined(pluRalMessage, roomID) {
|
|
||||||
// Ensure room ID is really an ID, not an alias
|
|
||||||
assert.ok(roomID[0] === "!")
|
|
||||||
|
|
||||||
// Ensure user
|
|
||||||
const mxid = await ensureSim(pluRalMessage)
|
|
||||||
|
|
||||||
// Ensure joined
|
|
||||||
const existing = select("sim_member", "mxid", {room_id: roomID, mxid}).pluck().get()
|
|
||||||
if (!existing) {
|
|
||||||
await api.inviteToRoom(roomID, mxid)
|
|
||||||
await api.joinRoom(roomID, mxid)
|
|
||||||
db.prepare("INSERT OR IGNORE INTO sim_member (room_id, mxid) VALUES (?, ?)").run(roomID, mxid)
|
|
||||||
}
|
|
||||||
return mxid
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate profile data based on webhook displayname and configured avatar.
|
|
||||||
* @param {Ty.PluRalWebhookMessage} pluRalMessage
|
|
||||||
* @param {Ty.WebhookAuthor} author
|
|
||||||
*/
|
|
||||||
async function memberToStateContent(pluRalMessage, author) {
|
|
||||||
// We prefer to use the member's avatar URL data since the image upload can be cached across channels,
|
|
||||||
// unlike the userAvatar URL which is unique per channel, due to the webhook ID being in the URL.
|
|
||||||
const avatar = pluRalMessage.member.avatar_url || file.userAvatar(author)
|
|
||||||
|
|
||||||
const content = {
|
|
||||||
displayname: author.username,
|
|
||||||
membership: "join",
|
|
||||||
"moe.cadence.ooye.plu_ral_member": pluRalMessage.member
|
|
||||||
}
|
|
||||||
if (avatar) content.avatar_url = await file.uploadDiscordFileToMxc(avatar)
|
|
||||||
|
|
||||||
return content
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sync profile data for a sim user. This function follows the following process:
|
|
||||||
* 1. Look up data about proxy user from API
|
|
||||||
* 2. If this fails, try to use previously cached data (won't sync)
|
|
||||||
* 3. Create and join the sim to the room if needed
|
|
||||||
* 4. Make an object of what the new room member state content would be, including uploading the profile picture if it hasn't been done before
|
|
||||||
* 5. Compare against the previously known state content, which is helpfully stored in the database
|
|
||||||
* 6. If the state content has changed, send it to Matrix and update it in the database for next time
|
|
||||||
* @param {string} channelID to call API with
|
|
||||||
* @param {string} messageID to call API with
|
|
||||||
* @param {Ty.WebhookAuthor} author for profile data
|
|
||||||
* @param {string} roomID room to join member to
|
|
||||||
* @param {boolean} shouldActuallySync whether to actually sync updated user data or just ensure it's joined
|
|
||||||
* @returns {Promise<string>} mxid of the updated sim
|
|
||||||
*/
|
|
||||||
async function syncUser(channelID, messageID, author, roomID, shouldActuallySync) {
|
|
||||||
try {
|
|
||||||
// API lookup
|
|
||||||
var pluRalMessage = await fetchMessage(channelID, messageID)
|
|
||||||
const simName = getSimName(pluRalMessage)
|
|
||||||
db.prepare("REPLACE INTO sim_proxy (user_id, proxy_owner_id, displayname, proxy_app) VALUES (?, ?, ?, 1)").run(simName, pluRalMessage.author_id, author.username)
|
|
||||||
} catch (e) {
|
|
||||||
// Fall back to offline cache
|
|
||||||
const senderMxid = from("sim_proxy").join("sim", "user_id").join("sim_member", "mxid").where({displayname: author.username, room_id: roomID, proxy_app: 1}).pluck("mxid").get()
|
|
||||||
if (!senderMxid) throw e
|
|
||||||
return senderMxid
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create and join the sim to the room if needed
|
|
||||||
const mxid = await ensureSimJoined(pluRalMessage, roomID)
|
|
||||||
|
|
||||||
if (shouldActuallySync) {
|
|
||||||
// Build current profile data and sync if the hash has changed
|
|
||||||
const content = await memberToStateContent(pluRalMessage, author)
|
|
||||||
await registerUser._sendSyncUser(roomID, mxid, content, null)
|
|
||||||
}
|
|
||||||
|
|
||||||
return mxid
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports.syncUser = syncUser
|
|
||||||
|
|
@ -154,7 +154,7 @@ function memberToPowerLevel(user, member, guild, channel) {
|
||||||
if (!member) return 0
|
if (!member) return 0
|
||||||
|
|
||||||
const permissions = dUtils.getPermissions(guild.id, member.roles, guild.roles, user.id, channel.permission_overwrites)
|
const permissions = dUtils.getPermissions(guild.id, member.roles, guild.roles, user.id, channel.permission_overwrites)
|
||||||
const everyonePermissions = dUtils.getDefaultPermissions(guild, channel.permission_overwrites)
|
const everyonePermissions = dUtils.getPermissions(guild.id, [], guild.roles, undefined, channel.permission_overwrites)
|
||||||
/*
|
/*
|
||||||
* PL 100 = Administrator = People who can brick the room. RATIONALE:
|
* PL 100 = Administrator = People who can brick the room. RATIONALE:
|
||||||
* - Administrator.
|
* - Administrator.
|
||||||
|
|
@ -206,16 +206,14 @@ function _hashProfileContent(content, powerLevel) {
|
||||||
* 3. Calculate the power level the user should get based on their Discord permissions
|
* 3. Calculate the power level the user should get based on their Discord permissions
|
||||||
* 4. Compare against the previously known state content, which is helpfully stored in the database
|
* 4. Compare against the previously known state content, which is helpfully stored in the database
|
||||||
* 5. If the state content or power level have changed, send them to Matrix and update them in the database for next time
|
* 5. If the state content or power level have changed, send them to Matrix and update them in the database for next time
|
||||||
* 6. If the sim is for a user-installed app, check which user it was added by
|
|
||||||
* @param {DiscordTypes.APIUser} user
|
* @param {DiscordTypes.APIUser} user
|
||||||
* @param {Omit<DiscordTypes.APIGuildMember, "user"> | undefined} member
|
* @param {Omit<DiscordTypes.APIGuildMember, "user"> | undefined} member
|
||||||
* @param {DiscordTypes.APIGuildChannel} channel
|
* @param {DiscordTypes.APIGuildChannel} channel
|
||||||
* @param {DiscordTypes.APIGuild} guild
|
* @param {DiscordTypes.APIGuild} guild
|
||||||
* @param {string} roomID
|
* @param {string} roomID
|
||||||
* @param {DiscordTypes.APIMessageInteractionMetadata} [interactionMetadata]
|
|
||||||
* @returns {Promise<string>} mxid of the updated sim
|
* @returns {Promise<string>} mxid of the updated sim
|
||||||
*/
|
*/
|
||||||
async function syncUser(user, member, channel, guild, roomID, interactionMetadata) {
|
async function syncUser(user, member, channel, guild, roomID) {
|
||||||
const mxid = await ensureSimJoined(user, roomID)
|
const mxid = await ensureSimJoined(user, roomID)
|
||||||
const content = await memberToStateContent(user, member, guild.id)
|
const content = await memberToStateContent(user, member, guild.id)
|
||||||
const powerLevel = memberToPowerLevel(user, member, guild, channel)
|
const powerLevel = memberToPowerLevel(user, member, guild, channel)
|
||||||
|
|
@ -224,12 +222,6 @@ async function syncUser(user, member, channel, guild, roomID, interactionMetadat
|
||||||
allowOverwrite: !!member,
|
allowOverwrite: !!member,
|
||||||
globalProfile: await userToGlobalProfile(user)
|
globalProfile: await userToGlobalProfile(user)
|
||||||
})
|
})
|
||||||
|
|
||||||
const appInstalledByUser = user.bot && interactionMetadata?.authorizing_integration_owners?.[DiscordTypes.ApplicationIntegrationType.UserInstall]
|
|
||||||
if (appInstalledByUser) {
|
|
||||||
db.prepare("INSERT OR IGNORE INTO app_user_install (app_bot_id, user_id, guild_id) VALUES (?, ?, ?)").run(user.id, appInstalledByUser, guild.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
return mxid
|
return mxid
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
// @ts-check
|
|
||||||
|
|
||||||
const passthrough = require("../../passthrough")
|
|
||||||
const {sync, db, select, from} = passthrough
|
|
||||||
/** @type {import("../../matrix/api")} */
|
|
||||||
const api = sync.require("../../matrix/api")
|
|
||||||
/** @type {import("../converters/remove-member-mxids")} */
|
|
||||||
const removeMemberMxids = sync.require("../converters/remove-member-mxids")
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} userID discord user ID that left
|
|
||||||
* @param {string} guildID discord guild ID that they left
|
|
||||||
*/
|
|
||||||
async function removeMember(userID, guildID) {
|
|
||||||
const {userAppDeletions, membership} = removeMemberMxids.removeMemberMxids(userID, guildID)
|
|
||||||
db.transaction(() => {
|
|
||||||
for (const d of userAppDeletions) {
|
|
||||||
db.prepare("DELETE FROM app_user_install WHERE guild_id = ? and user_id = ?").run(guildID, d)
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
for (const m of membership) {
|
|
||||||
try {
|
|
||||||
await api.leaveRoom(m.room_id, m.mxid)
|
|
||||||
} catch (e) {
|
|
||||||
if (String(e).includes("not in room")) {
|
|
||||||
// no further action needed
|
|
||||||
} else {
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Update cache to say that the member isn't in the room any more
|
|
||||||
// You'd think this would happen automatically when the leave event arrives at Matrix's event dispatcher, but that isn't 100% reliable.
|
|
||||||
db.prepare("DELETE FROM sim_member WHERE room_id = ? AND mxid = ?").run(m.room_id, m.mxid)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports.removeMember = removeMember
|
|
||||||
|
|
@ -2,15 +2,7 @@
|
||||||
|
|
||||||
const {EventEmitter} = require("events")
|
const {EventEmitter} = require("events")
|
||||||
const passthrough = require("../../passthrough")
|
const passthrough = require("../../passthrough")
|
||||||
const {select, sync, from} = passthrough
|
const {select} = passthrough
|
||||||
/** @type {import("../../matrix/utils")} */
|
|
||||||
const utils = sync.require("../../matrix/utils")
|
|
||||||
|
|
||||||
/*
|
|
||||||
Due to Eventual Consistency(TM) an update/delete may arrive before the original message arrives
|
|
||||||
(or before the it has finished being bridged to an event).
|
|
||||||
In this case, wait until the original message has finished bridging, then retrigger the passed function.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const DEBUG_RETRIGGER = false
|
const DEBUG_RETRIGGER = false
|
||||||
|
|
||||||
|
|
@ -20,140 +12,81 @@ function debugRetrigger(message) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const storage = new class {
|
const paused = new Set()
|
||||||
/** @private @type {Set<string>} */
|
const emitter = new EventEmitter()
|
||||||
paused = new Set()
|
|
||||||
/** @private @type {Map<string, ((found: Boolean) => any)[]>} id -> list of resolvers */
|
|
||||||
resolves = new Map()
|
|
||||||
/** @private @type {Map<string, ReturnType<setTimeout>>} id -> timer */
|
|
||||||
timers = new Map()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The purpose of storage is to store `resolve` and call it at a later time.
|
|
||||||
* @param {string} id
|
|
||||||
* @param {(found: Boolean) => any} resolve
|
|
||||||
*/
|
|
||||||
store(id, resolve) {
|
|
||||||
debugRetrigger(`[retrigger] STORE id = ${id}`)
|
|
||||||
this.resolves.set(id, (this.resolves.get(id) || []).concat(resolve)) // add to list in map value
|
|
||||||
if (!this.timers.has(id)) {
|
|
||||||
debugRetrigger(`[retrigger] SET TIMER id = ${id}`)
|
|
||||||
this.timers.set(id, setTimeout(() => this.resolve(id, false), 60 * 1000).unref()) // 1 minute
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @param {string} id */
|
|
||||||
isNotPaused(id) {
|
|
||||||
return !storage.paused.has(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @param {string} id */
|
|
||||||
pause(id) {
|
|
||||||
debugRetrigger(`[retrigger] PAUSE id = ${id}`)
|
|
||||||
this.paused.add(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Go through `resolves` storage and resolve them all. (Also resets timer/paused.)
|
|
||||||
* @param {string} id
|
|
||||||
* @param {boolean} value
|
|
||||||
*/
|
|
||||||
resolve(id, value) {
|
|
||||||
if (this.paused.has(id)) {
|
|
||||||
debugRetrigger(`[retrigger] RESUME id = ${id}`)
|
|
||||||
this.paused.delete(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.resolves.has(id)) {
|
|
||||||
debugRetrigger(`[retrigger] RESOLVE ${value} id = ${id}`)
|
|
||||||
const fns = this.resolves.get(id) || []
|
|
||||||
this.resolves.delete(id)
|
|
||||||
for (const fn of fns) {
|
|
||||||
fn(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.timers.has(id)) {
|
|
||||||
clearTimeout(this.timers.get(id))
|
|
||||||
this.timers.delete(id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} id
|
* Due to Eventual Consistency(TM) an update/delete may arrive before the original message arrives
|
||||||
* @param {(found: Boolean) => any} resolve
|
* (or before the it has finished being bridged to an event).
|
||||||
* @param {boolean} existsInDatabase
|
* In this case, wait until the original message has finished bridging, then retrigger the passed function.
|
||||||
|
* @template {(...args: any[]) => any} T
|
||||||
|
* @param {string} inputID
|
||||||
|
* @param {T} fn
|
||||||
|
* @param {Parameters<T>} rest
|
||||||
|
* @returns {boolean} false if the event was found and the function will be ignored, true if the event was not found and the function will be retriggered
|
||||||
*/
|
*/
|
||||||
function waitFor(id, resolve, existsInDatabase) {
|
function eventNotFoundThenRetrigger(inputID, fn, ...rest) {
|
||||||
if (existsInDatabase && storage.isNotPaused(id)) { // if event already exists and isn't paused then resolve immediately
|
if (!paused.has(inputID)) {
|
||||||
debugRetrigger(`[retrigger] EXISTS id = ${id}`)
|
if (inputID.match(/^[0-9]+$/)) {
|
||||||
return resolve(true)
|
const eventID = select("event_message", "event_id", {message_id: inputID}).pluck().get()
|
||||||
|
if (eventID) {
|
||||||
|
debugRetrigger(`[retrigger] OK mid <-> eid = ${inputID} <-> ${eventID}`)
|
||||||
|
return false // event was found so don't retrigger
|
||||||
|
}
|
||||||
|
} else if (inputID.match(/^\$/)) {
|
||||||
|
const messageID = select("event_message", "message_id", {event_id: inputID}).pluck().get()
|
||||||
|
if (messageID) {
|
||||||
|
debugRetrigger(`[retrigger] OK eid <-> mid = ${inputID} <-> ${messageID}`)
|
||||||
|
return false // message was found so don't retrigger
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// doesn't exist. wait for it to exist. storage will resolve true if it exists or false if it timed out
|
debugRetrigger(`[retrigger] WAIT id = ${inputID}`)
|
||||||
return storage.store(id, resolve)
|
emitter.once(inputID, () => {
|
||||||
}
|
debugRetrigger(`[retrigger] TRIGGER id = ${inputID}`)
|
||||||
|
fn(...rest)
|
||||||
const GET_EVENT_PREPARED = from("event_message").select("event_id").and("WHERE event_id = ?").prepare().raw()
|
})
|
||||||
/**
|
// if the event never arrives, don't trigger the callback, just clean up
|
||||||
* @param {string} eventID
|
setTimeout(() => {
|
||||||
* @returns {Promise<boolean>} if true then the message did not arrive
|
if (emitter.listeners(inputID).length) {
|
||||||
*/
|
debugRetrigger(`[retrigger] EXPIRE id = ${inputID}`)
|
||||||
function waitForEvent(eventID) {
|
}
|
||||||
const {promise, resolve} = Promise.withResolvers()
|
emitter.removeAllListeners(inputID)
|
||||||
waitFor(eventID, resolve, !!GET_EVENT_PREPARED.get(eventID))
|
}, 60 * 1000) // 1 minute
|
||||||
return promise
|
return true // event was not found, then retrigger
|
||||||
}
|
|
||||||
|
|
||||||
const GET_MESSAGE_PREPARED = from("event_message").select("message_id").and("WHERE message_id = ?").prepare().raw()
|
|
||||||
/**
|
|
||||||
* @param {string} messageID
|
|
||||||
* @returns {Promise<boolean>} if true then the message did not arrive
|
|
||||||
*/
|
|
||||||
function waitForMessage(messageID) {
|
|
||||||
const {promise, resolve} = Promise.withResolvers()
|
|
||||||
waitFor(messageID, resolve, !!GET_MESSAGE_PREPARED.get(messageID))
|
|
||||||
return promise
|
|
||||||
}
|
|
||||||
|
|
||||||
const GET_REACTION_EVENT_PREPARED = from("reaction").select("hashed_event_id").and("WHERE hashed_event_id = ?").prepare().raw()
|
|
||||||
/**
|
|
||||||
* @param {string} eventID
|
|
||||||
* @returns {Promise<boolean>} if true then the message did not arrive
|
|
||||||
*/
|
|
||||||
function waitForReactionEvent(eventID) {
|
|
||||||
const {promise, resolve} = Promise.withResolvers()
|
|
||||||
waitFor(eventID, resolve, !!GET_REACTION_EVENT_PREPARED.get(utils.getEventIDHash(eventID)))
|
|
||||||
return promise
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Anything calling retrigger during the callback will be paused and retriggered after the callback resolves.
|
* Anything calling retrigger during the callback will be paused and retriggered after the callback resolves.
|
||||||
* @template T
|
* @template T
|
||||||
* @param {string} id
|
* @param {string} messageID
|
||||||
* @param {Promise<T>} promise
|
* @param {Promise<T>} promise
|
||||||
* @returns {Promise<T>}
|
* @returns {Promise<T>}
|
||||||
*/
|
*/
|
||||||
async function pauseChanges(id, promise) {
|
async function pauseChanges(messageID, promise) {
|
||||||
try {
|
try {
|
||||||
storage.pause(id)
|
debugRetrigger(`[retrigger] PAUSE id = ${messageID}`)
|
||||||
|
paused.add(messageID)
|
||||||
return await promise
|
return await promise
|
||||||
} finally {
|
} finally {
|
||||||
finishedBridging(id)
|
debugRetrigger(`[retrigger] RESUME id = ${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} id
|
* @param {string} messageID
|
||||||
*/
|
*/
|
||||||
function finishedBridging(id) {
|
function messageFinishedBridging(messageID) {
|
||||||
storage.resolve(id, true)
|
if (emitter.listeners(messageID).length) {
|
||||||
|
debugRetrigger(`[retrigger] EMIT id = ${messageID}`)
|
||||||
|
}
|
||||||
|
emitter.emit(messageID)
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports.waitForMessage = waitForMessage
|
module.exports.eventNotFoundThenRetrigger = eventNotFoundThenRetrigger
|
||||||
module.exports.waitForEvent = waitForEvent
|
module.exports.messageFinishedBridging = messageFinishedBridging
|
||||||
module.exports.waitForReactionEvent = waitForReactionEvent
|
|
||||||
module.exports.pauseChanges = pauseChanges
|
module.exports.pauseChanges = pauseChanges
|
||||||
module.exports.finishedBridging = finishedBridging
|
|
||||||
|
|
@ -4,16 +4,17 @@ const assert = require("assert").strict
|
||||||
const DiscordTypes = require("discord-api-types/v10")
|
const DiscordTypes = require("discord-api-types/v10")
|
||||||
|
|
||||||
const passthrough = require("../../passthrough")
|
const passthrough = require("../../passthrough")
|
||||||
const {discord, sync, db, select, from} = passthrough
|
const { discord, sync, db, select, from} = passthrough
|
||||||
const {reg} = require("../../matrix/read-registration")
|
|
||||||
/** @type {import("../converters/message-to-event")} */
|
/** @type {import("../converters/message-to-event")} */
|
||||||
const messageToEvent = sync.require("../converters/message-to-event")
|
const messageToEvent = sync.require("../converters/message-to-event")
|
||||||
/** @type {import("../../matrix/api")} */
|
/** @type {import("../../matrix/api")} */
|
||||||
const api = sync.require("../../matrix/api")
|
const api = sync.require("../../matrix/api")
|
||||||
/** @type {import("./register-user")} */
|
/** @type {import("./register-user")} */
|
||||||
const registerUser = sync.require("./register-user")
|
const registerUser = sync.require("./register-user")
|
||||||
/** @type {import("./speedbump")} */
|
/** @type {import("./register-pk-user")} */
|
||||||
const speedbump = sync.require("./speedbump")
|
const registerPkUser = sync.require("./register-pk-user")
|
||||||
|
/** @type {import("./register-webhook-user")} */
|
||||||
|
const registerWebhookUser = sync.require("./register-webhook-user")
|
||||||
/** @type {import("../actions/create-room")} */
|
/** @type {import("../actions/create-room")} */
|
||||||
const createRoom = sync.require("../actions/create-room")
|
const createRoom = sync.require("../actions/create-room")
|
||||||
/** @type {import("../actions/poll-end")} */
|
/** @type {import("../actions/poll-end")} */
|
||||||
|
|
@ -27,21 +28,30 @@ const channelWebhook = sync.require("../../m2d/actions/channel-webhook")
|
||||||
* @param {DiscordTypes.GatewayMessageCreateDispatchData} message
|
* @param {DiscordTypes.GatewayMessageCreateDispatchData} message
|
||||||
* @param {DiscordTypes.APIGuildChannel} channel
|
* @param {DiscordTypes.APIGuildChannel} channel
|
||||||
* @param {DiscordTypes.APIGuild} guild
|
* @param {DiscordTypes.APIGuild} guild
|
||||||
|
* @param {{speedbump_id: string, speedbump_webhook_id: string} | null} row data about the webhook which is proxying messages in this channel
|
||||||
*/
|
*/
|
||||||
async function sendMessage(message, channel, guild) {
|
async function sendMessage(message, channel, guild, row) {
|
||||||
const roomID = await createRoom.ensureRoom(message.channel_id)
|
const roomID = await createRoom.ensureRoom(message.channel_id)
|
||||||
const historicalRoomIndex = select("historical_channel_room", "historical_room_index", {room_id: roomID}).pluck().get()
|
const historicalRoomIndex = select("historical_channel_room", "historical_room_index", {room_id: roomID}).pluck().get()
|
||||||
assert(historicalRoomIndex)
|
assert(historicalRoomIndex)
|
||||||
|
|
||||||
let senderMxid = null
|
let senderMxid = null
|
||||||
if (dUtils.isWebhookMessage(message)) {
|
if (dUtils.isWebhookMessage(message)) {
|
||||||
senderMxid = await speedbump.getWebhookSenderId(message, guild.id, roomID)
|
const useWebhookProfile = select("guild_space", "webhook_profile", {guild_id: guild.id}).pluck().get() ?? 0
|
||||||
|
if (row && row.speedbump_webhook_id === message.webhook_id) {
|
||||||
|
// Handle the PluralKit public instance
|
||||||
|
if (row.speedbump_id === "466378653216014359") {
|
||||||
|
senderMxid = await registerPkUser.syncUser(message.id, message.author, roomID, true)
|
||||||
|
}
|
||||||
|
} else if (useWebhookProfile) {
|
||||||
|
senderMxid = await registerWebhookUser.syncUser(message.author, roomID, true)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// not a webhook
|
// not a webhook
|
||||||
if (message.author.id === discord.application.id) {
|
if (message.author.id === discord.application.id) {
|
||||||
// no need to sync the bot's own user
|
// no need to sync the bot's own user
|
||||||
} else {
|
} else {
|
||||||
senderMxid = await registerUser.syncUser(message.author, message.member, channel, guild, roomID, message.interaction_metadata)
|
senderMxid = await registerUser.syncUser(message.author, message.member, channel, guild, roomID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -50,7 +60,8 @@ async function sendMessage(message, channel, guild) {
|
||||||
const detailedResultsMessage = await pollEnd.endPoll(message)
|
const detailedResultsMessage = await pollEnd.endPoll(message)
|
||||||
if (detailedResultsMessage) {
|
if (detailedResultsMessage) {
|
||||||
const threadParent = select("channel_room", "thread_parent", {channel_id: message.channel_id}).pluck().get()
|
const threadParent = select("channel_room", "thread_parent", {channel_id: message.channel_id}).pluck().get()
|
||||||
const {channelID, threadID} = dUtils.swapThreadID(message.channel_id, threadParent)
|
const channelID = threadParent ? threadParent : message.channel_id
|
||||||
|
const threadID = threadParent ? message.channel_id : undefined
|
||||||
sentResultsMessage = await channelWebhook.sendMessageWithWebhook(channelID, detailedResultsMessage, threadID)
|
sentResultsMessage = await channelWebhook.sendMessageWithWebhook(channelID, detailedResultsMessage, threadID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const assert = require("assert").strict
|
|
||||||
|
|
||||||
const passthrough = require("../../passthrough")
|
const passthrough = require("../../passthrough")
|
||||||
const {sync, select} = passthrough
|
const {sync, select} = passthrough
|
||||||
/** @type {import("../../matrix/api")} */
|
/** @type {import("../../matrix/api")} */
|
||||||
|
|
@ -28,7 +26,7 @@ const presenceLoopInterval = 28e3
|
||||||
|
|
||||||
// Cache the list of enabled guilds rather than accessing it like multiple times per second when any user changes presence
|
// Cache the list of enabled guilds rather than accessing it like multiple times per second when any user changes presence
|
||||||
const guildPresenceSetting = new class {
|
const guildPresenceSetting = new class {
|
||||||
/** @private @type {Set<string>} */ guilds = new Set()
|
/** @private @type {Set<string>} */ guilds
|
||||||
constructor() {
|
constructor() {
|
||||||
this.update()
|
this.update()
|
||||||
}
|
}
|
||||||
|
|
@ -42,7 +40,7 @@ const guildPresenceSetting = new class {
|
||||||
|
|
||||||
class Presence extends sync.reloadClassMethods(() => Presence) {
|
class Presence extends sync.reloadClassMethods(() => Presence) {
|
||||||
/** @type {string} */ userID
|
/** @type {string} */ userID
|
||||||
/** @type {{presence: "online" | "offline" | "unavailable", status_msg?: string} | undefined} */ data
|
/** @type {{presence: "online" | "offline" | "unavailable", status_msg?: string}} */ data
|
||||||
/** @private @type {?string | undefined} */ mxid
|
/** @private @type {?string | undefined} */ mxid
|
||||||
/** @private @type {number} */ delay = Math.random()
|
/** @private @type {number} */ delay = Math.random()
|
||||||
|
|
||||||
|
|
@ -68,7 +66,6 @@ class Presence extends sync.reloadClassMethods(() => Presence) {
|
||||||
// I haven't tried, but I assume Synapse explodes if you try to update too many presences at the same time.
|
// I haven't tried, but I assume Synapse explodes if you try to update too many presences at the same time.
|
||||||
// This random delay will space them out over the whole 28 second cycle.
|
// This random delay will space them out over the whole 28 second cycle.
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
assert(this.data)
|
|
||||||
api.setPresence(this.data, mxid).catch(() => {})
|
api.setPresence(this.data, mxid).catch(() => {})
|
||||||
}, this.delay * presenceLoopInterval).unref()
|
}, this.delay * presenceLoopInterval).unref()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,14 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const DiscordTypes = require("discord-api-types/v10")
|
const DiscordTypes = require("discord-api-types/v10")
|
||||||
|
|
||||||
const passthrough = require("../../passthrough")
|
const passthrough = require("../../passthrough")
|
||||||
const {discord, from, select, db, sync} = passthrough
|
const {discord, select, db} = passthrough
|
||||||
|
|
||||||
const {reg} = require("../../matrix/read-registration")
|
|
||||||
/** @type {import("./register-pk-user")} */
|
|
||||||
const registerPkUser = sync.require("./register-pk-user")
|
|
||||||
/** @type {import("./register-plu-ral-user")} */
|
|
||||||
const registerPluRalUser = sync.require("./register-plu-ral-user")
|
|
||||||
/** @type {import("./register-webhook-user")} */
|
|
||||||
const registerWebhookUser = sync.require("./register-webhook-user")
|
|
||||||
|
|
||||||
const DEBUG_SPEEDBUMP = false
|
const DEBUG_SPEEDBUMP = false
|
||||||
|
|
||||||
function debugSpeedbump(...args) {
|
function debugSpeedbump(message) {
|
||||||
if (DEBUG_SPEEDBUMP) {
|
if (DEBUG_SPEEDBUMP) {
|
||||||
console.log(...args)
|
console.log(message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -26,8 +17,7 @@ const SPEEDBUMP_UPDATE_FREQUENCY = 2 * 60 * 60 // 2 hours
|
||||||
|
|
||||||
/** @type {Set<any>} */
|
/** @type {Set<any>} */
|
||||||
const KNOWN_BOTS = new Set([
|
const KNOWN_BOTS = new Set([
|
||||||
"466378653216014359", // PluralKit
|
"466378653216014359" // PluralKit
|
||||||
"1291501048493768784", // /plu/ral
|
|
||||||
])
|
])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -39,81 +29,55 @@ async function updateCache(channelID, lastChecked) {
|
||||||
const now = Math.floor(Date.now() / 1000)
|
const now = Math.floor(Date.now() / 1000)
|
||||||
if (lastChecked && now - lastChecked < SPEEDBUMP_UPDATE_FREQUENCY) return
|
if (lastChecked && now - lastChecked < SPEEDBUMP_UPDATE_FREQUENCY) return
|
||||||
const webhooks = await discord.snow.webhook.getChannelWebhooks(channelID)
|
const webhooks = await discord.snow.webhook.getChannelWebhooks(channelID)
|
||||||
const found = webhooks.filter(b => KNOWN_BOTS.has(b.application_id))
|
const found = webhooks.find(b => KNOWN_BOTS.has(b.application_id))
|
||||||
db.transaction(() => {
|
const foundApplication = found?.application_id
|
||||||
db.prepare("DELETE FROM channel_speedbump WHERE channel_id = ?").run(channelID)
|
const foundWebhook = found?.id
|
||||||
for (const webhook of found) {
|
db.prepare("UPDATE channel_room SET speedbump_id = ?, speedbump_webhook_id = ?, speedbump_checked = ? WHERE channel_id = ?").run(foundApplication, foundWebhook, now, channelID)
|
||||||
db.prepare("INSERT INTO channel_speedbump (channel_id, speedbump_webhook_id, speedbump_user_id) VALUES (?, ?, ?)").run(channelID, webhook.id, webhook.application_id)
|
|
||||||
}
|
|
||||||
db.prepare("UPDATE channel_room SET speedbump_checked = ? WHERE channel_id = ?").run(now, channelID)
|
|
||||||
})()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** @type {Map<string, number>} messageID -> number of gateway events currently bumping */
|
||||||
* @typedef BumpingEntry
|
|
||||||
* @prop {number} number number of gateway events currently bumping for this message ID
|
|
||||||
* @prop {boolean} hasCreate whether there was a message create within the events currently bumping
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** @type {Map<string, BumpingEntry>} messageID -> BumpingEntry */
|
|
||||||
const bumping = new Map()
|
const bumping = new Map()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Slow down a message. After it passes the speedbump, return whether it's okay or if it's been deleted.
|
* Slow down a message. After it passes the speedbump, return whether it's okay or if it's been deleted.
|
||||||
* @param {boolean} isCreate
|
|
||||||
* @param {string} messageID
|
* @param {string} messageID
|
||||||
* @returns whether it was deleted
|
* @returns whether it was deleted
|
||||||
*/
|
*/
|
||||||
async function doSpeedbump(isCreate, messageID) {
|
async function doSpeedbump(messageID) {
|
||||||
const entry = bumping.get(messageID) ?? (() => {
|
let value = (bumping.get(messageID) ?? 0) + 1
|
||||||
const entry = {number: 0, hasCreate: false}
|
bumping.set(messageID, value)
|
||||||
bumping.set(messageID, entry)
|
debugSpeedbump(`[speedbump] WAIT ${messageID}++ = ${value}`)
|
||||||
return entry
|
|
||||||
})()
|
|
||||||
|
|
||||||
entry.number++
|
|
||||||
entry.hasCreate ||= isCreate
|
|
||||||
|
|
||||||
debugSpeedbump(`[speedbump] WAIT ${messageID}++ =`, entry)
|
|
||||||
|
|
||||||
await new Promise(resolve => setTimeout(resolve, SPEEDBUMP_SPEED))
|
await new Promise(resolve => setTimeout(resolve, SPEEDBUMP_SPEED))
|
||||||
|
|
||||||
if (!bumping.has(messageID)) {
|
if (!bumping.has(messageID)) {
|
||||||
debugSpeedbump(`[speedbump] DELETED ${messageID}`)
|
debugSpeedbump(`[speedbump] DELETED ${messageID}`)
|
||||||
return {skip: true, hasCreate: null}
|
return true
|
||||||
}
|
}
|
||||||
|
value = (bumping.get(messageID) ?? 0) - 1
|
||||||
if (--entry.number <= 0) {
|
if (value <= 0) {
|
||||||
debugSpeedbump(`[speedbump] OK ${messageID}-- =`, entry)
|
debugSpeedbump(`[speedbump] OK ${messageID}-- = ${value}`)
|
||||||
bumping.delete(messageID)
|
bumping.delete(messageID)
|
||||||
return {skip: false, hasCreate: entry.hasCreate}
|
return false
|
||||||
} else {
|
} else {
|
||||||
debugSpeedbump(`[speedbump] MULTI ${messageID}-- =`, entry)
|
debugSpeedbump(`[speedbump] MULTI ${messageID}-- = ${value}`)
|
||||||
return {skip: true, hasCreate: null}
|
bumping.set(messageID, value)
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSpeedbumpRows(channelID) {
|
|
||||||
return from("channel_room").join("channel_speedbump", "channel_id").select("thread_parent", "speedbump_user_id", "speedbump_webhook_id").where({channel_id: channelID}).all()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check whether to slow down a message, and do it. After it passes the speedbump, return whether it's okay or if it's been deleted.
|
* Check whether to slow down a message, and do it. After it passes the speedbump, return whether it's okay or if it's been deleted.
|
||||||
* @param {boolean} isCreate
|
* @param {string} channelID
|
||||||
* @param {{id: string, channel_id: string, author: {id: string}, backfill?: boolean}} message uses the ID to identify, and the userID to only slow down the message when the user has used PK before
|
* @param {string} messageID
|
||||||
* @returns whether to skip this message, and whether the message should be created as a creation
|
* @returns whether it was deleted, and data about the channel's (not thread's) speedbump
|
||||||
*/
|
*/
|
||||||
async function maybeDoSpeedbump(isCreate, message) {
|
async function maybeDoSpeedbump(channelID, messageID) {
|
||||||
let rows = getSpeedbumpRows(message.channel_id)
|
let row = select("channel_room", ["thread_parent", "speedbump_id", "speedbump_webhook_id"], {channel_id: channelID}).get()
|
||||||
if (rows[0]?.thread_parent) rows = getSpeedbumpRows(rows[0].thread_parent) // webhooks belong to the channel, not the thread
|
if (row?.thread_parent) row = select("channel_room", ["thread_parent", "speedbump_id", "speedbump_webhook_id"], {channel_id: row.thread_parent}).get() // webhooks belong to the channel, not the thread
|
||||||
if (!rows.length) return {skip: false} // channel not affected, no speedbump
|
if (!row?.speedbump_webhook_id) return {affected: false, row: null} // not affected, no speedbump
|
||||||
|
const affected = await doSpeedbump(messageID)
|
||||||
if (message.backfill) return {skip: false} // don't slow messages during backfill
|
return {affected, row} // maybe affected, and there is a speedbump
|
||||||
if (rows.some(r => r.speedbump_webhook_id === message.author.id)) return {skip: false} // shortcut
|
|
||||||
const userHasProxy = select("sim_proxy", "user_id", {proxy_owner_id: message.author.id}).pluck().get()
|
|
||||||
if (!userHasProxy) return {skip: false} // user has not used PK before, no speedbump
|
|
||||||
const {skip, hasCreate} = await doSpeedbump(isCreate, message.id)
|
|
||||||
return {skip, hasCreate} // maybe affected, and there is a speedbump
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -123,25 +87,7 @@ function onMessageDelete(messageID) {
|
||||||
bumping.delete(messageID)
|
bumping.delete(messageID)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {DiscordTypes.APIMessage} message
|
|
||||||
* @param {string} guildID
|
|
||||||
* @param {string} roomID
|
|
||||||
*/
|
|
||||||
async function getWebhookSenderId(message, guildID, roomID) {
|
|
||||||
const speedbumpUserID = select("channel_speedbump", "speedbump_user_id", {channel_id: message.channel_id, speedbump_webhook_id: message.webhook_id}).pluck().get()
|
|
||||||
const useWebhookProfile = select("guild_space", "webhook_profile", {guild_id: guildID}).pluck().get() ?? 0
|
|
||||||
if (speedbumpUserID === "466378653216014359") { // PluralKit public instance
|
|
||||||
return await registerPkUser.syncUser(message.id, message.author, roomID, true)
|
|
||||||
} else if (speedbumpUserID === "1291501048493768784" && reg.ooye.plu_ral_api_key) { // /plu/ral public instance
|
|
||||||
return await registerPluRalUser.syncUser(message.channel_id, message.id, message.author, roomID, true)
|
|
||||||
} else if (useWebhookProfile) {
|
|
||||||
return await registerWebhookUser.syncUser(message.author, roomID, true)
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports.updateCache = updateCache
|
module.exports.updateCache = updateCache
|
||||||
|
module.exports.doSpeedbump = doSpeedbump
|
||||||
module.exports.maybeDoSpeedbump = maybeDoSpeedbump
|
module.exports.maybeDoSpeedbump = maybeDoSpeedbump
|
||||||
module.exports.onMessageDelete = onMessageDelete
|
module.exports.onMessageDelete = onMessageDelete
|
||||||
module.exports.getWebhookSenderId = getWebhookSenderId
|
|
||||||
|
|
|
||||||
|
|
@ -151,11 +151,9 @@ async function editToChanges(message, guild, api) {
|
||||||
const messageReallyOld = message.timestamp && new Date(message.timestamp).getTime() < Date.now() - 2 * 60 * 1000 // older than 2 minutes ago
|
const messageReallyOld = message.timestamp && new Date(message.timestamp).getTime() < Date.now() - 2 * 60 * 1000 // older than 2 minutes ago
|
||||||
// Don't post new generated embeds for messages if the setting was disabled.
|
// Don't post new generated embeds for messages if the setting was disabled.
|
||||||
const embedsEnabled = select("guild_space", "url_preview", {guild_id: guild?.id}).pluck().get() ?? 1
|
const embedsEnabled = select("guild_space", "url_preview", {guild_id: guild?.id}).pluck().get() ?? 1
|
||||||
// Bots may rely on embeds to send new content, so the rules may be more lax for them.
|
|
||||||
const botEmbedsApproved = message.author?.bot && !originallyFromMatrix
|
|
||||||
if (messageReallyOld) {
|
if (messageReallyOld) {
|
||||||
eventsToSend = [] // Only allow edits to change and delete, but not send new.
|
eventsToSend = [] // Only allow edits to change and delete, but not send new.
|
||||||
} else if ((messageQuiteOld || !embedsEnabled) && !botEmbedsApproved) {
|
} else if ((messageQuiteOld || !embedsEnabled) && !message.author?.bot) {
|
||||||
eventsToSend = eventsToSend.filter(e => e.msgtype !== "m.notice") // Only send events that aren't embeds.
|
eventsToSend = eventsToSend.filter(e => e.msgtype !== "m.notice") // Only send events that aren't embeds.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,7 @@ test("edit2changes: bot response", async t => {
|
||||||
newContent: {
|
newContent: {
|
||||||
$type: "m.room.message",
|
$type: "m.room.message",
|
||||||
msgtype: "m.text",
|
msgtype: "m.text",
|
||||||
body: "* :ae_botrac4r: @cadence asked ````, I respond: Stop drinking paint. (No)\n\nHit :bn_re: to reroll.",
|
body: "* :ae_botrac4r: [@cadence](https://matrix.to/#/@cadence:cadence.moe) asked ````, I respond: Stop drinking paint. (No)\n\nHit :bn_re: to reroll.",
|
||||||
format: "org.matrix.custom.html",
|
format: "org.matrix.custom.html",
|
||||||
formatted_body: '* <img data-mx-emoticon height="32" src="mxc://cadence.moe/skqfuItqxNmBYekzmVKyoLzs" title=":ae_botrac4r:" alt=":ae_botrac4r:"> <a href="https://matrix.to/#/@cadence:cadence.moe">@cadence</a> asked <code></code>, I respond: Stop drinking paint. (No)<br><br>Hit <img data-mx-emoticon height="32" src="mxc://cadence.moe/OIpqpfxTnHKokcsYqDusxkBT" title=":bn_re:" alt=":bn_re:"> to reroll.',
|
formatted_body: '* <img data-mx-emoticon height="32" src="mxc://cadence.moe/skqfuItqxNmBYekzmVKyoLzs" title=":ae_botrac4r:" alt=":ae_botrac4r:"> <a href="https://matrix.to/#/@cadence:cadence.moe">@cadence</a> asked <code></code>, I respond: Stop drinking paint. (No)<br><br>Hit <img data-mx-emoticon height="32" src="mxc://cadence.moe/OIpqpfxTnHKokcsYqDusxkBT" title=":bn_re:" alt=":bn_re:"> to reroll.',
|
||||||
"m.mentions": {
|
"m.mentions": {
|
||||||
|
|
@ -87,7 +87,7 @@ test("edit2changes: bot response", async t => {
|
||||||
// *** Replaced With: ***
|
// *** Replaced With: ***
|
||||||
"m.new_content": {
|
"m.new_content": {
|
||||||
msgtype: "m.text",
|
msgtype: "m.text",
|
||||||
body: ":ae_botrac4r: @cadence asked ````, I respond: Stop drinking paint. (No)\n\nHit :bn_re: to reroll.",
|
body: ":ae_botrac4r: [@cadence](https://matrix.to/#/@cadence:cadence.moe) asked ````, I respond: Stop drinking paint. (No)\n\nHit :bn_re: to reroll.",
|
||||||
format: "org.matrix.custom.html",
|
format: "org.matrix.custom.html",
|
||||||
formatted_body: '<img data-mx-emoticon height="32" src="mxc://cadence.moe/skqfuItqxNmBYekzmVKyoLzs" title=":ae_botrac4r:" alt=":ae_botrac4r:"> <a href="https://matrix.to/#/@cadence:cadence.moe">@cadence</a> asked <code></code>, I respond: Stop drinking paint. (No)<br><br>Hit <img data-mx-emoticon height="32" src="mxc://cadence.moe/OIpqpfxTnHKokcsYqDusxkBT" title=":bn_re:" alt=":bn_re:"> to reroll.',
|
formatted_body: '<img data-mx-emoticon height="32" src="mxc://cadence.moe/skqfuItqxNmBYekzmVKyoLzs" title=":ae_botrac4r:" alt=":ae_botrac4r:"> <a href="https://matrix.to/#/@cadence:cadence.moe">@cadence</a> asked <code></code>, I respond: Stop drinking paint. (No)<br><br>Hit <img data-mx-emoticon height="32" src="mxc://cadence.moe/OIpqpfxTnHKokcsYqDusxkBT" title=":bn_re:" alt=":bn_re:"> to reroll.',
|
||||||
"m.mentions": {
|
"m.mentions": {
|
||||||
|
|
@ -343,18 +343,19 @@ test("edit2changes: generated embed", async t => {
|
||||||
t.deepEqual(eventsToSend, [{
|
t.deepEqual(eventsToSend, [{
|
||||||
$type: "m.room.message",
|
$type: "m.room.message",
|
||||||
msgtype: "m.notice",
|
msgtype: "m.notice",
|
||||||
body: "| ## This post nerdsniped me, so here's some RULES FOR REAL-LIFE BALATRO https://cohost.org/jkap/post/4794219-empty"
|
body: "| via hthrflwrs on cohost"
|
||||||
|
+ "\n| \n| ## This post nerdsniped me, so here's some RULES FOR REAL-LIFE BALATRO https://cohost.org/jkap/post/4794219-empty"
|
||||||
+ "\n| \n| 1v1 physical card game. Each player gets one standard deck of cards with a different backing to differentiate. Every turn proceeds as follows:"
|
+ "\n| \n| 1v1 physical card game. Each player gets one standard deck of cards with a different backing to differentiate. Every turn proceeds as follows:"
|
||||||
+ "\n| \n| * Both players draw eight cards"
|
+ "\n| \n| * Both players draw eight cards"
|
||||||
+ "\n| * Both players may choose up to eight cards to discard, then draw that number of cards to put back in their hand"
|
+ "\n| * Both players may choose up to eight cards to discard, then draw that number of cards to put back in their hand"
|
||||||
+ "\n| * Both players present their best five-or-less-card pok..."
|
+ "\n| * Both players present their best five-or-less-card pok...",
|
||||||
+ "\n| via hthrflwrs on cohost",
|
|
||||||
format: "org.matrix.custom.html",
|
format: "org.matrix.custom.html",
|
||||||
formatted_body: `<blockquote><p><strong><a href="https://cohost.org/jkap/post/4794219-empty">This post nerdsniped me, so here's some RULES FOR REAL-LIFE BALATRO</a></strong>`
|
formatted_body: `<blockquote><p><sub>hthrflwrs on cohost</sub>`
|
||||||
|
+ `</p><p><strong><a href="https://cohost.org/jkap/post/4794219-empty">This post nerdsniped me, so here's some RULES FOR REAL-LIFE BALATRO</a></strong>`
|
||||||
+ `</p><p>1v1 physical card game. Each player gets one standard deck of cards with a different backing to differentiate. Every turn proceeds as follows:`
|
+ `</p><p>1v1 physical card game. Each player gets one standard deck of cards with a different backing to differentiate. Every turn proceeds as follows:`
|
||||||
+ `<br><br><ul><li>Both players draw eight cards`
|
+ `<br><br><ul><li>Both players draw eight cards`
|
||||||
+ `</li><li>Both players may choose up to eight cards to discard, then draw that number of cards to put back in their hand`
|
+ `</li><li>Both players may choose up to eight cards to discard, then draw that number of cards to put back in their hand`
|
||||||
+ `</li><li>Both players present their best five-or-less-card pok...</li></ul></p><sub>hthrflwrs on cohost</sub></blockquote>`,
|
+ `</li><li>Both players present their best five-or-less-card pok...</li></ul></p></blockquote>`,
|
||||||
"m.mentions": {}
|
"m.mentions": {}
|
||||||
}])
|
}])
|
||||||
t.deepEqual(promotions, [{
|
t.deepEqual(promotions, [{
|
||||||
|
|
|
||||||
|
|
@ -146,18 +146,10 @@ function findMention(pjr, maximumWrittenSection, baseOffset, prefix, content) {
|
||||||
// Highlight the relevant part of the message
|
// Highlight the relevant part of the message
|
||||||
const start = baseOffset + best.scored.matchedInputTokens[0].index
|
const start = baseOffset + best.scored.matchedInputTokens[0].index
|
||||||
const end = baseOffset + prefix.length + best.scored.matchedInputTokens.slice(-1)[0].end
|
const end = baseOffset + prefix.length + best.scored.matchedInputTokens.slice(-1)[0].end
|
||||||
const newNodes = [{
|
const newContent = content.slice(0, start) + "[" + content.slice(start, end) + "](https://matrix.to/#/" + best.mxid + ")" + content.slice(end)
|
||||||
type: "text", content: content.slice(0, start)
|
|
||||||
}, {
|
|
||||||
type: "link", target: `https://matrix.to/#/${best.mxid}`, content: [
|
|
||||||
{type: "text", content: content.slice(start, end)}
|
|
||||||
]
|
|
||||||
}, {
|
|
||||||
type: "text", content: content.slice(end)
|
|
||||||
}]
|
|
||||||
return {
|
return {
|
||||||
mxid: best.mxid,
|
mxid: best.mxid,
|
||||||
newNodes
|
newContent
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,10 +35,10 @@ function getDiscordParseCallbacks(message, guild, useHTML, spoilers = []) {
|
||||||
/** @param {{id: string, type: "discordUser"}} node */
|
/** @param {{id: string, type: "discordUser"}} node */
|
||||||
user: node => {
|
user: node => {
|
||||||
const mxid = select("sim", "mxid", {user_id: node.id}).pluck().get()
|
const mxid = select("sim", "mxid", {user_id: node.id}).pluck().get()
|
||||||
const interactionMetadata = message.interaction_metadata
|
const interaction = message.interaction_metadata || message.interaction
|
||||||
const username = message.mentions?.find(ment => ment.id === node.id)?.username
|
const username = message.mentions?.find(ment => ment.id === node.id)?.username
|
||||||
|| message.referenced_message?.mentions?.find(ment => ment.id === node.id)?.username
|
|| message.referenced_message?.mentions?.find(ment => ment.id === node.id)?.username
|
||||||
|| (interactionMetadata?.user.id === node.id ? interactionMetadata.user.username : null)
|
|| (interaction?.user.id === node.id ? interaction.user.username : null)
|
||||||
|| (message.author?.id === node.id ? message.author.username : null)
|
|| (message.author?.id === node.id ? message.author.username : null)
|
||||||
|| "unknown-user"
|
|| "unknown-user"
|
||||||
if (mxid && useHTML) {
|
if (mxid && useHTML) {
|
||||||
|
|
@ -122,7 +122,7 @@ async function attachmentToEvent(mentions, attachment, alwaysLink) {
|
||||||
: attachment.content_type?.startsWith("audio/") ? "🎶"
|
: attachment.content_type?.startsWith("audio/") ? "🎶"
|
||||||
: "📄"
|
: "📄"
|
||||||
// no native media spoilers in Element, so we'll post a link instead, forcing it to not preview using a blockquote
|
// no native media spoilers in Element, so we'll post a link instead, forcing it to not preview using a blockquote
|
||||||
if (attachment.filename.startsWith("SPOILER_") || !!((attachment.flags || 0) & DiscordTypes.AttachmentFlags.IsSpoiler)) {
|
if (attachment.filename.startsWith("SPOILER_")) {
|
||||||
return {
|
return {
|
||||||
$type: "m.room.message",
|
$type: "m.room.message",
|
||||||
"m.mentions": mentions,
|
"m.mentions": mentions,
|
||||||
|
|
@ -256,34 +256,8 @@ function getFormattedInteraction(interaction, isThinkingInteraction) {
|
||||||
const username = interaction.member?.nick || interaction.user.global_name || interaction.user.username
|
const username = interaction.member?.nick || interaction.user.global_name || interaction.user.username
|
||||||
const thinkingText = isThinkingInteraction ? " — interaction loading..." : ""
|
const thinkingText = isThinkingInteraction ? " — interaction loading..." : ""
|
||||||
return {
|
return {
|
||||||
body: `❭ ${username} used \`/${interaction.name}\`${thinkingText}`,
|
body: `↪️ ${username} used \`/${interaction.name}\`${thinkingText}`,
|
||||||
html: `<blockquote><sub>❭ ${mxid ? tag`<a href="https://matrix.to/#/${mxid}">${username}</a>` : username} used <code>/${interaction.name}</code>${thinkingText}</sub></blockquote>`
|
html: `<blockquote><sub>↪️ ${mxid ? tag`<a href="https://matrix.to/#/${mxid}">${username}</a>` : username} used <code>/${interaction.name}</code>${thinkingText}</sub></blockquote>`
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {any} newEvents merge into events
|
|
||||||
* @param {any} events will be modified
|
|
||||||
* @param {boolean} forceSameMsgtype whether m.text may only be combined with m.text, etc
|
|
||||||
* @param {boolean} [forceMerge] if true, must merge event, will error if it had to append
|
|
||||||
*/
|
|
||||||
function mergeTextEvents(newEvents, events, forceSameMsgtype, forceMerge = false) {
|
|
||||||
let prev = events.at(-1)
|
|
||||||
for (const ne of newEvents) {
|
|
||||||
const isAllText = prev?.body && prev?.formatted_body && ["m.text", "m.notice"].includes(ne.msgtype) && ["m.text", "m.notice"].includes(prev?.msgtype)
|
|
||||||
const typesPermitted = !forceSameMsgtype || ne?.msgtype === prev?.msgtype
|
|
||||||
if (isAllText && typesPermitted) {
|
|
||||||
const rep = new mxUtils.MatrixStringBuilder()
|
|
||||||
rep.body = prev.body
|
|
||||||
rep.formattedBody = prev.formatted_body
|
|
||||||
rep.addLine(ne.body, ne.formatted_body)
|
|
||||||
prev.body = rep.body
|
|
||||||
prev.formatted_body = rep.formattedBody
|
|
||||||
} else if (forceMerge) {
|
|
||||||
throw new Error("Unable to merge events")
|
|
||||||
} else {
|
|
||||||
events.push(ne)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -360,19 +334,9 @@ async function messageToEvent(message, guild, options = {}, di) {
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message.type === DiscordTypes.MessageType.ChannelFollowAdd) {
|
const interaction = message.interaction_metadata || message.interaction
|
||||||
return [{
|
const isInteraction = message.type === DiscordTypes.MessageType.ChatInputCommand && !!interaction && "name" in interaction
|
||||||
$type: "m.room.message",
|
const isThinkingInteraction = isInteraction && !!((message.flags || 0) & DiscordTypes.MessageFlags.Loading)
|
||||||
msgtype: "m.emote",
|
|
||||||
body: `set this room to receive announcements from ${message.content}`,
|
|
||||||
format: "org.matrix.custom.html",
|
|
||||||
formatted_body: tag`set this room to receive announcements from <strong>${message.content}</strong>`,
|
|
||||||
"m.mentions": {}
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
|
|
||||||
let isInteraction = (message.type === DiscordTypes.MessageType.ChatInputCommand || message.type === DiscordTypes.MessageType.ContextMenuCommand) && message.interaction && "name" in message.interaction
|
|
||||||
let isThinkingInteraction = isInteraction && !!((message.flags || 0) & DiscordTypes.MessageFlags.Loading)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@type {{room?: boolean, user_ids?: string[]}}
|
@type {{room?: boolean, user_ids?: string[]}}
|
||||||
|
|
@ -413,16 +377,6 @@ async function messageToEvent(message, guild, options = {}, di) {
|
||||||
} else if (message.referenced_message) {
|
} else if (message.referenced_message) {
|
||||||
repliedToUnknownEvent = true
|
repliedToUnknownEvent = true
|
||||||
}
|
}
|
||||||
} else if (message.type === DiscordTypes.MessageType.ContextMenuCommand && message.interaction && message.message_reference?.message_id) {
|
|
||||||
// It could be a /plu/ral emulated reply
|
|
||||||
if (message.interaction.name.startsWith("Reply ") && message.content.startsWith("-# [↪](")) {
|
|
||||||
const row = await getHistoricalEventRow(message.message_reference?.message_id)
|
|
||||||
if (row && "event_id" in row) {
|
|
||||||
repliedToEventRow = Object.assign(row, {channel_id: row.reference_channel_id})
|
|
||||||
message.content = message.content.replace(/^.*\n/, "")
|
|
||||||
isInteraction = false // declutter
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (dUtils.isWebhookMessage(message) && message.embeds[0]?.author?.name?.endsWith("↩️")) {
|
} else if (dUtils.isWebhookMessage(message) && message.embeds[0]?.author?.name?.endsWith("↩️")) {
|
||||||
// It could be a PluralKit emulated reply, let's see if it has a message link
|
// It could be a PluralKit emulated reply, let's see if it has a message link
|
||||||
const isEmulatedReplyToText = message.embeds[0].description?.startsWith("**[Reply to:]")
|
const isEmulatedReplyToText = message.embeds[0].description?.startsWith("**[Reply to:]")
|
||||||
|
|
@ -557,7 +511,7 @@ async function messageToEvent(message, guild, options = {}, di) {
|
||||||
|
|
||||||
// Handling emojis that we don't know about. The emoji has to be present in the DB for it to be picked up in the emoji markdown converter.
|
// Handling emojis that we don't know about. The emoji has to be present in the DB for it to be picked up in the emoji markdown converter.
|
||||||
// So we scan the message ahead of time for all its emojis and ensure they are in the DB.
|
// So we scan the message ahead of time for all its emojis and ensure they are in the DB.
|
||||||
const emojiMatches = [...content.matchAll(/<(a?):([^:>]+):([0-9]+)>/g)]
|
const emojiMatches = [...content.matchAll(/<(a?):([^:>]{1,64}):([0-9]+)>/g)]
|
||||||
await Promise.all(emojiMatches.map(match => {
|
await Promise.all(emojiMatches.map(match => {
|
||||||
const id = match[3]
|
const id = match[3]
|
||||||
const name = match[2]
|
const name = match[2]
|
||||||
|
|
@ -565,60 +519,29 @@ async function messageToEvent(message, guild, options = {}, di) {
|
||||||
return emojiToKey.emojiToKey({id, name, animated}, message.id) // Register the custom emoji if needed
|
return emojiToKey.emojiToKey({id, name, animated}, message.id) // Register the custom emoji if needed
|
||||||
}))
|
}))
|
||||||
|
|
||||||
async function transformParsedVia(parsed, scanTextForMentions) {
|
async function transformParsedVia(parsed) {
|
||||||
for (let n = 0; n < parsed.length; n++) {
|
for (const node of parsed) {
|
||||||
const node = parsed[n]
|
|
||||||
if (node.type === "discordChannel" || node.type === "discordChannelLink") {
|
if (node.type === "discordChannel" || node.type === "discordChannelLink") {
|
||||||
node.row = select("channel_room", ["room_id", "name", "nick"], {channel_id: node.id}).get()
|
node.row = select("channel_room", ["room_id", "name", "nick"], {channel_id: node.id}).get()
|
||||||
if (node.row?.room_id) {
|
if (node.row?.room_id) {
|
||||||
node.via = await getViaServersMemo(node.row.room_id)
|
node.via = await getViaServersMemo(node.row.room_id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (node.type === "text" && typeof node.content === "string") {
|
|
||||||
// Merge adjacent text nodes into this one
|
|
||||||
while (parsed[n+1]?.type === "text" && typeof parsed[n+1].content === "string") {
|
|
||||||
node.content += parsed[n+1].content
|
|
||||||
parsed.splice(n+1, 1)
|
|
||||||
}
|
|
||||||
// Mentions scenario 3: scan the message content for written @mentions of matrix users. Allows for up to one space between @ and mention.
|
|
||||||
if (scanTextForMentions) {
|
|
||||||
let content = node.content
|
|
||||||
const matches = [...content.matchAll(/(@ ?)([a-z0-9_.#$][^@\n]+)/gi)]
|
|
||||||
for (let i = matches.length; i--;) {
|
|
||||||
const m = matches[i]
|
|
||||||
const prefix = m[1]
|
|
||||||
const maximumWrittenSection = m[2].toLowerCase()
|
|
||||||
if (m.index > 0 && !content[m.index-1].match(/ |\(|\n/)) continue // must have space before it
|
|
||||||
if (maximumWrittenSection.match(/^everyone\b/) || maximumWrittenSection.match(/^here\b/)) continue // ignore @everyone/@here
|
|
||||||
|
|
||||||
var roomID = roomID ?? select("channel_room", "room_id", {channel_id: message.channel_id}).pluck().get()
|
|
||||||
assert(roomID)
|
|
||||||
var pjr = pjr ?? findMentions.processJoined(Object.entries((await di.api.getJoinedMembers(roomID)).joined).map(([mxid, ev]) => ({mxid, displayname: ev.display_name})))
|
|
||||||
|
|
||||||
const found = findMentions.findMention(pjr, maximumWrittenSection, m.index, prefix, content)
|
|
||||||
if (found) {
|
|
||||||
addMention(found.mxid)
|
|
||||||
parsed.splice(n, 1, ...found.newNodes)
|
|
||||||
content = found.newNodes[0].content
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const maybeChildNodesArray of [node, node.content, node.items]) {
|
for (const maybeChildNodesArray of [node, node.content, node.items]) {
|
||||||
if (Array.isArray(maybeChildNodesArray)) {
|
if (Array.isArray(maybeChildNodesArray)) {
|
||||||
await transformParsedVia(maybeChildNodesArray, scanTextForMentions && ["blockQuote", "list", "paragraph", "em", "strong", "u", "del", "text"].includes(node.type))
|
await transformParsedVia(maybeChildNodesArray)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return parsed
|
return parsed
|
||||||
}
|
}
|
||||||
|
|
||||||
let html = await markdown.toHtmlWithPostParser(content, parsed => transformParsedVia(parsed, customOptions.isTheMessageContent && options.scanTextForMentions !== false), {
|
let html = await markdown.toHtmlWithPostParser(content, transformParsedVia, {
|
||||||
discordCallback: getDiscordParseCallbacks(message, guild, true, spoilers),
|
discordCallback: getDiscordParseCallbacks(message, guild, true, spoilers),
|
||||||
...customOptions
|
...customOptions
|
||||||
}, customParser, customHtmlOutput)
|
}, customParser, customHtmlOutput)
|
||||||
|
|
||||||
let body = await markdown.toHtmlWithPostParser(content, parsed => transformParsedVia(parsed, false), { // not scanning plaintext body for mentions as we don't parse whether they're in code
|
let body = await markdown.toHtmlWithPostParser(content, transformParsedVia, {
|
||||||
discordCallback: getDiscordParseCallbacks(message, guild, false),
|
discordCallback: getDiscordParseCallbacks(message, guild, false),
|
||||||
discordOnly: true,
|
discordOnly: true,
|
||||||
escapeHTML: false,
|
escapeHTML: false,
|
||||||
|
|
@ -643,8 +566,8 @@ async function messageToEvent(message, guild, options = {}, di) {
|
||||||
|
|
||||||
const flags = message.flags || 0
|
const flags = message.flags || 0
|
||||||
if (flags & DiscordTypes.MessageFlags.IsCrosspost) {
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback body/formatted_body for replies
|
// Fallback body/formatted_body for replies
|
||||||
|
|
@ -659,8 +582,7 @@ async function messageToEvent(message, guild, options = {}, di) {
|
||||||
// check that condition 1 or 2 is met
|
// check that condition 1 or 2 is met
|
||||||
if (repliedToEventInDifferentRoom || repliedToUnknownEvent) {
|
if (repliedToEventInDifferentRoom || repliedToUnknownEvent) {
|
||||||
let referenced = message.referenced_message
|
let referenced = message.referenced_message
|
||||||
/* c8 ignore next 4 - backend couldn't be bothered to dereference the message, have to do it ourselves */
|
if (!referenced) { // backend couldn't be bothered to dereference the message, have to do it ourselves
|
||||||
if (!referenced) {
|
|
||||||
assert(message.message_reference?.message_id)
|
assert(message.message_reference?.message_id)
|
||||||
referenced = await discord.snow.channel.getChannelMessage(message.message_reference.channel_id, message.message_reference.message_id)
|
referenced = await discord.snow.channel.getChannelMessage(message.message_reference.channel_id, message.message_reference.message_id)
|
||||||
}
|
}
|
||||||
|
|
@ -672,7 +594,7 @@ async function messageToEvent(message, guild, options = {}, di) {
|
||||||
const match = repliedToEventSenderMxid.match(/^@([^:]*)/)
|
const match = repliedToEventSenderMxid.match(/^@([^:]*)/)
|
||||||
assert(match)
|
assert(match)
|
||||||
repliedToDisplayName = referenced.author.username || match[1] || "a Matrix user" // grab the localpart as the display name, whatever
|
repliedToDisplayName = referenced.author.username || match[1] || "a Matrix user" // grab the localpart as the display name, whatever
|
||||||
repliedToUserHtml = tag`<a href="https://matrix.to/#/${repliedToEventSenderMxid}">${repliedToDisplayName}</a>`
|
repliedToUserHtml = `<a href="https://matrix.to/#/${repliedToEventSenderMxid}">${repliedToDisplayName}</a>`
|
||||||
} else {
|
} else {
|
||||||
repliedToDisplayName = referenced.author.global_name || referenced.author.username || "a Discord user"
|
repliedToDisplayName = referenced.author.global_name || referenced.author.username || "a Discord user"
|
||||||
repliedToUserHtml = repliedToDisplayName
|
repliedToUserHtml = repliedToDisplayName
|
||||||
|
|
@ -697,12 +619,6 @@ async function messageToEvent(message, guild, options = {}, di) {
|
||||||
+ html
|
+ html
|
||||||
body = `${repliedToDisplayName}: ${repliedToBody}`.split("\n").map(line => "> " + line).join("\n") // scenario 1 part B for mentions
|
body = `${repliedToDisplayName}: ${repliedToBody}`.split("\n").map(line => "> " + line).join("\n") // scenario 1 part B for mentions
|
||||||
+ "\n\n" + body
|
+ "\n\n" + body
|
||||||
} else if (referenced.type === DiscordTypes.MessageType.UserJoin) {
|
|
||||||
// Discord user join messages are bridged as joins, not text events. Generate substitute text for reply.
|
|
||||||
const joinerMxid = select("sim", "mxid", {user_id: referenced.author.id}).pluck().get()
|
|
||||||
const joinerHtml = joinerMxid ? tag`<a href="https://matrix.to/#/${joinerMxid}">${repliedToDisplayName}</a>` : tag`<strong>${repliedToDisplayName}</strong>`
|
|
||||||
html = `<blockquote>${joinerHtml} joined the room</blockquote>` + html
|
|
||||||
body = `> ${repliedToDisplayName} joined the room\n\n` + body
|
|
||||||
} else { // repliedToUnknownEvent
|
} else { // repliedToUnknownEvent
|
||||||
const dateDisplay = dUtils.howOldUnbridgedMessage(referenced.timestamp, message.timestamp)
|
const dateDisplay = dUtils.howOldUnbridgedMessage(referenced.timestamp, message.timestamp)
|
||||||
html = `<blockquote>In reply to ${dateDisplay} from ${repliedToDisplayName}:`
|
html = `<blockquote>In reply to ${dateDisplay} from ${repliedToDisplayName}:`
|
||||||
|
|
@ -714,8 +630,8 @@ async function messageToEvent(message, guild, options = {}, di) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isInteraction && !isThinkingInteraction && message.interaction && events.length === 0) {
|
if (isInteraction && !isThinkingInteraction && events.length === 0) {
|
||||||
const formattedInteraction = getFormattedInteraction(message.interaction, false)
|
const formattedInteraction = getFormattedInteraction(interaction, false)
|
||||||
body = `${formattedInteraction.body}\n${body}`
|
body = `${formattedInteraction.body}\n${body}`
|
||||||
html = `${formattedInteraction.html}${html}`
|
html = `${formattedInteraction.html}${html}`
|
||||||
}
|
}
|
||||||
|
|
@ -771,20 +687,20 @@ async function messageToEvent(message, guild, options = {}, di) {
|
||||||
if (row && "event_id" in row) {
|
if (row && "event_id" in row) {
|
||||||
const via = await getViaServersMemo(row.room_id)
|
const via = await getViaServersMemo(row.room_id)
|
||||||
forwardedNotice.addLine(
|
forwardedNotice.addLine(
|
||||||
`[↷ Forwarded from #${roomName}]`,
|
`[🔀 Forwarded from #${roomName}]`,
|
||||||
tag`↷ <em>Forwarded from ${roomName} <a href="https://matrix.to/#/${room.room_id}/${row.event_id}?${via}">[jump to event]</a></em>`
|
tag`🔀 <em>Forwarded from ${roomName} <a href="https://matrix.to/#/${room.room_id}/${row.event_id}?${via}">[jump to event]</a></em>`
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
const via = await getViaServersMemo(room.room_id)
|
const via = await getViaServersMemo(room.room_id)
|
||||||
forwardedNotice.addLine(
|
forwardedNotice.addLine(
|
||||||
`[↷ Forwarded from #${roomName}]`,
|
`[🔀 Forwarded from #${roomName}]`,
|
||||||
tag`↷ <em>Forwarded from ${roomName} <a href="https://matrix.to/#/${room.room_id}?${via}">[jump to room]</a></em>`
|
tag`🔀 <em>Forwarded from ${roomName} <a href="https://matrix.to/#/${room.room_id}?${via}">[jump to room]</a></em>`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
forwardedNotice.addLine(
|
forwardedNotice.addLine(
|
||||||
`[↷ Forwarded message]`,
|
`[🔀 Forwarded message]`,
|
||||||
tag`↷ <em>Forwarded message</em>`
|
tag`🔀 <em>Forwarded message</em>`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -811,37 +727,49 @@ async function messageToEvent(message, guild, options = {}, di) {
|
||||||
events.push(...forwardedEvents)
|
events.push(...forwardedEvents)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isInteraction && isThinkingInteraction && message.interaction) {
|
if (isThinkingInteraction) {
|
||||||
const formattedInteraction = getFormattedInteraction(message.interaction, true)
|
const formattedInteraction = getFormattedInteraction(interaction, true)
|
||||||
await addTextEvent(formattedInteraction.body, formattedInteraction.html, "m.notice")
|
await addTextEvent(formattedInteraction.body, formattedInteraction.html, "m.notice")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Then text content
|
// Then text content
|
||||||
if (message.content && !isOnlyKlipyGIF && !isThinkingInteraction) {
|
if (message.content && !isOnlyKlipyGIF && !isThinkingInteraction) {
|
||||||
|
// Mentions scenario 3: scan the message content for written @mentions of matrix users. Allows for up to one space between @ and mention.
|
||||||
|
let content = message.content
|
||||||
|
if (options.scanTextForMentions !== false) {
|
||||||
|
const matches = [...content.matchAll(/(@ ?)([a-z0-9_.#$][^@\n]+)/gi)]
|
||||||
|
for (let i = matches.length; i--;) {
|
||||||
|
const m = matches[i]
|
||||||
|
const prefix = m[1]
|
||||||
|
const maximumWrittenSection = m[2].toLowerCase()
|
||||||
|
if (m.index > 0 && !content[m.index-1].match(/ |\(|\n/)) continue // must have space before it
|
||||||
|
if (maximumWrittenSection.match(/^everyone\b/) || maximumWrittenSection.match(/^here\b/)) continue // ignore @everyone/@here
|
||||||
|
|
||||||
|
var roomID = roomID ?? select("channel_room", "room_id", {channel_id: message.channel_id}).pluck().get()
|
||||||
|
assert(roomID)
|
||||||
|
var pjr = pjr ?? findMentions.processJoined(Object.entries((await di.api.getJoinedMembers(roomID)).joined).map(([mxid, ev]) => ({mxid, displayname: ev.display_name})))
|
||||||
|
|
||||||
|
const found = findMentions.findMention(pjr, maximumWrittenSection, m.index, prefix, content)
|
||||||
|
if (found) {
|
||||||
|
addMention(found.mxid)
|
||||||
|
content = found.newContent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Scan the content for emojihax and replace them with real emojis
|
// Scan the content for emojihax and replace them with real emojis
|
||||||
let content = message.content.replaceAll(/\[([a-zA-Z0-9_-]{2,32})(?:~[0-9]+)?\]\(https:\/\/cdn\.discordapp\.com\/emojis\/([0-9]+)\.[^ \n)`]+\)/g, (_, name, id) => {
|
content = content.replaceAll(/\[([a-zA-Z0-9_-]{2,32})(?:~[0-9]+)?\]\(https:\/\/cdn\.discordapp\.com\/emojis\/([0-9]+)\.[^ \n)`]+\)/g, (_, name, id) => {
|
||||||
return `<:${name}:${id}>`
|
return `<:${name}:${id}>`
|
||||||
})
|
})
|
||||||
|
|
||||||
const {body, html} = await transformContent(content, {isTheMessageContent: true})
|
const {body, html} = await transformContent(content)
|
||||||
await addTextEvent(body, html, msgtype)
|
await addTextEvent(body, html, msgtype)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Then scheduled events
|
// Then scheduled events
|
||||||
if (message.content && di?.snow) {
|
if (message.content && di?.snow) {
|
||||||
for (const match of [...message.content.matchAll(/discord\.gg\/([A-Za-z0-9]+)\?event=([0-9]{18,})/g)]) { // snowflake has minimum 18 because the events feature is at least that old
|
for (const match of [...message.content.matchAll(/discord\.gg\/([A-Za-z0-9]+)\?event=([0-9]{18,})/g)]) { // snowflake has minimum 18 because the events feature is at least that old
|
||||||
let invite
|
const invite = await di.snow.invite.getInvite(match[1], {guild_scheduled_event_id: match[2]})
|
||||||
try {
|
|
||||||
invite = await di.snow.invite.getInvite(match[1], {guild_scheduled_event_id: match[2]})
|
|
||||||
} catch (e) {
|
|
||||||
// Skip expired/invalid invites and events
|
|
||||||
if (e.message === `{"message": "Unknown Invite", "code": 10006}`) {
|
|
||||||
break
|
|
||||||
} else {
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const event = invite.guild_scheduled_event
|
const event = invite.guild_scheduled_event
|
||||||
if (!event) continue // the event ID provided was not valid
|
if (!event) continue // the event ID provided was not valid
|
||||||
|
|
||||||
|
|
@ -887,7 +815,15 @@ async function messageToEvent(message, guild, options = {}, di) {
|
||||||
|
|
||||||
// Try to merge attachment events with the previous event
|
// Try to merge attachment events with the previous event
|
||||||
// This means that if the attachments ended up as a text link, and especially if there were many of them, the events will be joined together.
|
// This means that if the attachments ended up as a text link, and especially if there were many of them, the events will be joined together.
|
||||||
mergeTextEvents(attachmentEvents, events, false)
|
let prev = events.at(-1)
|
||||||
|
for (const atch of attachmentEvents) {
|
||||||
|
if (atch.msgtype === "m.text" && prev?.body && prev?.formatted_body && ["m.text", "m.notice"].includes(prev?.msgtype)) {
|
||||||
|
prev.body = prev.body + "\n" + atch.body
|
||||||
|
prev.formatted_body = prev.formatted_body + "<br>" + atch.formatted_body
|
||||||
|
} else {
|
||||||
|
events.push(atch)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Then components
|
// Then components
|
||||||
|
|
@ -907,14 +843,7 @@ async function messageToEvent(message, guild, options = {}, di) {
|
||||||
/** @type {{[k in keyof DiscordTypes.APIUnfurledMediaItem]-?: NonNullable<DiscordTypes.APIUnfurledMediaItem[k]>}} */ // @ts-ignore
|
/** @type {{[k in keyof DiscordTypes.APIUnfurledMediaItem]-?: NonNullable<DiscordTypes.APIUnfurledMediaItem[k]>}} */ // @ts-ignore
|
||||||
const file = component.file
|
const file = component.file
|
||||||
assert(component.name && component.size && file.content_type)
|
assert(component.name && component.size && file.content_type)
|
||||||
const ev = await attachmentToEvent({}, {
|
const ev = await attachmentToEvent({}, {...file, filename: component.name, size: component.size}, true)
|
||||||
filename: component.name,
|
|
||||||
content_type: file.content_type,
|
|
||||||
size: component.size,
|
|
||||||
url: file.url,
|
|
||||||
height: file.height,
|
|
||||||
width: file.width,
|
|
||||||
}, true)
|
|
||||||
stack.msb.addLine(ev.body, ev.formatted_body)
|
stack.msb.addLine(ev.body, ev.formatted_body)
|
||||||
}
|
}
|
||||||
else if (component.type === DiscordTypes.ComponentType.MediaGallery) {
|
else if (component.type === DiscordTypes.ComponentType.MediaGallery) {
|
||||||
|
|
@ -976,9 +905,11 @@ async function messageToEvent(message, guild, options = {}, di) {
|
||||||
else if (component.type === DiscordTypes.ComponentType.Button) {
|
else if (component.type === DiscordTypes.ComponentType.Button) {
|
||||||
// May only be a section accessory or in an action row (up to 5)
|
// May only be a section accessory or in an action row (up to 5)
|
||||||
if (component.style === DiscordTypes.ButtonStyle.Link) {
|
if (component.style === DiscordTypes.ButtonStyle.Link) {
|
||||||
assert(component.label) // required for Discord to validate link buttons
|
if (component.label) {
|
||||||
const link = await transformContentMessageLinks(component.url)
|
stack.msb.add(`[${component.label} ${component.url}] `, tag`<a href="${component.url}">${component.label}</a> `)
|
||||||
stack.msb.add(`[${component.label} ${link}] `, tag`<a href="${link}">${component.label}</a> `)
|
} else {
|
||||||
|
stack.msb.add(component.url)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -991,20 +922,8 @@ async function messageToEvent(message, guild, options = {}, di) {
|
||||||
|
|
||||||
const {body, formatted_body} = stack.msb.get()
|
const {body, formatted_body} = stack.msb.get()
|
||||||
if (body.trim().length) {
|
if (body.trim().length) {
|
||||||
// Create new message if Components V2 (cannot have regular content)
|
|
||||||
if ((message.flags ?? 0) & DiscordTypes.MessageFlags.IsComponentsV2) {
|
|
||||||
await addTextEvent(body, formatted_body, "m.text")
|
await addTextEvent(body, formatted_body, "m.text")
|
||||||
}
|
}
|
||||||
// Add to existing message if legacy components https://docs.discord.com/developers/components/reference#legacy-message-component-behavior
|
|
||||||
else {
|
|
||||||
mergeTextEvents([{
|
|
||||||
msgtype: "m.text",
|
|
||||||
body,
|
|
||||||
format: "org.matrix.custom.html",
|
|
||||||
formatted_body
|
|
||||||
}], events, false, true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Then polls
|
// Then polls
|
||||||
|
|
@ -1045,7 +964,6 @@ async function messageToEvent(message, guild, options = {}, di) {
|
||||||
|
|
||||||
// Start building up a replica ("rep") of the embed in Discord-markdown format, which we will convert into both plaintext and formatted body at once
|
// Start building up a replica ("rep") of the embed in Discord-markdown format, which we will convert into both plaintext and formatted body at once
|
||||||
const rep = new mxUtils.MatrixStringBuilder()
|
const rep = new mxUtils.MatrixStringBuilder()
|
||||||
let isAdditionalImage = false
|
|
||||||
|
|
||||||
if (isKlipyGIF) {
|
if (isKlipyGIF) {
|
||||||
assert(embed.video?.url)
|
assert(embed.video?.url)
|
||||||
|
|
@ -1062,6 +980,15 @@ async function messageToEvent(message, guild, options = {}, di) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Provider
|
||||||
|
if (embed.provider?.name && embed.provider.name !== "Tenor") {
|
||||||
|
if (embed.provider.url) {
|
||||||
|
rep.addParagraph(`via ${embed.provider.name} ${embed.provider.url}`, tag`<sub><a href="${embed.provider.url}">${embed.provider.name}</a></sub>`)
|
||||||
|
} else {
|
||||||
|
rep.addParagraph(`via ${embed.provider.name}`, tag`<sub>${embed.provider.name}</sub>`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Author and URL into a paragraph
|
// Author and URL into a paragraph
|
||||||
let authorNameText = embed.author?.name || ""
|
let authorNameText = embed.author?.name || ""
|
||||||
if (authorNameText && embed.author?.icon_url) authorNameText = `⏺️ ${authorNameText}` // using the emoji instead of an image
|
if (authorNameText && embed.author?.icon_url) authorNameText = `⏺️ ${authorNameText}` // using the emoji instead of an image
|
||||||
|
|
@ -1103,35 +1030,15 @@ async function messageToEvent(message, guild, options = {}, di) {
|
||||||
let chosenImage = embed.image?.url
|
let chosenImage = embed.image?.url
|
||||||
// the thumbnail seems to be used for "article" type but displayed big at the bottom by discord
|
// the thumbnail seems to be used for "article" type but displayed big at the bottom by discord
|
||||||
if (embed.type === "article" && embed.thumbnail?.url && !chosenImage) chosenImage = embed.thumbnail.url
|
if (embed.type === "article" && embed.thumbnail?.url && !chosenImage) chosenImage = embed.thumbnail.url
|
||||||
|
if (chosenImage) rep.addParagraph(`📸 ${dUtils.getPublicUrlForCdn(chosenImage)}`)
|
||||||
if (chosenImage) {
|
|
||||||
isAdditionalImage = !rep.body && !!events.length
|
|
||||||
rep.addParagraph(`📸 ${dUtils.getPublicUrlForCdn(chosenImage)}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (embed.video?.url) rep.addParagraph(`🎞️ ${dUtils.getPublicUrlForCdn(embed.video.url)}`)
|
if (embed.video?.url) rep.addParagraph(`🎞️ ${dUtils.getPublicUrlForCdn(embed.video.url)}`)
|
||||||
|
|
||||||
// Provider and footer (moved to bottom, below author/title, to improve room preview line)
|
if (embed.footer?.text) rep.addLine(`— ${embed.footer.text}`, tag`— ${embed.footer.text}`)
|
||||||
if (embed.provider?.name && embed.provider.name !== "Tenor" && embed.provider.name !== "YouTube") {
|
|
||||||
const insertableFooterText = embed.footer?.text ? ` — ${embed.footer.text}` : ""
|
|
||||||
if (embed.provider.url) {
|
|
||||||
rep.addLine(`via ${embed.provider.name}${insertableFooterText || " " + embed.provider.url}`, tag`<sub><a href="${embed.provider.url}">${embed.provider.name}</a>${insertableFooterText}</sub>`)
|
|
||||||
} else {
|
|
||||||
rep.addLine(`via ${embed.provider.name}${insertableFooterText}`, tag`<sub>${embed.provider.name}${insertableFooterText}</sub>`)
|
|
||||||
}
|
|
||||||
} else 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()
|
||||||
body = body.split("\n").map(l => "| " + l).join("\n")
|
body = body.split("\n").map(l => "| " + l).join("\n")
|
||||||
html = `<blockquote>${html}</blockquote>`
|
html = `<blockquote>${html}</blockquote>`
|
||||||
|
|
||||||
if (isAdditionalImage) {
|
|
||||||
mergeTextEvents([{...rep.get(), body, html, msgtype: "m.notice"}], events, true)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send as m.notice to apply the usual automated/subtle appearance, showing this wasn't actually typed by the person
|
// Send as m.notice to apply the usual automated/subtle appearance, showing this wasn't actually typed by the person
|
||||||
await addTextEvent(body, html, "m.notice")
|
await addTextEvent(body, html, "m.notice")
|
||||||
}
|
}
|
||||||
|
|
@ -1152,7 +1059,7 @@ async function messageToEvent(message, guild, options = {}, di) {
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let body = stickerItem.name
|
let body = stickerItem.name
|
||||||
const sticker = guild.stickers?.find(sticker => sticker.id === stickerItem.id)
|
const sticker = guild.stickers.find(sticker => sticker.id === stickerItem.id)
|
||||||
if (sticker && sticker.description) body += ` - ${sticker.description}`
|
if (sticker && sticker.description) body += ` - ${sticker.description}`
|
||||||
return {
|
return {
|
||||||
$type: "m.sticker",
|
$type: "m.sticker",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
const {test} = require("supertape")
|
const {test} = require("supertape")
|
||||||
const {messageToEvent} = require("./message-to-event")
|
const {messageToEvent} = require("./message-to-event")
|
||||||
const data = require("../../../test/data")
|
const data = require("../../../test/data")
|
||||||
const {mockGetEffectivePower} = require("../../matrix/utils.test")
|
|
||||||
|
|
||||||
test("message2event components: pk question mark output", async t => {
|
test("message2event components: pk question mark output", async t => {
|
||||||
const events = await messageToEvent(data.message_with_components.pk_question_mark_response, data.guild.general, {})
|
const events = await messageToEvent(data.message_with_components.pk_question_mark_response, data.guild.general, {})
|
||||||
|
|
@ -66,7 +65,7 @@ test("message2event components: pk question mark output", async t => {
|
||||||
+ "<hr>"
|
+ "<hr>"
|
||||||
+ "<blockquote><p><strong>System:</strong> INX (<code>xffgnx</code>)"
|
+ "<blockquote><p><strong>System:</strong> INX (<code>xffgnx</code>)"
|
||||||
+ "<br><strong>Member:</strong> Lillith (<code>pphhoh</code>)"
|
+ "<br><strong>Member:</strong> Lillith (<code>pphhoh</code>)"
|
||||||
+ "<br><strong>Sent by:</strong> infinidoge1337 (<a href=\"https://matrix.to/#/@_ooye_infinidoge1337:cadence.moe\">@unknown-user</a>)"
|
+ "<br><strong>Sent by:</strong> infinidoge1337 (@unknown-user:)"
|
||||||
+ "<br><br><strong>Account Roles (7)</strong>"
|
+ "<br><br><strong>Account Roles (7)</strong>"
|
||||||
+ "<br>§b, !, ‼, Ears Port Ping, Ears Update Ping, Yttr Ping, unsup Ping</p>"
|
+ "<br>§b, !, ‼, Ears Port Ping, Ears Update Ping, Yttr Ping, unsup Ping</p>"
|
||||||
+ `🖼️ <a href="https://files.inx.moe/p/cdn/lillith.webp">https://files.inx.moe/p/cdn/lillith.webp</a>`
|
+ `🖼️ <a href="https://files.inx.moe/p/cdn/lillith.webp">https://files.inx.moe/p/cdn/lillith.webp</a>`
|
||||||
|
|
@ -78,24 +77,3 @@ test("message2event components: pk question mark output", async t => {
|
||||||
msgtype: "m.text",
|
msgtype: "m.text",
|
||||||
}])
|
}])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("message2event components: pk ping message legacy components", async t => {
|
|
||||||
const events = await messageToEvent(data.message_with_components.pk_ping_components_v1, data.guild.general, {}, {
|
|
||||||
api: {
|
|
||||||
async getJoinedMembers() {
|
|
||||||
return {joined: {}}
|
|
||||||
},
|
|
||||||
getEffectivePower: mockGetEffectivePower()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
t.deepEqual(events, [{
|
|
||||||
$type: "m.room.message",
|
|
||||||
msgtype: "m.text",
|
|
||||||
body: "❭ cadence used `/🔔 Ping author`"
|
|
||||||
+ "\nPsst, **Red** (@cadence.worm:), you have been pinged by @cadence.worm:."
|
|
||||||
+ "\n[Jump https://matrix.to/#/!TqlyQmifxGUggEmdBN:cadence.moe/$l9FMmsEbh9K0NUReeEpWOMZYGRlUOE8yLcm6P-TYHSM?via=cadence.moe] ",
|
|
||||||
format: "org.matrix.custom.html",
|
|
||||||
formatted_body: "<blockquote><sub>❭ <a href=\"https://matrix.to/#/@_ooye_cadence:cadence.moe\">cadence</a> used <code>/🔔 Ping author</code></sub></blockquote>Psst, <strong>Red</strong> (<a href=\"https://matrix.to/#/@_ooye_cadence:cadence.moe\">@cadence.worm</a>), you have been pinged by <a href=\"https://matrix.to/#/@_ooye_cadence:cadence.moe\">@cadence.worm</a>.<br><a href=\"https://matrix.to/#/!TqlyQmifxGUggEmdBN:cadence.moe/$l9FMmsEbh9K0NUReeEpWOMZYGRlUOE8yLcm6P-TYHSM?via=cadence.moe\">Jump</a> ",
|
|
||||||
"m.mentions": {}
|
|
||||||
}])
|
|
||||||
})
|
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,9 @@ test("message2event embeds: interaction loading", async t => {
|
||||||
const events = await messageToEvent(data.interaction_message.thinking_interaction, data.guild.general, {})
|
const events = await messageToEvent(data.interaction_message.thinking_interaction, data.guild.general, {})
|
||||||
t.deepEqual(events, [{
|
t.deepEqual(events, [{
|
||||||
$type: "m.room.message",
|
$type: "m.room.message",
|
||||||
body: "❭ Brad used `/stats` — interaction loading...",
|
body: "↪️ Brad used `/stats` — interaction loading...",
|
||||||
format: "org.matrix.custom.html",
|
format: "org.matrix.custom.html",
|
||||||
formatted_body: "<blockquote><sub>❭ <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">Brad</a> used <code>/stats</code> — interaction loading...</sub></blockquote>",
|
formatted_body: "<blockquote><sub>↪️ <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">Brad</a> used <code>/stats</code> — interaction loading...</sub></blockquote>",
|
||||||
"m.mentions": {},
|
"m.mentions": {},
|
||||||
msgtype: "m.notice",
|
msgtype: "m.notice",
|
||||||
}])
|
}])
|
||||||
|
|
@ -22,12 +22,12 @@ test("message2event embeds: nothing but a field", async t => {
|
||||||
$type: "m.room.message",
|
$type: "m.room.message",
|
||||||
"m.mentions": {},
|
"m.mentions": {},
|
||||||
msgtype: "m.notice",
|
msgtype: "m.notice",
|
||||||
body: "❭ PapiOphidian used `/stats`"
|
body: "↪️ PapiOphidian used `/stats`"
|
||||||
+ "\n| ### Amanda 🎵#2192 :online:"
|
+ "\n| ### Amanda 🎵#2192 :online:"
|
||||||
+ "\n| willow tree, branch 0"
|
+ "\n| willow tree, branch 0"
|
||||||
+ "\n| **❯ Uptime:**\n| 3m 55s\n| **❯ Memory:**\n| 64.45MB",
|
+ "\n| **❯ Uptime:**\n| 3m 55s\n| **❯ Memory:**\n| 64.45MB",
|
||||||
format: "org.matrix.custom.html",
|
format: "org.matrix.custom.html",
|
||||||
formatted_body: '<blockquote><sub>❭ <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">PapiOphidian</a> used <code>/stats</code></sub></blockquote>'
|
formatted_body: '<blockquote><sub>↪️ <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">PapiOphidian</a> used <code>/stats</code></sub></blockquote>'
|
||||||
+ '<blockquote><p><strong>Amanda 🎵#2192 <img data-mx-emoticon height=\"32\" src=\"mxc://cadence.moe/LCEqjStXCxvRQccEkuslXEyZ\" title=\":online:\" alt=\":online:\">'
|
+ '<blockquote><p><strong>Amanda 🎵#2192 <img data-mx-emoticon height=\"32\" src=\"mxc://cadence.moe/LCEqjStXCxvRQccEkuslXEyZ\" title=\":online:\" alt=\":online:\">'
|
||||||
+ '<br>willow tree, branch 0</strong>'
|
+ '<br>willow tree, branch 0</strong>'
|
||||||
+ '<br><strong>❯ Uptime:</strong><br>3m 55s'
|
+ '<br><strong>❯ Uptime:</strong><br>3m 55s'
|
||||||
|
|
@ -125,8 +125,8 @@ test("message2event embeds: blockquote in embed", async t => {
|
||||||
t.equal(called, 1, "should call getJoinedMembers once")
|
t.equal(called, 1, "should call getJoinedMembers once")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("message2event embeds: extreme html is all escaped", async t => {
|
test("message2event embeds: crazy html is all escaped", async t => {
|
||||||
const events = await messageToEvent(data.message_with_embeds.extreme_html_escaping, data.guild.general)
|
const events = await messageToEvent(data.message_with_embeds.escaping_crazy_html_tags, data.guild.general)
|
||||||
t.deepEqual(events, [{
|
t.deepEqual(events, [{
|
||||||
$type: "m.room.message",
|
$type: "m.room.message",
|
||||||
msgtype: "m.notice",
|
msgtype: "m.notice",
|
||||||
|
|
@ -153,10 +153,10 @@ test("message2event embeds: title without url", async t => {
|
||||||
t.deepEqual(events, [{
|
t.deepEqual(events, [{
|
||||||
$type: "m.room.message",
|
$type: "m.room.message",
|
||||||
msgtype: "m.notice",
|
msgtype: "m.notice",
|
||||||
body: "❭ PapiOphidian used `/stats`"
|
body: "↪️ PapiOphidian used `/stats`"
|
||||||
+ "\n| ## Hi, I'm Amanda!\n| \n| I condone pirating music!",
|
+ "\n| ## Hi, I'm Amanda!\n| \n| I condone pirating music!",
|
||||||
format: "org.matrix.custom.html",
|
format: "org.matrix.custom.html",
|
||||||
formatted_body: '<blockquote><sub>❭ <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">PapiOphidian</a> used <code>/stats</code></sub></blockquote>'
|
formatted_body: '<blockquote><sub>↪️ <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">PapiOphidian</a> used <code>/stats</code></sub></blockquote>'
|
||||||
+ `<blockquote><p><strong>Hi, I'm Amanda!</strong></p><p>I condone pirating music!</p></blockquote>`,
|
+ `<blockquote><p><strong>Hi, I'm Amanda!</strong></p><p>I condone pirating music!</p></blockquote>`,
|
||||||
"m.mentions": {}
|
"m.mentions": {}
|
||||||
}])
|
}])
|
||||||
|
|
@ -167,10 +167,10 @@ test("message2event embeds: url without title", async t => {
|
||||||
t.deepEqual(events, [{
|
t.deepEqual(events, [{
|
||||||
$type: "m.room.message",
|
$type: "m.room.message",
|
||||||
msgtype: "m.notice",
|
msgtype: "m.notice",
|
||||||
body: "❭ PapiOphidian used `/stats`"
|
body: "↪️ PapiOphidian used `/stats`"
|
||||||
+ "\n| I condone pirating music!",
|
+ "\n| I condone pirating music!",
|
||||||
format: "org.matrix.custom.html",
|
format: "org.matrix.custom.html",
|
||||||
formatted_body: '<blockquote><sub>❭ <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">PapiOphidian</a> used <code>/stats</code></sub></blockquote>'
|
formatted_body: '<blockquote><sub>↪️ <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">PapiOphidian</a> used <code>/stats</code></sub></blockquote>'
|
||||||
+ `<blockquote><p>I condone pirating music!</p></blockquote>`,
|
+ `<blockquote><p>I condone pirating music!</p></blockquote>`,
|
||||||
"m.mentions": {}
|
"m.mentions": {}
|
||||||
}])
|
}])
|
||||||
|
|
@ -181,10 +181,10 @@ test("message2event embeds: author without url", async t => {
|
||||||
t.deepEqual(events, [{
|
t.deepEqual(events, [{
|
||||||
$type: "m.room.message",
|
$type: "m.room.message",
|
||||||
msgtype: "m.notice",
|
msgtype: "m.notice",
|
||||||
body: "❭ PapiOphidian used `/stats`"
|
body: "↪️ PapiOphidian used `/stats`"
|
||||||
+ "\n| ## Amanda\n| \n| I condone pirating music!",
|
+ "\n| ## Amanda\n| \n| I condone pirating music!",
|
||||||
format: "org.matrix.custom.html",
|
format: "org.matrix.custom.html",
|
||||||
formatted_body: '<blockquote><sub>❭ <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">PapiOphidian</a> used <code>/stats</code></sub></blockquote>'
|
formatted_body: '<blockquote><sub>↪️ <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">PapiOphidian</a> used <code>/stats</code></sub></blockquote>'
|
||||||
+ `<blockquote><p><strong>Amanda</strong></p><p>I condone pirating music!</p></blockquote>`,
|
+ `<blockquote><p><strong>Amanda</strong></p><p>I condone pirating music!</p></blockquote>`,
|
||||||
"m.mentions": {}
|
"m.mentions": {}
|
||||||
}])
|
}])
|
||||||
|
|
@ -195,81 +195,15 @@ test("message2event embeds: author url without name", async t => {
|
||||||
t.deepEqual(events, [{
|
t.deepEqual(events, [{
|
||||||
$type: "m.room.message",
|
$type: "m.room.message",
|
||||||
msgtype: "m.notice",
|
msgtype: "m.notice",
|
||||||
body: "❭ PapiOphidian used `/stats`"
|
body: "↪️ PapiOphidian used `/stats`"
|
||||||
+ "\n| I condone pirating music!",
|
+ "\n| I condone pirating music!",
|
||||||
format: "org.matrix.custom.html",
|
format: "org.matrix.custom.html",
|
||||||
formatted_body: '<blockquote><sub>❭ <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">PapiOphidian</a> used <code>/stats</code></sub></blockquote>'
|
formatted_body: '<blockquote><sub>↪️ <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">PapiOphidian</a> used <code>/stats</code></sub></blockquote>'
|
||||||
+ `<blockquote><p>I condone pirating music!</p></blockquote>`,
|
+ `<blockquote><p>I condone pirating music!</p></blockquote>`,
|
||||||
"m.mentions": {}
|
"m.mentions": {}
|
||||||
}])
|
}])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("message2event embeds: provider and footer", async t => {
|
|
||||||
const events = await messageToEvent(data.message_with_embeds.embed_provider_and_footer, data.guild.general)
|
|
||||||
t.deepEqual(events, [{
|
|
||||||
$type: "m.room.message",
|
|
||||||
msgtype: "m.text",
|
|
||||||
body: "https://cohost.org/jkap/post/4794219-empty",
|
|
||||||
format: "org.matrix.custom.html",
|
|
||||||
formatted_body: `<a href="https://cohost.org/jkap/post/4794219-empty">https://cohost.org/jkap/post/4794219-empty</a>`,
|
|
||||||
"m.mentions": {}
|
|
||||||
}, {
|
|
||||||
$type: "m.room.message",
|
|
||||||
msgtype: "m.notice",
|
|
||||||
body: "| ## This post nerdsniped me, so here's some RULES FOR REAL-LIFE BALATRO https://cohost.org/jkap/post/4794219-empty"
|
|
||||||
+ "\n| \n| 1v1 physical card game. Each player gets one standard deck of cards with a different backing to differentiate. Every turn proceeds as follows:"
|
|
||||||
+ "\n| \n| * Both players draw eight cards"
|
|
||||||
+ "\n| * Both players may choose up to eight cards to discard, then draw that number of cards to put back in their hand"
|
|
||||||
+ "\n| * Both players present their best five-or-less-card pok..."
|
|
||||||
+ "\n| via hthrflwrs on cohost — 29/2/2024, 2:17 PM",
|
|
||||||
format: "org.matrix.custom.html",
|
|
||||||
formatted_body: `<blockquote><p><strong><a href="https://cohost.org/jkap/post/4794219-empty">This post nerdsniped me, so here's some RULES FOR REAL-LIFE BALATRO</a></strong>`
|
|
||||||
+ `</p><p>1v1 physical card game. Each player gets one standard deck of cards with a different backing to differentiate. Every turn proceeds as follows:`
|
|
||||||
+ `<br><br><ul><li>Both players draw eight cards`
|
|
||||||
+ `</li><li>Both players may choose up to eight cards to discard, then draw that number of cards to put back in their hand`
|
|
||||||
+ `</li><li>Both players present their best five-or-less-card pok...</li></ul></p><sub>hthrflwrs on cohost — 29/2/2024, 2:17 PM</sub></blockquote>`,
|
|
||||||
"m.mentions": {}
|
|
||||||
}])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("message2event embeds: 4 images", async t => {
|
|
||||||
const events = await messageToEvent(data.message_with_embeds.four_images, data.guild.general)
|
|
||||||
t.deepEqual(events, [{
|
|
||||||
$type: "m.room.message",
|
|
||||||
msgtype: "m.text",
|
|
||||||
body: "[↷ Forwarded message]\n» https://fixupx.com/i/status/2032003668787020046",
|
|
||||||
format: "org.matrix.custom.html",
|
|
||||||
formatted_body: "↷ <em>Forwarded message</em><br><blockquote><a href=\"https://fixupx.com/i/status/2032003668787020046\">https://fixupx.com/i/status/2032003668787020046</a></blockquote>",
|
|
||||||
"m.mentions": {}
|
|
||||||
}, {
|
|
||||||
$type: "m.room.message",
|
|
||||||
msgtype: "m.notice",
|
|
||||||
body: "» | ## ⏺️ AUTOMATON WEST (@AUTOMATON_ENG) https://x.com/AUTOMATON_ENG/status/2032003668787020046"
|
|
||||||
+ "\n» | "
|
|
||||||
+ "\n» | 4chan owner Hiroyuki, Evangelion director Hideaki Anno and GACKT to participate in “humanity’s last non\\-AI made social network”"
|
|
||||||
+ "\n» | ︀︀"
|
|
||||||
+ "\n» | ︀︀[automaton-media.com/en/news/4chan-owner-hiroyuki-evangelion-director-hideaki-anno-and-gackt-to-participate-in-humanitys-last-non-ai-made-social-network/](https://automaton-media.com/en/news/4chan-owner-hiroyuki-evangelion-director-hideaki-anno-and-gackt-to-participate-in-humanitys-last-non-ai-made-social-network/)"
|
|
||||||
+ "\n» | "
|
|
||||||
+ "\n» | **[💬](https://x.com/intent/tweet?in_reply_to=2032003668787020046) 36 [🔁](https://x.com/intent/retweet?tweet_id=2032003668787020046) 212 [❤](https://x.com/intent/like?tweet_id=2032003668787020046) 3\\.0K 👁 131\\.7K **"
|
|
||||||
+ "\n» | "
|
|
||||||
+ "\n» | 📸 https://pbs.twimg.com/media/HDMUyf6bQAM3yts.jpg?name=orig"
|
|
||||||
+ "\n» | — FixupX"
|
|
||||||
+ "\n» | 📸 https://pbs.twimg.com/media/HDMUgxybQAE4FtJ.jpg?name=orig"
|
|
||||||
+ "\n» | 📸 https://pbs.twimg.com/media/HDMUrPobgAAeb90.jpg?name=orig"
|
|
||||||
+ "\n» | 📸 https://pbs.twimg.com/media/HDMUuy5bgAAInj5.jpg?name=orig",
|
|
||||||
format: "org.matrix.custom.html",
|
|
||||||
formatted_body: "<blockquote><blockquote><p><strong><a href=\"https://x.com/AUTOMATON_ENG/status/2032003668787020046\">⏺️ AUTOMATON WEST (@AUTOMATON_ENG)</a></strong></p>"
|
|
||||||
+ "<p>4chan owner Hiroyuki, Evangelion director Hideaki Anno and GACKT to participate in “humanity’s last non-AI made social network”"
|
|
||||||
+ "<br>︀︀<br>︀︀<a href=\"https://automaton-media.com/en/news/4chan-owner-hiroyuki-evangelion-director-hideaki-anno-and-gackt-to-participate-in-humanitys-last-non-ai-made-social-network/\">automaton-media.com/en/news/4chan-owner-hiroyuki-evangelion-director-hideaki-anno-and-gackt-to-participate-in-humanitys-last-non-ai-made-social-network/</a>"
|
|
||||||
+ "<br><br><strong><a href=\"https://x.com/intent/tweet?in_reply_to=2032003668787020046\">💬</a> 36 <a href=\"https://x.com/intent/retweet?tweet_id=2032003668787020046\">🔁</a> 212 <a href=\"https://x.com/intent/like?tweet_id=2032003668787020046\">❤</a> 3.0K 👁 131.7K </strong></p>"
|
|
||||||
+ "<p>📸 https://pbs.twimg.com/media/HDMUyf6bQAM3yts.jpg?name=orig</p>— FixupX</blockquote>"
|
|
||||||
+ "<p>📸 https://pbs.twimg.com/media/HDMUgxybQAE4FtJ.jpg?name=orig</p>"
|
|
||||||
+ "<p>📸 https://pbs.twimg.com/media/HDMUrPobgAAeb90.jpg?name=orig</p>"
|
|
||||||
+ "<p>📸 https://pbs.twimg.com/media/HDMUuy5bgAAInj5.jpg?name=orig</p></blockquote>",
|
|
||||||
"m.mentions": {}
|
|
||||||
}])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("message2event embeds: vx image", async t => {
|
test("message2event embeds: vx image", async t => {
|
||||||
const events = await messageToEvent(data.message_with_embeds.vx_image, data.guild.general)
|
const events = await messageToEvent(data.message_with_embeds.vx_image, data.guild.general)
|
||||||
t.deepEqual(events, [{
|
t.deepEqual(events, [{
|
||||||
|
|
@ -282,7 +216,9 @@ test("message2event embeds: vx image", async t => {
|
||||||
}, {
|
}, {
|
||||||
$type: "m.room.message",
|
$type: "m.room.message",
|
||||||
msgtype: "m.notice",
|
msgtype: "m.notice",
|
||||||
body: "| ## Twitter https://twitter.com/tomorrowcorp/status/1760330671074287875"
|
body: "| via vxTwitter / fixvx https://github.com/dylanpdx/BetterTwitFix"
|
||||||
|
+ "\n| "
|
||||||
|
+ "\n| ## Twitter https://twitter.com/tomorrowcorp/status/1760330671074287875"
|
||||||
+ "\n| "
|
+ "\n| "
|
||||||
+ "\n| ## Tomorrow Corporation (@TomorrowCorp) https://vxtwitter.com/TomorrowCorp/status/1760330671074287875"
|
+ "\n| ## Tomorrow Corporation (@TomorrowCorp) https://vxtwitter.com/TomorrowCorp/status/1760330671074287875"
|
||||||
+ "\n| "
|
+ "\n| "
|
||||||
|
|
@ -292,16 +228,15 @@ test("message2event embeds: vx image", async t => {
|
||||||
+ "\n| "
|
+ "\n| "
|
||||||
+ "\n| 💖 123 🔁 36"
|
+ "\n| 💖 123 🔁 36"
|
||||||
+ "\n| "
|
+ "\n| "
|
||||||
+ "\n| 📸 https://pbs.twimg.com/media/GG3zUMGbIAAxs3h.jpg"
|
+ "\n| 📸 https://pbs.twimg.com/media/GG3zUMGbIAAxs3h.jpg",
|
||||||
+ "\n| via vxTwitter / fixvx https://github.com/dylanpdx/BetterTwitFix",
|
|
||||||
format: "org.matrix.custom.html",
|
format: "org.matrix.custom.html",
|
||||||
formatted_body: `<blockquote><p><strong><a href="https://twitter.com/tomorrowcorp/status/1760330671074287875">Twitter</a></strong>`
|
formatted_body: `<blockquote><p><sub><a href="https://github.com/dylanpdx/BetterTwitFix">vxTwitter / fixvx</a></sub>`
|
||||||
|
+ `</p><p><strong><a href="https://twitter.com/tomorrowcorp/status/1760330671074287875">Twitter</a></strong>`
|
||||||
+ `</p><p><strong><a href="https://vxtwitter.com/TomorrowCorp/status/1760330671074287875">Tomorrow Corporation (@TomorrowCorp)</a></strong>`
|
+ `</p><p><strong><a href="https://vxtwitter.com/TomorrowCorp/status/1760330671074287875">Tomorrow Corporation (@TomorrowCorp)</a></strong>`
|
||||||
+ `</p><p>Mark your calendar with a wet black stain! World of Goo 2 releases on May 23, 2024 on Nintendo Switch, Epic Games Store (Win/Mac), and <a href="http://WorldOfGoo2.com">http://WorldOfGoo2.com</a> (Win/Mac/Linux).`
|
+ `</p><p>Mark your calendar with a wet black stain! World of Goo 2 releases on May 23, 2024 on Nintendo Switch, Epic Games Store (Win/Mac), and <a href="http://WorldOfGoo2.com">http://WorldOfGoo2.com</a> (Win/Mac/Linux).`
|
||||||
+ `<br><br><a href="https://tomorrowcorporation.com/posts/world-of-goo-2-now-with-100-more-release-dates-and-platforms">https://tomorrowcorporation.com/posts/world-of-goo-2-now-with-100-more-release-dates-and-platforms</a>`
|
+ `<br><br><a href="https://tomorrowcorporation.com/posts/world-of-goo-2-now-with-100-more-release-dates-and-platforms">https://tomorrowcorporation.com/posts/world-of-goo-2-now-with-100-more-release-dates-and-platforms</a>`
|
||||||
+ `<br><br>💖 123 🔁 36`
|
+ `<br><br>💖 123 🔁 36`
|
||||||
+ `</p><p>📸 https://pbs.twimg.com/media/GG3zUMGbIAAxs3h.jpg</p>`
|
+ `</p><p>📸 https://pbs.twimg.com/media/GG3zUMGbIAAxs3h.jpg</p></blockquote>`,
|
||||||
+ `<sub><a href="https://github.com/dylanpdx/BetterTwitFix">vxTwitter / fixvx</a></sub></blockquote>`,
|
|
||||||
"m.mentions": {}
|
"m.mentions": {}
|
||||||
}])
|
}])
|
||||||
})
|
})
|
||||||
|
|
@ -318,16 +253,16 @@ test("message2event embeds: vx video", async t => {
|
||||||
}, {
|
}, {
|
||||||
$type: "m.room.message",
|
$type: "m.room.message",
|
||||||
msgtype: "m.notice",
|
msgtype: "m.notice",
|
||||||
body: "| ## McDonald’s🤝@studiopierrot"
|
body: "| via vxTwitter / fixvx https://github.com/dylanpdx/BetterTwitFix"
|
||||||
|
+ "\n| \n| ## McDonald’s🤝@studiopierrot"
|
||||||
+ "\n| \n| 💖 89 🔁 21 https://twitter.com/McDonalds/status/1759971752254341417"
|
+ "\n| \n| 💖 89 🔁 21 https://twitter.com/McDonalds/status/1759971752254341417"
|
||||||
+ "\n| \n| ## McDonald's (@McDonalds) https://vxtwitter.com/McDonalds/status/1759971752254341417"
|
+ "\n| \n| ## McDonald's (@McDonalds) https://vxtwitter.com/McDonalds/status/1759971752254341417"
|
||||||
+ "\n| \n| 🎞️ https://video.twimg.com/ext_tw_video/1759967449548541952/pu/vid/avc1/1280x720/XN1LFIJqAFBdtaoh.mp4?tag=12"
|
+ "\n| \n| 🎞️ https://video.twimg.com/ext_tw_video/1759967449548541952/pu/vid/avc1/1280x720/XN1LFIJqAFBdtaoh.mp4?tag=12",
|
||||||
+ "\n| via vxTwitter / fixvx https://github.com/dylanpdx/BetterTwitFix",
|
|
||||||
format: "org.matrix.custom.html",
|
format: "org.matrix.custom.html",
|
||||||
formatted_body: `<blockquote><p><strong><a href="https://twitter.com/McDonalds/status/1759971752254341417">McDonald’s🤝@studiopierrot\n\n💖 89 🔁 21</a></strong>`
|
formatted_body: `<blockquote><p><sub><a href="https://github.com/dylanpdx/BetterTwitFix">vxTwitter / fixvx</a></sub>`
|
||||||
|
+ `</p><p><strong><a href="https://twitter.com/McDonalds/status/1759971752254341417">McDonald’s🤝@studiopierrot\n\n💖 89 🔁 21</a></strong>`
|
||||||
+ `</p><p><strong><a href="https://vxtwitter.com/McDonalds/status/1759971752254341417">McDonald's (@McDonalds)</a></strong>`
|
+ `</p><p><strong><a href="https://vxtwitter.com/McDonalds/status/1759971752254341417">McDonald's (@McDonalds)</a></strong>`
|
||||||
+ `</p><p>🎞️ https://video.twimg.com/ext_tw_video/1759967449548541952/pu/vid/avc1/1280x720/XN1LFIJqAFBdtaoh.mp4?tag=12</p>`
|
+ `</p><p>🎞️ https://video.twimg.com/ext_tw_video/1759967449548541952/pu/vid/avc1/1280x720/XN1LFIJqAFBdtaoh.mp4?tag=12</p></blockquote>`,
|
||||||
+ `<sub><a href="https://github.com/dylanpdx/BetterTwitFix">vxTwitter / fixvx</a></sub></blockquote>`,
|
|
||||||
"m.mentions": {}
|
"m.mentions": {}
|
||||||
}])
|
}])
|
||||||
})
|
})
|
||||||
|
|
@ -344,7 +279,8 @@ test("message2event embeds: youtube video", async t => {
|
||||||
}, {
|
}, {
|
||||||
$type: "m.room.message",
|
$type: "m.room.message",
|
||||||
msgtype: "m.notice",
|
msgtype: "m.notice",
|
||||||
body: "| ## Happy O Funny https://www.youtube.com/channel/UCEpQ9aEb1NafpvWp5Aoizrg"
|
body: "| via YouTube https://www.youtube.com"
|
||||||
|
+ "\n| \n| ## Happy O Funny https://www.youtube.com/channel/UCEpQ9aEb1NafpvWp5Aoizrg"
|
||||||
+ "\n| \n| ## Shoebill stork clattering sounds like machine guun~!! (Japan Matsue... https://www.youtube.com/watch?v=kDMHHw8JqLE"
|
+ "\n| \n| ## Shoebill stork clattering sounds like machine guun~!! (Japan Matsue... https://www.youtube.com/watch?v=kDMHHw8JqLE"
|
||||||
+ "\n| \n| twitter"
|
+ "\n| \n| twitter"
|
||||||
+ "\n| https://twitter.com/matsuevogelpark"
|
+ "\n| https://twitter.com/matsuevogelpark"
|
||||||
|
|
@ -353,7 +289,8 @@ test("message2event embeds: youtube video", async t => {
|
||||||
+ "\n| \n| #shoebill #livingdinosaur #happyofunny #weirdcreature #weirdsoun..."
|
+ "\n| \n| #shoebill #livingdinosaur #happyofunny #weirdcreature #weirdsoun..."
|
||||||
+ "\n| \n| 🎞️ https://www.youtube.com/embed/kDMHHw8JqLE",
|
+ "\n| \n| 🎞️ https://www.youtube.com/embed/kDMHHw8JqLE",
|
||||||
format: "org.matrix.custom.html",
|
format: "org.matrix.custom.html",
|
||||||
formatted_body: `<blockquote><p><strong><a href="https://www.youtube.com/channel/UCEpQ9aEb1NafpvWp5Aoizrg">Happy O Funny</a></strong>`
|
formatted_body: `<blockquote><p><sub><a href="https://www.youtube.com">YouTube</a></sub></p>`
|
||||||
|
+ `<p><strong><a href="https://www.youtube.com/channel/UCEpQ9aEb1NafpvWp5Aoizrg">Happy O Funny</a></strong>`
|
||||||
+ `</p><p><strong><a href="https://www.youtube.com/watch?v=kDMHHw8JqLE">Shoebill stork clattering sounds like machine guun~!! (Japan Matsue...</a></strong>`
|
+ `</p><p><strong><a href="https://www.youtube.com/watch?v=kDMHHw8JqLE">Shoebill stork clattering sounds like machine guun~!! (Japan Matsue...</a></strong>`
|
||||||
+ `</p><p>twitter<br><a href="https://twitter.com/matsuevogelpark">https://twitter.com/matsuevogelpark</a><br><br>The shoebill (Balaeniceps rex) also known as whalehead, whale-headed stork, or shoe-billed stork, is a very large stork-like bird. It derives its name from its enormous shoe-shaped bill<br>some people also called them the living dinosaur~~<br><br>#shoebill #livingdinosaur #happyofunny #weirdcreature #weirdsoun...`
|
+ `</p><p>twitter<br><a href="https://twitter.com/matsuevogelpark">https://twitter.com/matsuevogelpark</a><br><br>The shoebill (Balaeniceps rex) also known as whalehead, whale-headed stork, or shoe-billed stork, is a very large stork-like bird. It derives its name from its enormous shoe-shaped bill<br>some people also called them the living dinosaur~~<br><br>#shoebill #livingdinosaur #happyofunny #weirdcreature #weirdsoun...`
|
||||||
+ `</p><p>🎞️ https://www.youtube.com/embed/kDMHHw8JqLE`
|
+ `</p><p>🎞️ https://www.youtube.com/embed/kDMHHw8JqLE`
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ const {MatrixServerError} = require("../../matrix/mreq")
|
||||||
const data = require("../../../test/data")
|
const data = require("../../../test/data")
|
||||||
const {mockGetEffectivePower} = require("../../matrix/utils.test")
|
const {mockGetEffectivePower} = require("../../matrix/utils.test")
|
||||||
const Ty = require("../../types")
|
const Ty = require("../../types")
|
||||||
const {db} = require("../../passthrough")
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} roomID
|
* @param {string} roomID
|
||||||
|
|
@ -734,31 +733,6 @@ test("message2event: reply to a Discord message that wasn't bridged", async t =>
|
||||||
}])
|
}])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("message2event: reply to a Discord member join (who didn't join on Matrix)", async t => {
|
|
||||||
const events = await messageToEvent(data.message.reply_to_member_join, data.guild.general)
|
|
||||||
t.deepEqual(events, [{
|
|
||||||
$type: "m.room.message",
|
|
||||||
msgtype: "m.text",
|
|
||||||
body: "> PEASANT!! joined the room\n\nwhen the broke friend who we pay to bring food shows up at the medieval lord party",
|
|
||||||
format: "org.matrix.custom.html",
|
|
||||||
formatted_body: "<blockquote><strong>PEASANT!!</strong> joined the room</blockquote>when the broke friend who we pay to bring food shows up at the medieval lord party",
|
|
||||||
"m.mentions": {}
|
|
||||||
}])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("message2event: reply to a Discord member join (who did join on Matrix)", async t => {
|
|
||||||
db.prepare("INSERT INTO sim (user_id, username, sim_name, mxid) VALUES ('1461677775554478161', 'peasant321_76775', 'peasant321_76775', '@_ooye_peasant321_76775:cadence.moe')").run()
|
|
||||||
const events = await messageToEvent(data.message.reply_to_member_join, data.guild.general)
|
|
||||||
t.deepEqual(events, [{
|
|
||||||
$type: "m.room.message",
|
|
||||||
msgtype: "m.text",
|
|
||||||
body: "> PEASANT!! joined the room\n\nwhen the broke friend who we pay to bring food shows up at the medieval lord party",
|
|
||||||
format: "org.matrix.custom.html",
|
|
||||||
formatted_body: `<blockquote><a href="https://matrix.to/#/@_ooye_peasant321_76775:cadence.moe">PEASANT!!</a> joined the room</blockquote>when the broke friend who we pay to bring food shows up at the medieval lord party`,
|
|
||||||
"m.mentions": {}
|
|
||||||
}])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("message2event: simple written @mention for matrix user", async t => {
|
test("message2event: simple written @mention for matrix user", async t => {
|
||||||
const events = await messageToEvent(data.message.simple_written_at_mention_for_matrix, data.guild.general, {}, {
|
const events = await messageToEvent(data.message.simple_written_at_mention_for_matrix, data.guild.general, {}, {
|
||||||
api: {
|
api: {
|
||||||
|
|
@ -815,7 +789,7 @@ test("message2event: simple written @mention for matrix user", async t => {
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
msgtype: "m.text",
|
msgtype: "m.text",
|
||||||
body: "@ash do you need anything from the store btw as I'm heading there after gym",
|
body: "[@ash](https://matrix.to/#/@she_who_brings_destruction:cadence.moe) do you need anything from the store btw as I'm heading there after gym",
|
||||||
format: "org.matrix.custom.html",
|
format: "org.matrix.custom.html",
|
||||||
formatted_body: `<a href="https://matrix.to/#/@she_who_brings_destruction:cadence.moe">@ash</a> do you need anything from the store btw as I'm heading there after gym`
|
formatted_body: `<a href="https://matrix.to/#/@she_who_brings_destruction:cadence.moe">@ash</a> do you need anything from the store btw as I'm heading there after gym`
|
||||||
}])
|
}])
|
||||||
|
|
@ -864,7 +838,7 @@ test("message2event: many written @mentions for matrix users", async t => {
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
msgtype: "m.text",
|
msgtype: "m.text",
|
||||||
body: "@Cadence, tell me about @Phil, the creator of the Chin Trick, who has become ever more powerful under the mentorship of @botrac4r and @huck",
|
body: "[@Cadence](https://matrix.to/#/@cadence:cadence.moe), tell me about @Phil, the creator of the Chin Trick, who has become ever more powerful under the mentorship of @botrac4r and [@huck](https://matrix.to/#/@huckleton:cadence.moe)",
|
||||||
format: "org.matrix.custom.html",
|
format: "org.matrix.custom.html",
|
||||||
formatted_body: `<a href="https://matrix.to/#/@cadence:cadence.moe">@Cadence</a>, tell me about @Phil, the creator of the Chin Trick, who has become ever more powerful under the mentorship of @botrac4r and <a href="https://matrix.to/#/@huckleton:cadence.moe">@huck</a>`
|
formatted_body: `<a href="https://matrix.to/#/@cadence:cadence.moe">@Cadence</a>, tell me about @Phil, the creator of the Chin Trick, who has become ever more powerful under the mentorship of @botrac4r and <a href="https://matrix.to/#/@huckleton:cadence.moe">@huck</a>`
|
||||||
}])
|
}])
|
||||||
|
|
@ -916,7 +890,7 @@ test("message2event: written @mentions may match part of the name", async t => {
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
msgtype: "m.text",
|
msgtype: "m.text",
|
||||||
body: "I wonder if @cadence saw this?",
|
body: "I wonder if [@cadence](https://matrix.to/#/@secret:cadence.moe) saw this?",
|
||||||
format: "org.matrix.custom.html",
|
format: "org.matrix.custom.html",
|
||||||
formatted_body: `I wonder if <a href="https://matrix.to/#/@secret:cadence.moe">@cadence</a> saw this?`
|
formatted_body: `I wonder if <a href="https://matrix.to/#/@secret:cadence.moe">@cadence</a> saw this?`
|
||||||
}])
|
}])
|
||||||
|
|
@ -967,7 +941,7 @@ test("message2event: written @mentions may match part of the mxid", async t => {
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
msgtype: "m.text",
|
msgtype: "m.text",
|
||||||
body: "I wonder if @huck saw this?",
|
body: "I wonder if [@huck](https://matrix.to/#/@huckleton:cadence.moe) saw this?",
|
||||||
format: "org.matrix.custom.html",
|
format: "org.matrix.custom.html",
|
||||||
formatted_body: `I wonder if <a href="https://matrix.to/#/@huckleton:cadence.moe">@huck</a> saw this?`
|
formatted_body: `I wonder if <a href="https://matrix.to/#/@huckleton:cadence.moe">@huck</a> saw this?`
|
||||||
}])
|
}])
|
||||||
|
|
@ -988,36 +962,6 @@ test("message2event: written @mentions do not match in URLs", async t => {
|
||||||
}])
|
}])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("message2event: written @mentions do not match in inline code", async t => {
|
|
||||||
const events = await messageToEvent({
|
|
||||||
...data.message.advanced_written_at_mention_for_matrix,
|
|
||||||
content: "`public @Nullable EntityType<?>`"
|
|
||||||
}, data.guild.general, {}, {})
|
|
||||||
t.deepEqual(events, [{
|
|
||||||
$type: "m.room.message",
|
|
||||||
"m.mentions": {},
|
|
||||||
msgtype: "m.text",
|
|
||||||
body: "`public @Nullable EntityType<?>`",
|
|
||||||
format: "org.matrix.custom.html",
|
|
||||||
formatted_body: `<code>public @Nullable EntityType<?></code>`
|
|
||||||
}])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("message2event: written @mentions do not match in code block", async t => {
|
|
||||||
const events = await messageToEvent({
|
|
||||||
...data.message.advanced_written_at_mention_for_matrix,
|
|
||||||
content: "```java\npublic @Nullable EntityType<?>\n```"
|
|
||||||
}, data.guild.general, {}, {})
|
|
||||||
t.deepEqual(events, [{
|
|
||||||
$type: "m.room.message",
|
|
||||||
"m.mentions": {},
|
|
||||||
msgtype: "m.text",
|
|
||||||
body: "```java\npublic @Nullable EntityType<?>\n```",
|
|
||||||
format: "org.matrix.custom.html",
|
|
||||||
formatted_body: `<pre><code class="language-java">public @Nullable EntityType<?></code></pre>`
|
|
||||||
}])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("message2event: entire message may match elaborate display name", async t => {
|
test("message2event: entire message may match elaborate display name", async t => {
|
||||||
let called = 0
|
let called = 0
|
||||||
const events = await messageToEvent({
|
const events = await messageToEvent({
|
||||||
|
|
@ -1063,7 +1007,7 @@ test("message2event: entire message may match elaborate display name", async t =
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
msgtype: "m.text",
|
msgtype: "m.text",
|
||||||
body: "@Cadence, Maid of Creation, Eye of Clarity, Empress of Hope ☆",
|
body: "[@Cadence, Maid of Creation, Eye of Clarity, Empress of Hope ☆](https://matrix.to/#/@wa:cadence.moe)",
|
||||||
format: "org.matrix.custom.html",
|
format: "org.matrix.custom.html",
|
||||||
formatted_body: `<a href="https://matrix.to/#/@wa:cadence.moe">@Cadence, Maid of Creation, Eye of Clarity, Empress of Hope ☆</a>`
|
formatted_body: `<a href="https://matrix.to/#/@wa:cadence.moe">@Cadence, Maid of Creation, Eye of Clarity, Empress of Hope ☆</a>`
|
||||||
}])
|
}])
|
||||||
|
|
@ -1140,7 +1084,7 @@ test("message2event: multiple attachments are combined into the same event where
|
||||||
formatted_body: "hey"
|
formatted_body: "hey"
|
||||||
+ `<br>📄 Uploaded file: <a href="https://bridge.example.org/download/discordcdn/123/456/789.mega">hey.jpg</a> (100 MB)`
|
+ `<br>📄 Uploaded file: <a href="https://bridge.example.org/download/discordcdn/123/456/789.mega">hey.jpg</a> (100 MB)`
|
||||||
+ `<br><blockquote>📸 Uploaded SPOILER file: <a href="https://bridge.example.org/download/discordcdn/123/456/SPOILER_secret.jpg">https://bridge.example.org/download/discordcdn/123/456/SPOILER_secret.jpg</a> (38 KB)</blockquote>`
|
+ `<br><blockquote>📸 Uploaded SPOILER file: <a href="https://bridge.example.org/download/discordcdn/123/456/SPOILER_secret.jpg">https://bridge.example.org/download/discordcdn/123/456/SPOILER_secret.jpg</a> (38 KB)</blockquote>`
|
||||||
+ `📄 Uploaded file: <a href="https://bridge.example.org/download/discordcdn/123/456/789.mega">hey.jpg</a> (100 MB)`
|
+ `<br>📄 Uploaded file: <a href="https://bridge.example.org/download/discordcdn/123/456/789.mega">hey.jpg</a> (100 MB)`
|
||||||
}, {
|
}, {
|
||||||
$type: "m.room.message",
|
$type: "m.room.message",
|
||||||
"m.mentions": {},
|
"m.mentions": {},
|
||||||
|
|
@ -1168,19 +1112,6 @@ test("message2event: type 4 channel name change", async t => {
|
||||||
}])
|
}])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("message2event: type 12 channel follow add", async t => {
|
|
||||||
const events = await messageToEvent(data.special_message.channel_follow_add, data.guild.general)
|
|
||||||
t.deepEqual(events, [{
|
|
||||||
$type: "m.room.message",
|
|
||||||
"m.mentions": {},
|
|
||||||
msgtype: "m.emote",
|
|
||||||
body: "set this room to receive announcements from PluralKit #downtime",
|
|
||||||
format: "org.matrix.custom.html",
|
|
||||||
formatted_body: "set this room to receive announcements from <strong>PluralKit #downtime</strong>",
|
|
||||||
"m.mentions": {}
|
|
||||||
}])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("message2event: thread start message reference", async t => {
|
test("message2event: thread start message reference", async t => {
|
||||||
const events = await messageToEvent(data.special_message.thread_start_context, data.guild.general, {}, {
|
const events = await messageToEvent(data.special_message.thread_start_context, data.guild.general, {}, {
|
||||||
api: {
|
api: {
|
||||||
|
|
@ -1273,9 +1204,9 @@ test("message2event: crossposted announcements say where they are crossposted fr
|
||||||
$type: "m.room.message",
|
$type: "m.room.message",
|
||||||
"m.mentions": {},
|
"m.mentions": {},
|
||||||
msgtype: "m.text",
|
msgtype: "m.text",
|
||||||
body: "[↷ Chewey Bot Official Server #announcements]\nAll text based commands are now inactive on Chewey Bot\nTo continue using commands you'll need to use them as slash commands",
|
body: "[🔀 Chewey Bot Official Server #announcements]\nAll text based commands are now inactive on Chewey Bot\nTo continue using commands you'll need to use them as slash commands",
|
||||||
format: "org.matrix.custom.html",
|
format: "org.matrix.custom.html",
|
||||||
formatted_body: "↷ <strong>Chewey Bot Official Server #announcements</strong><br>All text based commands are now inactive on Chewey Bot<br>To continue using commands you'll need to use them as slash commands"
|
formatted_body: "🔀 <strong>Chewey Bot Official Server #announcements</strong><br>All text based commands are now inactive on Chewey Bot<br>To continue using commands you'll need to use them as slash commands"
|
||||||
}])
|
}])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -1344,9 +1275,9 @@ test("message2event: forwarded image", async t => {
|
||||||
t.deepEqual(events, [
|
t.deepEqual(events, [
|
||||||
{
|
{
|
||||||
$type: "m.room.message",
|
$type: "m.room.message",
|
||||||
body: "[↷ Forwarded message]",
|
body: "[🔀 Forwarded message]",
|
||||||
format: "org.matrix.custom.html",
|
format: "org.matrix.custom.html",
|
||||||
formatted_body: "↷ <em>Forwarded message</em>",
|
formatted_body: "🔀 <em>Forwarded message</em>",
|
||||||
"m.mentions": {},
|
"m.mentions": {},
|
||||||
msgtype: "m.notice",
|
msgtype: "m.notice",
|
||||||
},
|
},
|
||||||
|
|
@ -1385,10 +1316,10 @@ test("message2event: constructed forwarded message", async t => {
|
||||||
t.deepEqual(events, [
|
t.deepEqual(events, [
|
||||||
{
|
{
|
||||||
$type: "m.room.message",
|
$type: "m.room.message",
|
||||||
body: "[↷ Forwarded from #wonderland]"
|
body: "[🔀 Forwarded from #wonderland]"
|
||||||
+ "\n» What's cooking, good looking? :hipposcope:",
|
+ "\n» What's cooking, good looking? :hipposcope:",
|
||||||
format: "org.matrix.custom.html",
|
format: "org.matrix.custom.html",
|
||||||
formatted_body: `↷ <em>Forwarded from wonderland <a href="https://matrix.to/#/!qzDBLKlildpzrrOnFZ:cadence.moe/$tBIT8mO7XTTCgIINyiAIy6M2MSoPAdJenRl_RLyYuaE?via=cadence.moe&via=matrix.org">[jump to event]</a></em>`
|
formatted_body: `🔀 <em>Forwarded from wonderland <a href="https://matrix.to/#/!qzDBLKlildpzrrOnFZ:cadence.moe/$tBIT8mO7XTTCgIINyiAIy6M2MSoPAdJenRl_RLyYuaE?via=cadence.moe&via=matrix.org">[jump to event]</a></em>`
|
||||||
+ `<br><blockquote>What's cooking, good looking? <img data-mx-emoticon height="32" src="mxc://cadence.moe/WbYqNlACRuicynBfdnPYtmvc" title=":hipposcope:" alt=":hipposcope:"></blockquote>`,
|
+ `<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": {},
|
"m.mentions": {},
|
||||||
msgtype: "m.text",
|
msgtype: "m.text",
|
||||||
|
|
@ -1444,10 +1375,10 @@ test("message2event: constructed forwarded text", async t => {
|
||||||
t.deepEqual(events, [
|
t.deepEqual(events, [
|
||||||
{
|
{
|
||||||
$type: "m.room.message",
|
$type: "m.room.message",
|
||||||
body: "[↷ Forwarded from #amanda-spam]"
|
body: "[🔀 Forwarded from #amanda-spam]"
|
||||||
+ "\n» What's cooking, good looking?",
|
+ "\n» What's cooking, good looking?",
|
||||||
format: "org.matrix.custom.html",
|
format: "org.matrix.custom.html",
|
||||||
formatted_body: `↷ <em>Forwarded from amanda-spam <a href="https://matrix.to/#/!CzvdIdUQXgUjDVKxeU:cadence.moe?via=cadence.moe&via=matrix.org">[jump to room]</a></em>`
|
formatted_body: `🔀 <em>Forwarded from amanda-spam <a href="https://matrix.to/#/!CzvdIdUQXgUjDVKxeU:cadence.moe?via=cadence.moe&via=matrix.org">[jump to room]</a></em>`
|
||||||
+ `<br><blockquote>What's cooking, good looking?</blockquote>`,
|
+ `<br><blockquote>What's cooking, good looking?</blockquote>`,
|
||||||
"m.mentions": {},
|
"m.mentions": {},
|
||||||
msgtype: "m.text",
|
msgtype: "m.text",
|
||||||
|
|
@ -1467,10 +1398,10 @@ test("message2event: don't scan forwarded messages for mentions", async t => {
|
||||||
t.deepEqual(events, [
|
t.deepEqual(events, [
|
||||||
{
|
{
|
||||||
$type: "m.room.message",
|
$type: "m.room.message",
|
||||||
body: "[↷ Forwarded message]"
|
body: "[🔀 Forwarded message]"
|
||||||
+ "\n» If some folks have spare bandwidth then helping out ArchiveTeam with archiving soon to be deleted research and government data might be worthwhile https://social.luca.run/@luca/113950834185678114",
|
+ "\n» If some folks have spare bandwidth then helping out ArchiveTeam with archiving soon to be deleted research and government data might be worthwhile https://social.luca.run/@luca/113950834185678114",
|
||||||
format: "org.matrix.custom.html",
|
format: "org.matrix.custom.html",
|
||||||
formatted_body: `↷ <em>Forwarded message</em>`
|
formatted_body: `🔀 <em>Forwarded message</em>`
|
||||||
+ `<br><blockquote>If some folks have spare bandwidth then helping out ArchiveTeam with archiving soon to be deleted research and government data might be worthwhile <a href="https://social.luca.run/@luca/113950834185678114">https://social.luca.run/@luca/113950834185678114</a></blockquote>`,
|
+ `<br><blockquote>If some folks have spare bandwidth then helping out ArchiveTeam with archiving soon to be deleted research and government data might be worthwhile <a href="https://social.luca.run/@luca/113950834185678114">https://social.luca.run/@luca/113950834185678114</a></blockquote>`,
|
||||||
"m.mentions": {},
|
"m.mentions": {},
|
||||||
msgtype: "m.text"
|
msgtype: "m.text"
|
||||||
|
|
@ -1607,28 +1538,6 @@ test("message2event: vc invite event renders embed with room link", async t => {
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("message2event: expired/invalid invites are sent as-is", async t => {
|
|
||||||
const events = await messageToEvent({content: "https://discord.gg/placeholder?event=1381190945646710824"}, {}, {}, {
|
|
||||||
snow: {
|
|
||||||
invite: {
|
|
||||||
async getInvite() {
|
|
||||||
throw new Error(`{"message": "Unknown Invite", "code": 10006}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
t.deepEqual(events, [
|
|
||||||
{
|
|
||||||
$type: "m.room.message",
|
|
||||||
body: "https://discord.gg/placeholder?event=1381190945646710824",
|
|
||||||
format: "org.matrix.custom.html",
|
|
||||||
formatted_body: "<a href=\"https://discord.gg/placeholder?event=1381190945646710824\">https://discord.gg/placeholder?event=1381190945646710824</a>",
|
|
||||||
"m.mentions": {},
|
|
||||||
msgtype: "m.text",
|
|
||||||
}
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("message2event: channel links are converted even inside lists (parser post-processer descends into list items)", async t => {
|
test("message2event: channel links are converted even inside lists (parser post-processer descends into list items)", async t => {
|
||||||
let called = 0
|
let called = 0
|
||||||
const events = await messageToEvent({
|
const events = await messageToEvent({
|
||||||
|
|
@ -1820,9 +1729,9 @@ test("message2event: forwarded message with unreferenced mention", async t => {
|
||||||
t.deepEqual(events, [{
|
t.deepEqual(events, [{
|
||||||
$type: "m.room.message",
|
$type: "m.room.message",
|
||||||
msgtype: "m.text",
|
msgtype: "m.text",
|
||||||
body: "[↷ Forwarded message]\n» @unknown-user:\n» 🎞️ Uploaded file: https://bridge.example.org/download/discordcdn/893634327722721290/1463174815119704114/2022-10-18_16-49-46.mp4 (51 MB)",
|
body: "[🔀 Forwarded message]\n» @unknown-user:\n» 🎞️ Uploaded file: https://bridge.example.org/download/discordcdn/893634327722721290/1463174815119704114/2022-10-18_16-49-46.mp4 (51 MB)",
|
||||||
format: "org.matrix.custom.html",
|
format: "org.matrix.custom.html",
|
||||||
formatted_body: "↷ <em>Forwarded message</em><br><blockquote>@unknown-user:<br>🎞️ Uploaded file: <a href=\"https://bridge.example.org/download/discordcdn/893634327722721290/1463174815119704114/2022-10-18_16-49-46.mp4\">2022-10-18_16-49-46.mp4</a> (51 MB)</blockquote>",
|
formatted_body: "🔀 <em>Forwarded message</em><br><blockquote>@unknown-user:<br>🎞️ Uploaded file: <a href=\"https://bridge.example.org/download/discordcdn/893634327722721290/1463174815119704114/2022-10-18_16-49-46.mp4\">2022-10-18_16-49-46.mp4</a> (51 MB)</blockquote>",
|
||||||
"m.mentions": {}
|
"m.mentions": {}
|
||||||
}])
|
}])
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ function pinsToList(pins, kstate) {
|
||||||
/** @type {string[]} */
|
/** @type {string[]} */
|
||||||
const result = []
|
const result = []
|
||||||
for (const pin of pins.items) {
|
for (const pin of pins.items) {
|
||||||
const eventID = select("event_message", "event_id", {message_id: pin.message.id}, "ORDER BY part ASC").pluck().get()
|
const eventID = select("event_message", "event_id", {message_id: pin.message.id, part: 0}).pluck().get()
|
||||||
if (eventID && !alreadyPinned.includes(eventID)) result.push(eventID)
|
if (eventID && !alreadyPinned.includes(eventID)) result.push(eventID)
|
||||||
}
|
}
|
||||||
result.reverse()
|
result.reverse()
|
||||||
|
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
// @ts-check
|
|
||||||
|
|
||||||
const passthrough = require("../../passthrough")
|
|
||||||
const {db, select, from} = passthrough
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} userID discord user ID that left
|
|
||||||
* @param {string} guildID discord guild ID that they left
|
|
||||||
*/
|
|
||||||
function removeMemberMxids(userID, guildID) {
|
|
||||||
// Get sims for user and remove
|
|
||||||
let membership = from("sim").join("sim_member", "mxid").join("channel_room", "room_id")
|
|
||||||
.select("room_id", "mxid").where({user_id: userID, guild_id: guildID}).and("ORDER BY room_id, mxid").all()
|
|
||||||
membership = membership.concat(from("sim_proxy").join("sim", "user_id").join("sim_member", "mxid").join("channel_room", "room_id")
|
|
||||||
.select("room_id", "mxid").where({proxy_owner_id: userID, guild_id: guildID}).and("ORDER BY room_id, mxid").all())
|
|
||||||
|
|
||||||
// Get user installed apps and remove
|
|
||||||
/** @type {string[]} */
|
|
||||||
let userAppDeletions = []
|
|
||||||
// 1. Select apps that have 1 user remaining
|
|
||||||
/** @type {Set<string>} */
|
|
||||||
const appsWithOneUser = new Set(db.prepare("SELECT app_bot_id FROM app_user_install WHERE guild_id = ? GROUP BY app_bot_id HAVING count(*) = 1").pluck().all(guildID))
|
|
||||||
// 2. Select apps installed by this user
|
|
||||||
const appsFromThisUser = new Set(select("app_user_install", "app_bot_id", {guild_id: guildID, user_id: userID}).pluck().all())
|
|
||||||
if (appsFromThisUser.size) userAppDeletions.push(userID)
|
|
||||||
// Then remove user installed apps if this was the last user with them
|
|
||||||
const appsToRemove = appsWithOneUser.intersection(appsFromThisUser)
|
|
||||||
for (const botID of appsToRemove) {
|
|
||||||
// Remove sims for user installed app
|
|
||||||
const appRemoval = removeMemberMxids(botID, guildID)
|
|
||||||
membership = membership.concat(appRemoval.membership)
|
|
||||||
userAppDeletions = userAppDeletions.concat(appRemoval.userAppDeletions)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {membership, userAppDeletions}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports.removeMemberMxids = removeMemberMxids
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
// @ts-check
|
|
||||||
|
|
||||||
const {test} = require("supertape")
|
|
||||||
const {removeMemberMxids} = require("./remove-member-mxids")
|
|
||||||
|
|
||||||
test("remove member mxids: would remove mxid for all rooms in this server", t => {
|
|
||||||
t.deepEqual(removeMemberMxids("772659086046658620", "112760669178241024"), {
|
|
||||||
userAppDeletions: [],
|
|
||||||
membership: [{
|
|
||||||
mxid: "@_ooye_cadence:cadence.moe",
|
|
||||||
room_id: "!fGgIymcYWOqjbSRUdV:cadence.moe"
|
|
||||||
}, {
|
|
||||||
mxid: "@_ooye_cadence:cadence.moe",
|
|
||||||
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe"
|
|
||||||
}]
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("remove member mxids: removes sims too", t => {
|
|
||||||
t.deepEqual(removeMemberMxids("196188877885538304", "112760669178241024"), {
|
|
||||||
userAppDeletions: [],
|
|
||||||
membership: [{
|
|
||||||
mxid: '@_ooye_ampflower:cadence.moe',
|
|
||||||
room_id: '!qzDBLKlildpzrrOnFZ:cadence.moe'
|
|
||||||
}, {
|
|
||||||
mxid: '@_ooye__pk_zoego:cadence.moe',
|
|
||||||
room_id: '!qzDBLKlildpzrrOnFZ:cadence.moe'
|
|
||||||
}]
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("remove member mxids: removes apps too", t => {
|
|
||||||
t.deepEqual(removeMemberMxids("197126718400626689", "66192955777486848"), {
|
|
||||||
userAppDeletions: ["197126718400626689"],
|
|
||||||
membership: [{
|
|
||||||
mxid: '@_ooye_infinidoge1337:cadence.moe',
|
|
||||||
room_id: '!BnKuBPCvyfOkhcUjEu:cadence.moe'
|
|
||||||
}, {
|
|
||||||
mxid: '@_ooye_evil_lillith_sheher:cadence.moe',
|
|
||||||
room_id: '!BnKuBPCvyfOkhcUjEu:cadence.moe'
|
|
||||||
}]
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -34,7 +34,7 @@ function removeReaction(data, reactions, key) {
|
||||||
// Even though the bridge bot only reacted once on Discord-side, multiple Matrix users may have
|
// Even though the bridge bot only reacted once on Discord-side, multiple Matrix users may have
|
||||||
// reacted on Matrix-side. Semantically, we want to remove the reaction from EVERY Matrix user.
|
// reacted on Matrix-side. Semantically, we want to remove the reaction from EVERY Matrix user.
|
||||||
// Also need to clean up the database.
|
// Also need to clean up the database.
|
||||||
const hash = utils.getEventIDHash(eventID)
|
const hash = utils.getEventIDHash(event.event_id)
|
||||||
removals.push({eventID, mxid: null, hash})
|
removals.push({eventID, mxid: null, hash})
|
||||||
}
|
}
|
||||||
if (!lookingAtMatrixReaction && !wantToRemoveMatrixReaction) {
|
if (!lookingAtMatrixReaction && !wantToRemoveMatrixReaction) {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
const {test} = require("supertape")
|
const {test} = require("supertape")
|
||||||
const {tryToCatch} = require("try-to-catch")
|
const tryToCatch = require("try-to-catch")
|
||||||
const assert = require("assert")
|
const assert = require("assert")
|
||||||
const data = require("../../../test/data")
|
const data = require("../../../test/data")
|
||||||
const {userToSimName, webhookAuthorToSimName} = require("./user-to-mxid")
|
const {userToSimName, webhookAuthorToSimName} = require("./user-to-mxid")
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,6 @@ const {sync} = passthrough
|
||||||
/** @type {import("./discord-packets")} */
|
/** @type {import("./discord-packets")} */
|
||||||
const discordPackets = sync.require("./discord-packets")
|
const discordPackets = sync.require("./discord-packets")
|
||||||
|
|
||||||
const CONNECTION_DEBUG = false
|
|
||||||
|
|
||||||
class DiscordClient {
|
class DiscordClient {
|
||||||
/**
|
/**
|
||||||
* @param {string} discordToken
|
* @param {string} discordToken
|
||||||
|
|
@ -26,7 +24,7 @@ class DiscordClient {
|
||||||
const intents = [
|
const intents = [
|
||||||
"DIRECT_MESSAGES", "DIRECT_MESSAGE_REACTIONS", "DIRECT_MESSAGE_TYPING",
|
"DIRECT_MESSAGES", "DIRECT_MESSAGE_REACTIONS", "DIRECT_MESSAGE_TYPING",
|
||||||
"GUILDS", "GUILD_EMOJIS_AND_STICKERS", "GUILD_MESSAGES", "GUILD_MESSAGE_REACTIONS", "GUILD_MESSAGE_TYPING", "GUILD_WEBHOOKS", "GUILD_MESSAGE_POLLS",
|
"GUILDS", "GUILD_EMOJIS_AND_STICKERS", "GUILD_MESSAGES", "GUILD_MESSAGE_REACTIONS", "GUILD_MESSAGE_TYPING", "GUILD_WEBHOOKS", "GUILD_MESSAGE_POLLS",
|
||||||
"MESSAGE_CONTENT", "GUILD_MEMBERS"
|
"MESSAGE_CONTENT"
|
||||||
]
|
]
|
||||||
if (reg.ooye.receive_presences !== false) intents.push("GUILD_PRESENCES")
|
if (reg.ooye.receive_presences !== false) intents.push("GUILD_PRESENCES")
|
||||||
this.discordToken = discordToken
|
this.discordToken = discordToken
|
||||||
|
|
@ -54,14 +52,9 @@ class DiscordClient {
|
||||||
/** @type {Map<string, Array<string>>} */
|
/** @type {Map<string, Array<string>>} */
|
||||||
this.guildChannelMap = new Map()
|
this.guildChannelMap = new Map()
|
||||||
if (listen !== "no") {
|
if (listen !== "no") {
|
||||||
this.cloud.on("event", message => {
|
this.cloud.on("event", message => discordPackets.onPacket(this, message, listen))
|
||||||
process.nextTick(() => {
|
|
||||||
discordPackets.onPacket(this, message, listen)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (CONNECTION_DEBUG) {
|
|
||||||
const addEventLogger = (eventName, logName) => {
|
const addEventLogger = (eventName, logName) => {
|
||||||
this.cloud.on(eventName, (...args) => {
|
this.cloud.on(eventName, (...args) => {
|
||||||
const d = new Date().toISOString().slice(0, 19)
|
const d = new Date().toISOString().slice(0, 19)
|
||||||
|
|
@ -72,7 +65,6 @@ class DiscordClient {
|
||||||
addEventLogger("disconnected", "Disconnected")
|
addEventLogger("disconnected", "Disconnected")
|
||||||
addEventLogger("ready", "Ready")
|
addEventLogger("ready", "Ready")
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = DiscordClient
|
module.exports = DiscordClient
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,18 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const assert = require("assert")
|
// Discord library internals type beat
|
||||||
const {scheduler} = require("timers/promises")
|
|
||||||
|
const DiscordTypes = require("discord-api-types/v10")
|
||||||
const passthrough = require("../passthrough")
|
const passthrough = require("../passthrough")
|
||||||
const {sync} = passthrough
|
const {sync, db} = passthrough
|
||||||
|
|
||||||
/** @type {import("../matrix/homeserver-status")} */
|
const utils = {
|
||||||
const homeserverStatus = sync.require("../matrix/homeserver-status")
|
/**
|
||||||
|
|
||||||
let checkedHomeserver = false
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {import("./discord-client")} client
|
* @param {import("./discord-client")} client
|
||||||
* @param {import("cloudstorm").IGatewayMessage} message
|
* @param {import("cloudstorm").IGatewayMessage} message
|
||||||
* @param {string} listen "full", "half", "no" - whether to set up the event listeners for OOYE to operate
|
* @param {string} listen "full", "half", "no" - whether to set up the event listeners for OOYE to operate
|
||||||
*/
|
*/
|
||||||
async function onPacket(client, message, listen) {
|
async onPacket(client, message, listen) {
|
||||||
// requiring this later so that the client is already constructed by the time event-dispatcher is loaded
|
// requiring this later so that the client is already constructed by the time event-dispatcher is loaded
|
||||||
/** @type {typeof import("./event-dispatcher")} */
|
/** @type {typeof import("./event-dispatcher")} */
|
||||||
const eventDispatcher = sync.require("./event-dispatcher")
|
const eventDispatcher = sync.require("./event-dispatcher")
|
||||||
|
|
@ -29,7 +26,6 @@ async function onPacket(client, message, listen) {
|
||||||
client.user = message.d.user
|
client.user = message.d.user
|
||||||
client.application = message.d.application
|
client.application = message.d.application
|
||||||
console.log(`Discord logged in as ${client.user.username}#${client.user.discriminator} (${client.user.id})`)
|
console.log(`Discord logged in as ${client.user.username}#${client.user.discriminator} (${client.user.id})`)
|
||||||
interactions.registerInteractions()
|
|
||||||
|
|
||||||
} else if (message.t === "GUILD_CREATE") {
|
} else if (message.t === "GUILD_CREATE") {
|
||||||
message.d.members = message.d.members.filter(m => m.user.id === client.user.id) // only keep the bot's own member - it's needed to determine private channels on web
|
message.d.members = message.d.members.filter(m => m.user.id === client.user.id) // only keep the bot's own member - it's needed to determine private channels on web
|
||||||
|
|
@ -51,35 +47,10 @@ async function onPacket(client, message, listen) {
|
||||||
|
|
||||||
if (listen === "full") {
|
if (listen === "full") {
|
||||||
try {
|
try {
|
||||||
/*
|
interactions.registerInteractions()
|
||||||
Info about guilds is populated one guild at a time.
|
|
||||||
For m->d bridging to work, the guild needs to be populated, so we need to have GUILD_CREATE for the guild.
|
|
||||||
If we ping the homeserver, it will send us any pending events, so we need to wait for all GUILD_CREATES before we ping.
|
|
||||||
We must attempt a ping because we don't want to try sending missed d->m messages to an offline homeserver.
|
|
||||||
This delay can be removed if ONE of the following is done:
|
|
||||||
1. m->d can queue incoming events until their guild exists in memory
|
|
||||||
2. d->m missed messages can have their errors handled and added to queue, rather than pinging first
|
|
||||||
*/
|
|
||||||
let isMainCharacter = false
|
|
||||||
if (!checkedHomeserver) {
|
|
||||||
checkedHomeserver = true
|
|
||||||
isMainCharacter = true
|
|
||||||
console.log("Warming up guilds~")
|
|
||||||
}
|
|
||||||
await scheduler.wait(5000)
|
|
||||||
if (isMainCharacter) {
|
|
||||||
checkedHomeserver = true
|
|
||||||
process.stdout.write("Connecting to homeserver... ")
|
|
||||||
await homeserverStatus.homeserverStatus.waitForOnline(true)
|
|
||||||
console.log("ok.\nReplaying past events. Welcome to Out Of Your Element.")
|
|
||||||
} else {
|
|
||||||
await homeserverStatus.homeserverStatus.waitForOnline(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
await eventDispatcher.checkMissedExpressions(message.d)
|
await eventDispatcher.checkMissedExpressions(message.d)
|
||||||
await eventDispatcher.checkMissedMessages(client, message.d)
|
|
||||||
await eventDispatcher.checkMissedPins(client, message.d)
|
await eventDispatcher.checkMissedPins(client, message.d)
|
||||||
await eventDispatcher.checkMissedLeaves(client, message.d)
|
await eventDispatcher.checkMissedMessages(client, message.d)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to sync missed events. To retry, please fix this error and restart OOYE:")
|
console.error("Failed to sync missed events. To retry, please fix this error and restart OOYE:")
|
||||||
console.error(e)
|
console.error(e)
|
||||||
|
|
@ -180,33 +151,6 @@ async function onPacket(client, message, listen) {
|
||||||
|
|
||||||
// Event dispatcher for OOYE bridge operations
|
// Event dispatcher for OOYE bridge operations
|
||||||
if (listen === "full" && message.t) {
|
if (listen === "full" && message.t) {
|
||||||
const alwaysRealTimeEvents = ["PRESENCE_UPDATE"]
|
|
||||||
const optionalEvents = ["TYPING_START"]
|
|
||||||
if (alwaysRealTimeEvents.includes(message.t) || homeserverStatus.homeserverStatus.isRealTime()) {
|
|
||||||
// real time
|
|
||||||
dispatchPacketToBridge(client, message)
|
|
||||||
} else if (optionalEvents.includes(message.t)) {
|
|
||||||
// when not real time, skip optional events
|
|
||||||
} else {
|
|
||||||
// queue required events
|
|
||||||
homeserverStatus.homeserverStatus.queuePacket(message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {import("./discord-client")} client
|
|
||||||
* @param {import("cloudstorm").IGatewayMessage} message
|
|
||||||
*/
|
|
||||||
async function dispatchPacketToBridge(client, message) {
|
|
||||||
// requiring this later so that the client is already constructed by the time event-dispatcher is loaded
|
|
||||||
/** @type {typeof import("./event-dispatcher")} */
|
|
||||||
const eventDispatcher = sync.require("./event-dispatcher")
|
|
||||||
/** @type {import("../discord/register-interactions")} */
|
|
||||||
const interactions = sync.require("../discord/register-interactions")
|
|
||||||
|
|
||||||
assert(message.t) // checked above
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (message.t === "MESSAGE_REACTION_REMOVE" || message.t === "MESSAGE_REACTION_REMOVE_EMOJI" || message.t === "MESSAGE_REACTION_REMOVE_ALL") {
|
if (message.t === "MESSAGE_REACTION_REMOVE" || message.t === "MESSAGE_REACTION_REMOVE_EMOJI" || message.t === "MESSAGE_REACTION_REMOVE_ALL") {
|
||||||
await eventDispatcher.onSomeReactionsRemoved(client, message.d)
|
await eventDispatcher.onSomeReactionsRemoved(client, message.d)
|
||||||
|
|
@ -221,7 +165,8 @@ async function dispatchPacketToBridge(client, message) {
|
||||||
// Let OOYE try to handle errors too
|
// Let OOYE try to handle errors too
|
||||||
await eventDispatcher.onError(client, e, message)
|
await eventDispatcher.onError(client, e, message)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports.onPacket = onPacket
|
module.exports = utils
|
||||||
module.exports.dispatchPacketToBridge = dispatchPacketToBridge
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
|
|
||||||
const assert = require("assert").strict
|
const assert = require("assert").strict
|
||||||
const DiscordTypes = require("discord-api-types/v10")
|
const DiscordTypes = require("discord-api-types/v10")
|
||||||
const {id: botID} = require("../../addbot")
|
|
||||||
const {sync, db, select, from} = require("../passthrough")
|
const {sync, db, select, from} = require("../passthrough")
|
||||||
|
|
||||||
/** @type {import("./actions/send-message")}) */
|
/** @type {import("./actions/send-message")}) */
|
||||||
|
|
@ -33,14 +32,10 @@ const speedbump = sync.require("./actions/speedbump")
|
||||||
const retrigger = sync.require("./actions/retrigger")
|
const retrigger = sync.require("./actions/retrigger")
|
||||||
/** @type {import("./actions/set-presence")} */
|
/** @type {import("./actions/set-presence")} */
|
||||||
const setPresence = sync.require("./actions/set-presence")
|
const setPresence = sync.require("./actions/set-presence")
|
||||||
/** @type {import("./actions/remove-member")} */
|
|
||||||
const removeMember = sync.require("./actions/remove-member")
|
|
||||||
/** @type {import("./actions/poll-vote")} */
|
/** @type {import("./actions/poll-vote")} */
|
||||||
const vote = sync.require("./actions/poll-vote")
|
const vote = sync.require("./actions/poll-vote")
|
||||||
/** @type {import("../m2d/event-dispatcher")} */
|
/** @type {import("../m2d/event-dispatcher")} */
|
||||||
const matrixEventDispatcher = sync.require("../m2d/event-dispatcher")
|
const matrixEventDispatcher = sync.require("../m2d/event-dispatcher")
|
||||||
/** @type {import("../m2d/actions/redact.js")} */
|
|
||||||
const redact = sync.require("../m2d/actions/redact.js")
|
|
||||||
/** @type {import("../discord/interactions/matrix-info")} */
|
/** @type {import("../discord/interactions/matrix-info")} */
|
||||||
const matrixInfoInteraction = sync.require("../discord/interactions/matrix-info")
|
const matrixInfoInteraction = sync.require("../discord/interactions/matrix-info")
|
||||||
|
|
||||||
|
|
@ -60,7 +55,7 @@ module.exports = {
|
||||||
|
|
||||||
matrixEventDispatcher.printError(gatewayMessage.t, "Discord", e, gatewayMessage)
|
matrixEventDispatcher.printError(gatewayMessage.t, "Discord", e, gatewayMessage)
|
||||||
|
|
||||||
const channelID = gatewayMessage.d?.["channel_id"]
|
const channelID = gatewayMessage.d["channel_id"]
|
||||||
if (!channelID) return
|
if (!channelID) return
|
||||||
const roomID = select("channel_room", "room_id", {channel_id: channelID}).pluck().get()
|
const roomID = select("channel_room", "room_id", {channel_id: channelID}).pluck().get()
|
||||||
if (!roomID) return
|
if (!roomID) return
|
||||||
|
|
@ -105,7 +100,7 @@ module.exports = {
|
||||||
// console.log(`[check missed messages] in ${channel.id} (${guild.name} / ${channel.name}) because its last message ${channel.last_message_id} is not in the database`)
|
// console.log(`[check missed messages] in ${channel.id} (${guild.name} / ${channel.name}) because its last message ${channel.last_message_id} is not in the database`)
|
||||||
let messages
|
let messages
|
||||||
try {
|
try {
|
||||||
messages = await client.snow.channel.getChannelMessages(channel.id, {limit: 100})
|
messages = await client.snow.channel.getChannelMessages(channel.id, {limit: 50})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.message === `{"message": "Missing Access", "code": 50001}`) { // pathetic error handling from SnowTransfer
|
if (e.message === `{"message": "Missing Access", "code": 50001}`) { // pathetic error handling from SnowTransfer
|
||||||
console.log(`[check missed messages] no permissions to look back in channel ${channel.name} (${channel.id})`)
|
console.log(`[check missed messages] no permissions to look back in channel ${channel.name} (${channel.id})`)
|
||||||
|
|
@ -128,7 +123,6 @@ module.exports = {
|
||||||
// Send in order
|
// Send in order
|
||||||
for (let i = Math.min(messages.length, latestBridgedMessageIndex)-1; i >= 0; i--) {
|
for (let i = Math.min(messages.length, latestBridgedMessageIndex)-1; i >= 0; i--) {
|
||||||
const message = messages[i]
|
const message = messages[i]
|
||||||
if (message.type === DiscordTypes.MessageType.UserJoin) continue // since join announcements don't become events, it would be a repetition to act on them during backfill
|
|
||||||
|
|
||||||
if (!members.has(message.author.id)) members.set(message.author.id, await client.snow.guild.getGuildMember(guild.id, message.author.id).catch(() => undefined))
|
if (!members.has(message.author.id)) members.set(message.author.id, await client.snow.guild.getGuildMember(guild.id, message.author.id).catch(() => undefined))
|
||||||
await module.exports.MESSAGE_CREATE(client, {
|
await module.exports.MESSAGE_CREATE(client, {
|
||||||
|
|
@ -178,31 +172,6 @@ module.exports = {
|
||||||
await createSpace.syncSpaceExpressions(data, true)
|
await createSpace.syncSpaceExpressions(data, true)
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* When logging back in, check if any members left while we were gone.
|
|
||||||
* Do this by getting the member list from Discord and seeing who we still have locally that isn't there in the response.
|
|
||||||
* @param {import("./discord-client")} client
|
|
||||||
* @param {DiscordTypes.GatewayGuildCreateDispatchData} guild
|
|
||||||
*/
|
|
||||||
async checkMissedLeaves(client, guild) {
|
|
||||||
const maxLimit = 1000
|
|
||||||
if (guild.member_count >= maxLimit) return // too large to want to scan
|
|
||||||
const discordMembers = await client.snow.guild.getGuildMembers(guild.id, {limit: maxLimit})
|
|
||||||
if (discordMembers.length >= maxLimit) return // response was maxed out, there are guild members that weren't listed, can't act safely
|
|
||||||
const discordMembersSet = new Set(discordMembers.map(m => m.user.id))
|
|
||||||
// no indexes on this one but I'll cope
|
|
||||||
const membersAddedOnMatrix = new Set(from("sim").join("sim_member", "mxid").join("channel_room", "room_id")
|
|
||||||
.pluck("user_id").selectUnsafe("DISTINCT user_id").where({guild_id: guild.id}).and("AND user_id not like '%-%' and user_id not like '%\\_%' escape '\\'").all())
|
|
||||||
const userInstalledAppIDs = new Set(from("app_user_install").pluck("app_bot_id").selectUnsafe("DISTINCT app_bot_id").where({guild_id: guild.id}).all())
|
|
||||||
// loop over members added on matrix and if the member does not exist on discord-side then they should be removed
|
|
||||||
for (const userID of membersAddedOnMatrix) {
|
|
||||||
if (userInstalledAppIDs.has(userID)) continue // skip user installed apps here since they're never true members - they'll be removed by removeMember when the associated user is removed
|
|
||||||
if (!discordMembersSet.has(userID)) {
|
|
||||||
await removeMember.removeMember(userID, guild.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Announces to the parent room that the thread room has been created.
|
* Announces to the parent room that the thread room has been created.
|
||||||
* See notes.md, "Ignore MESSAGE_UPDATE and bridge THREAD_CREATE as the announcement"
|
* See notes.md, "Ignore MESSAGE_UPDATE and bridge THREAD_CREATE as the announcement"
|
||||||
|
|
@ -242,14 +211,6 @@ module.exports = {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {import("./discord-client")} client
|
|
||||||
* @param {DiscordTypes.GatewayGuildMemberRemoveDispatchData} data
|
|
||||||
*/
|
|
||||||
async GUILD_MEMBER_REMOVE(client, data) {
|
|
||||||
await removeMember.removeMember(data.user.id, data.guild_id)
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {import("./discord-client")} client
|
* @param {import("./discord-client")} client
|
||||||
* @param {DiscordTypes.GatewayChannelUpdateDispatchData} channelOrThread
|
* @param {DiscordTypes.GatewayChannelUpdateDispatchData} channelOrThread
|
||||||
|
|
@ -288,6 +249,7 @@ module.exports = {
|
||||||
if (!guildID) return // channel must have been a DM channel or something
|
if (!guildID) return // channel must have been a DM channel or something
|
||||||
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()
|
||||||
if (!roomID) return // channel wasn't being bridged in the first place
|
if (!roomID) return // channel wasn't being bridged in the first place
|
||||||
|
// @ts-ignore
|
||||||
await createRoom.unbridgeChannel(channel, guildID)
|
await createRoom.unbridgeChannel(channel, guildID)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -297,7 +259,6 @@ module.exports = {
|
||||||
*/
|
*/
|
||||||
async MESSAGE_CREATE(client, message) {
|
async MESSAGE_CREATE(client, message) {
|
||||||
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.
|
||||||
if (select("opt_out", "user_id", {user_id: message.author.id}).pluck().get()) return // This user opted out.
|
|
||||||
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.
|
||||||
|
|
||||||
|
|
@ -313,12 +274,13 @@ module.exports = {
|
||||||
|
|
||||||
if (!createRoom.existsOrAutocreatable(channel, guild.id)) return // Check that the sending-to room exists or is autocreatable
|
if (!createRoom.existsOrAutocreatable(channel, guild.id)) return // Check that the sending-to room exists or is autocreatable
|
||||||
|
|
||||||
const {skip} = await speedbump.maybeDoSpeedbump(true, message)
|
const {affected, row} = await speedbump.maybeDoSpeedbump(message.channel_id, message.id)
|
||||||
if (skip) return
|
if (affected) return
|
||||||
|
|
||||||
await sendMessage.sendMessage(message, channel, guild)
|
// @ts-ignore
|
||||||
|
await sendMessage.sendMessage(message, channel, guild, row)
|
||||||
|
|
||||||
retrigger.finishedBridging(message.id)
|
retrigger.messageFinishedBridging(message.id)
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -334,27 +296,24 @@ module.exports = {
|
||||||
if (dUtils.isEphemeralMessage(data)) return // Ephemeral messages are for the eyes of the receiver only!
|
if (dUtils.isEphemeralMessage(data)) return // Ephemeral messages are for the eyes of the receiver only!
|
||||||
|
|
||||||
// Edits need to go through the speedbump as well. If the message is delayed but the edit isn't, we don't have anything to edit from.
|
// Edits need to go through the speedbump as well. If the message is delayed but the edit isn't, we don't have anything to edit from.
|
||||||
const {skip, hasCreate} = await speedbump.maybeDoSpeedbump(false, data)
|
const {affected, row} = await speedbump.maybeDoSpeedbump(data.channel_id, data.id)
|
||||||
if (skip) return
|
if (affected) return
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
// Check that the sending-to room exists, and deal with Eventual Consistency(TM)
|
||||||
|
if (retrigger.eventNotFoundThenRetrigger(data.id, module.exports.MESSAGE_UPDATE, client, data)) return
|
||||||
|
}
|
||||||
|
|
||||||
/** @type {DiscordTypes.GatewayMessageCreateDispatchData} */
|
/** @type {DiscordTypes.GatewayMessageCreateDispatchData} */
|
||||||
|
// @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)
|
||||||
|
|
||||||
if (!hasCreate) {
|
// @ts-ignore
|
||||||
// Standard path for most message updates
|
await retrigger.pauseChanges(message.id, editMessage.editMessage(message, guild, row))
|
||||||
// Check that the target message already exists, and deal with Eventual Consistency(TM)
|
|
||||||
if (!await retrigger.waitForMessage(data.id)) return
|
|
||||||
await retrigger.pauseChanges(message.id, editMessage.editMessage(message, guild))
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
// Path for edit packets that were speedbumped into the latest copy of a message that needs to be created
|
|
||||||
// Just pretend to be MESSAGE_CREATE
|
|
||||||
await sendMessage.sendMessage(message, channel, guild)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -362,7 +321,6 @@ module.exports = {
|
||||||
* @param {DiscordTypes.GatewayMessageReactionAddDispatchData} data
|
* @param {DiscordTypes.GatewayMessageReactionAddDispatchData} data
|
||||||
*/
|
*/
|
||||||
async MESSAGE_REACTION_ADD(client, data) {
|
async MESSAGE_REACTION_ADD(client, data) {
|
||||||
if (select("opt_out", "user_id", {user_id: data.user_id}).pluck().get()) return // This user opted out.
|
|
||||||
if (data.user_id === client.user.id) return // m2d reactions are added by the discord bot user - do not reflect them back to matrix.
|
if (data.user_id === client.user.id) return // m2d reactions are added by the discord bot user - do not reflect them back to matrix.
|
||||||
if (data.emoji.name === "❓" && select("event_message", "message_id", {message_id: data.message_id, source: 0, part: 0}).get()) { // source 0 = matrix
|
if (data.emoji.name === "❓" && select("event_message", "message_id", {message_id: data.message_id, source: 0, part: 0}).get()) { // source 0 = matrix
|
||||||
const guild_id = data.guild_id ?? client.channels.get(data.channel_id)?.["guild_id"]
|
const guild_id = data.guild_id ?? client.channels.get(data.channel_id)?.["guild_id"]
|
||||||
|
|
@ -381,16 +339,6 @@ module.exports = {
|
||||||
* @param {DiscordTypes.GatewayMessageReactionRemoveDispatchData | DiscordTypes.GatewayMessageReactionRemoveEmojiDispatchData | DiscordTypes.GatewayMessageReactionRemoveAllDispatchData} data
|
* @param {DiscordTypes.GatewayMessageReactionRemoveDispatchData | DiscordTypes.GatewayMessageReactionRemoveEmojiDispatchData | DiscordTypes.GatewayMessageReactionRemoveAllDispatchData} data
|
||||||
*/
|
*/
|
||||||
async onSomeReactionsRemoved(client, data) {
|
async onSomeReactionsRemoved(client, data) {
|
||||||
// Don't attempt to double-bridge our own m2d deleted reactions back to Matrix
|
|
||||||
if ("user_id" in data && data.user_id === botID) {
|
|
||||||
const emojiIdOrName = data.emoji.id || data.emoji.name
|
|
||||||
const i = redact.m2dDeletedReactions.findIndex(x => data.message_id === x.messageID && emojiIdOrName === x.emojiIdOrName)
|
|
||||||
if (i !== -1) {
|
|
||||||
redact.m2dDeletedReactions.splice(i, 1)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await removeReaction.removeSomeReactions(data)
|
await removeReaction.removeSomeReactions(data)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -400,7 +348,7 @@ module.exports = {
|
||||||
*/
|
*/
|
||||||
async MESSAGE_DELETE(client, data) {
|
async MESSAGE_DELETE(client, data) {
|
||||||
speedbump.onMessageDelete(data.id)
|
speedbump.onMessageDelete(data.id)
|
||||||
if (!await retrigger.waitForMessage(data.id)) return
|
if (retrigger.eventNotFoundThenRetrigger(data.id, module.exports.MESSAGE_DELETE, client, data)) return
|
||||||
await deleteMessage.deleteMessage(data)
|
await deleteMessage.deleteMessage(data)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -417,7 +365,6 @@ module.exports = {
|
||||||
* @param {DiscordTypes.GatewayTypingStartDispatchData} data
|
* @param {DiscordTypes.GatewayTypingStartDispatchData} data
|
||||||
*/
|
*/
|
||||||
async TYPING_START(client, data) {
|
async TYPING_START(client, data) {
|
||||||
if (data.user_id === client.user.id) return
|
|
||||||
const roomID = select("channel_room", "room_id", {channel_id: data.channel_id}).pluck().get()
|
const roomID = select("channel_room", "room_id", {channel_id: data.channel_id}).pluck().get()
|
||||||
if (!roomID) return
|
if (!roomID) return
|
||||||
const mxid = from("sim").join("sim_member", "mxid").where({user_id: data.user_id, room_id: roomID}).pluck("mxid").get()
|
const mxid = from("sim").join("sim_member", "mxid").where({user_id: data.user_id, room_id: roomID}).pluck("mxid").get()
|
||||||
|
|
@ -449,12 +396,12 @@ module.exports = {
|
||||||
* @param {DiscordTypes.GatewayMessagePollVoteDispatchData} data
|
* @param {DiscordTypes.GatewayMessagePollVoteDispatchData} data
|
||||||
*/
|
*/
|
||||||
async MESSAGE_POLL_VOTE_ADD(client, data) {
|
async MESSAGE_POLL_VOTE_ADD(client, data) {
|
||||||
if (!await retrigger.waitForMessage(data.message_id)) return
|
if (retrigger.eventNotFoundThenRetrigger(data.message_id, module.exports.MESSAGE_POLL_VOTE_ADD, client, data)) return
|
||||||
await vote.addVote(data)
|
await vote.addVote(data)
|
||||||
},
|
},
|
||||||
|
|
||||||
async MESSAGE_POLL_VOTE_REMOVE(client, data) {
|
async MESSAGE_POLL_VOTE_REMOVE(client, data) {
|
||||||
if (!await retrigger.waitForMessage(data.message_id)) return
|
if (retrigger.eventNotFoundThenRetrigger(data.message_id, module.exports.MESSAGE_POLL_VOTE_REMOVE, client, data)) return
|
||||||
await vote.removeVote(data)
|
await vote.removeVote(data)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
BEGIN TRANSACTION;
|
|
||||||
|
|
||||||
CREATE TABLE "role_default" (
|
|
||||||
"guild_id" TEXT NOT NULL,
|
|
||||||
"role_id" TEXT NOT NULL,
|
|
||||||
PRIMARY KEY ("guild_id", "role_id")
|
|
||||||
) WITHOUT ROWID;
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
BEGIN TRANSACTION;
|
|
||||||
|
|
||||||
CREATE TABLE "app_user_install" (
|
|
||||||
"guild_id" TEXT NOT NULL,
|
|
||||||
"app_bot_id" TEXT NOT NULL,
|
|
||||||
"user_id" TEXT NOT NULL,
|
|
||||||
PRIMARY KEY ("guild_id", "app_bot_id", "user_id")
|
|
||||||
) WITHOUT ROWID;
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
const {discord, db, from, select, sync} = require("../../passthrough")
|
|
||||||
/** @type {import("../../discord/utils")} */
|
|
||||||
const dUtils = sync.require("../../discord/utils")
|
|
||||||
|
|
||||||
const ones = "₀₁₂₃₄₅₆₇₈₉"
|
|
||||||
const tens = "0123456789"
|
|
||||||
|
|
||||||
/* c8 ignore start */
|
|
||||||
|
|
||||||
module.exports = async function(db) {
|
|
||||||
// added tolerance to https://discordstatus.com/incidents/4hpm4454hxtx
|
|
||||||
const OUTAGE_START = 1778263200000
|
|
||||||
const OUTAGE_END = 1778284800000
|
|
||||||
|
|
||||||
const startSnowflake = dUtils.timestampToSnowflakeInexact(OUTAGE_START)
|
|
||||||
const endSnowflake = dUtils.timestampToSnowflakeInexact(OUTAGE_END)
|
|
||||||
|
|
||||||
const affectedChannels = from("message_room").join("historical_channel_room", "historical_room_index")
|
|
||||||
.pluck("reference_channel_id").selectUnsafe("DISTINCT reference_channel_id")
|
|
||||||
.and("WHERE message_id >= ? AND message_id <= ? AND length(message_id) = ?").all(startSnowflake, endSnowflake, startSnowflake.length)
|
|
||||||
let affectedWebhooks = select("webhook", ["channel_id", "webhook_id", "webhook_token"], {channel_id: affectedChannels}).all()
|
|
||||||
affectedWebhooks = affectedWebhooks.filter(w => BigInt(w.webhook_id) < BigInt(endSnowflake)) // if webhook ID is already newly generated then no need to replace
|
|
||||||
|
|
||||||
if (affectedWebhooks.length) {
|
|
||||||
process.stdout.write(` revoking ${affectedWebhooks.length} possibly compromised webhooks... `)
|
|
||||||
for (let counter = 1; counter <= affectedWebhooks.length; counter++) {
|
|
||||||
const webhook = affectedWebhooks[counter-1]
|
|
||||||
|
|
||||||
await discord.snow.webhook.deleteWebhookToken(webhook.webhook_id, webhook.webhook_token, "Webhook token possibly compromised during 8th May 2026 outage").catch(e => {
|
|
||||||
if (e.message === `{"message": "Unknown Webhook", "code": 10015}`) {
|
|
||||||
// OK
|
|
||||||
} else {
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
})
|
|
||||||
db.prepare("DELETE FROM webhook WHERE channel_id = ?").run(webhook.channel_id)
|
|
||||||
|
|
||||||
process.stdout.write(String(counter).at(-1) === "0" ? tens[(counter/10)%10] : ones[counter%10])
|
|
||||||
}
|
|
||||||
process.stdout.write("\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
BEGIN TRANSACTION;
|
|
||||||
|
|
||||||
DELETE FROM emoji WHERE mxc_url NOT IN (SELECT mxc_url FROM file WHERE discord_url LIKE 'https://cdn.discordapp.com/emojis/%.webp%');
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
BEGIN TRANSACTION;
|
|
||||||
|
|
||||||
CREATE TABLE "channel_speedbump" (
|
|
||||||
"channel_id" TEXT NOT NULL,
|
|
||||||
"speedbump_webhook_id" TEXT NOT NULL,
|
|
||||||
"speedbump_user_id" TEXT NOT NULL,
|
|
||||||
PRIMARY KEY("channel_id","speedbump_webhook_id"),
|
|
||||||
FOREIGN KEY("channel_id") REFERENCES "channel_room"("channel_id")
|
|
||||||
) WITHOUT ROWID;
|
|
||||||
|
|
||||||
INSERT INTO channel_speedbump (channel_id, speedbump_webhook_id, speedbump_user_id)
|
|
||||||
SELECT channel_id, speedbump_webhook_id, speedbump_id FROM channel_room WHERE speedbump_id IS NOT NULL AND speedbump_webhook_id IS NOT NULL;
|
|
||||||
|
|
||||||
ALTER TABLE channel_room DROP COLUMN speedbump_id;
|
|
||||||
ALTER TABLE channel_room DROP COLUMN speedbump_webhook_id;
|
|
||||||
|
|
||||||
ALTER TABLE sim_proxy ADD COLUMN proxy_app INTEGER DEFAULT 0;
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
BEGIN TRANSACTION;
|
|
||||||
|
|
||||||
CREATE TABLE "opt_out" (
|
|
||||||
"user_id" TEXT NOT NULL,
|
|
||||||
"opted_out_at" INTEGER NOT NULL,
|
|
||||||
PRIMARY KEY("user_id")
|
|
||||||
) WITHOUT ROWID;
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
27
src/db/orm-defs.d.ts
vendored
27
src/db/orm-defs.d.ts
vendored
|
|
@ -1,10 +1,4 @@
|
||||||
export type Models = {
|
export type Models = {
|
||||||
app_user_install: {
|
|
||||||
guild_id: string
|
|
||||||
app_bot_id: string
|
|
||||||
user_id: string
|
|
||||||
}
|
|
||||||
|
|
||||||
auto_emoji: {
|
auto_emoji: {
|
||||||
name: string
|
name: string
|
||||||
emoji_id: string
|
emoji_id: string
|
||||||
|
|
@ -18,17 +12,13 @@ export type Models = {
|
||||||
thread_parent: string | null
|
thread_parent: string | null
|
||||||
custom_avatar: string | null
|
custom_avatar: string | null
|
||||||
last_bridged_pin_timestamp: number | null
|
last_bridged_pin_timestamp: number | null
|
||||||
|
speedbump_id: string | null
|
||||||
|
speedbump_webhook_id: string | null
|
||||||
speedbump_checked: number | null
|
speedbump_checked: number | null
|
||||||
guild_id: string | null
|
guild_id: string | null
|
||||||
custom_topic: number
|
custom_topic: number
|
||||||
}
|
}
|
||||||
|
|
||||||
channel_speedbump: {
|
|
||||||
channel_id: string
|
|
||||||
speedbump_webhook_id: string
|
|
||||||
speedbump_user_id: string
|
|
||||||
}
|
|
||||||
|
|
||||||
direct: {
|
direct: {
|
||||||
mxid: string
|
mxid: string
|
||||||
room_id: string
|
room_id: string
|
||||||
|
|
@ -48,7 +38,6 @@ export type Models = {
|
||||||
event_subtype: string | null
|
event_subtype: string | null
|
||||||
part: number
|
part: number
|
||||||
reaction_part: number
|
reaction_part: number
|
||||||
/** 0 = Matrix, 1 = Discord */
|
|
||||||
source: number
|
source: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -115,16 +104,6 @@ export type Models = {
|
||||||
historical_room_index: number
|
historical_room_index: number
|
||||||
}
|
}
|
||||||
|
|
||||||
opt_out: {
|
|
||||||
user_id: string
|
|
||||||
opted_out_at: number
|
|
||||||
}
|
|
||||||
|
|
||||||
role_default: {
|
|
||||||
guild_id: string
|
|
||||||
role_id: string
|
|
||||||
}
|
|
||||||
|
|
||||||
room_upgrade_pending: {
|
room_upgrade_pending: {
|
||||||
new_room_id: string
|
new_room_id: string
|
||||||
old_room_id: string
|
old_room_id: string
|
||||||
|
|
@ -147,8 +126,6 @@ export type Models = {
|
||||||
user_id: string
|
user_id: string
|
||||||
proxy_owner_id: string
|
proxy_owner_id: string
|
||||||
displayname: string
|
displayname: string
|
||||||
/** 0 = PluralKit, 1 = /plu/ral */
|
|
||||||
proxy_app: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
webhook: {
|
webhook: {
|
||||||
|
|
|
||||||
|
|
@ -104,16 +104,6 @@ class From {
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
pluckUnsafe(col) {
|
|
||||||
/** @type {Pluck<Table, any>} */
|
|
||||||
// @ts-ignore
|
|
||||||
const r = this
|
|
||||||
r.cols = [col]
|
|
||||||
r.makeColsSafe = false
|
|
||||||
r.isPluck = true
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} sql
|
* @param {string} sql
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -68,8 +68,3 @@ test("orm: select unsafe works (to select complex column names that can't be typ
|
||||||
.all()
|
.all()
|
||||||
t.equal(results[0].power_level, 150)
|
t.equal(results[0].power_level, 150)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("orm: pluck unsafe works (to select complex column names that can't be type verified)", t => {
|
|
||||||
const result = from("channel_room").where({guild_id: "112760669178241024"}).pluckUnsafe("count(*)").get()
|
|
||||||
t.equal(result, 7)
|
|
||||||
})
|
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,6 @@ async function _interact({guild_id, data}, {api}) {
|
||||||
// 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)
|
||||||
const via = await utils.getViaServersQuery(message.room_id, api)
|
const via = await utils.getViaServersQuery(message.room_id, api)
|
||||||
|
|
||||||
const channelsInGuild = discord.guildChannelMap.get(guild_id)
|
const channelsInGuild = discord.guildChannelMap.get(guild_id)
|
||||||
assert(channelsInGuild)
|
assert(channelsInGuild)
|
||||||
const inChannels = channelsInGuild
|
const inChannels = channelsInGuild
|
||||||
|
|
@ -62,35 +61,8 @@ async function _interact({guild_id, data}, {api}) {
|
||||||
.map(/** @returns {DiscordTypes.APIGuildChannel} */ cid => discord.channels.get(cid))
|
.map(/** @returns {DiscordTypes.APIGuildChannel} */ cid => discord.channels.get(cid))
|
||||||
.sort((a, b) => webGuild._getPosition(a, discord.channels) - webGuild._getPosition(b, discord.channels))
|
.sort((a, b) => webGuild._getPosition(a, discord.channels) - webGuild._getPosition(b, discord.channels))
|
||||||
.filter(channel => from("channel_room").join("member_cache", "room_id").select("mxid").where({channel_id: channel.id, mxid: event.sender}).get())
|
.filter(channel => from("channel_room").join("member_cache", "room_id").select("mxid").where({channel_id: channel.id, mxid: event.sender}).get())
|
||||||
let inChannelsText = inChannels.map(c => `<#${c.id}>`).join(" • ")
|
|
||||||
if (inChannelsText.length > 1024) {
|
|
||||||
inChannelsText = `In ${inChannels.length} channels`
|
|
||||||
}
|
|
||||||
|
|
||||||
const matrixMember = select("member_cache", ["displayname", "avatar_url"], {room_id: message.room_id, mxid: event.sender}).get()
|
const matrixMember = select("member_cache", ["displayname", "avatar_url"], {room_id: message.room_id, mxid: event.sender}).get()
|
||||||
let name = matrixMember?.displayname || event.sender
|
const name = matrixMember?.displayname || event.sender
|
||||||
let avatar = utils.getPublicUrlForMxc(matrixMember?.avatar_url)
|
|
||||||
|
|
||||||
// Check for per-message profile
|
|
||||||
const perMessageProfile = event.content?.["com.beeper.per_message_profile"]
|
|
||||||
let profileNote = ""
|
|
||||||
if (perMessageProfile) {
|
|
||||||
if (perMessageProfile.displayname) {
|
|
||||||
name = perMessageProfile.displayname
|
|
||||||
}
|
|
||||||
if ("avatar_url" in perMessageProfile) {
|
|
||||||
if (perMessageProfile.avatar_url) {
|
|
||||||
// use provided avatar_url
|
|
||||||
avatar = utils.getPublicUrlForMxc(perMessageProfile.avatar_url)
|
|
||||||
} else if (perMessageProfile.avatar_url === "") {
|
|
||||||
// empty string avatar_url clears the avatar
|
|
||||||
avatar = undefined
|
|
||||||
}
|
|
||||||
// else, omitted/null falls back to member avatar
|
|
||||||
}
|
|
||||||
profileNote = "Sent with a per-message profile.\n"
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
|
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
|
||||||
data: {
|
data: {
|
||||||
|
|
@ -98,13 +70,13 @@ async function _interact({guild_id, data}, {api}) {
|
||||||
author: {
|
author: {
|
||||||
name,
|
name,
|
||||||
url: `https://matrix.to/#/${event.sender}`,
|
url: `https://matrix.to/#/${event.sender}`,
|
||||||
icon_url: avatar
|
icon_url: utils.getPublicUrlForMxc(matrixMember?.avatar_url)
|
||||||
},
|
},
|
||||||
description: `This Matrix message was delivered to Discord by **Out Of Your Element**.\n[View on Matrix →](<https://matrix.to/#/${message.room_id}/${message.event_id}?${via}>)\n\n${profileNote}**User ID**: [${event.sender}](<https://matrix.to/#/${event.sender}>)`,
|
description: `This Matrix message was delivered to Discord by **Out Of Your Element**.\n[View on Matrix →](<https://matrix.to/#/${message.room_id}/${message.event_id}?${via}>)\n\n**User ID**: [${event.sender}](<https://matrix.to/#/${event.sender}>)`,
|
||||||
color: 0x0dbd8b,
|
color: 0x0dbd8b,
|
||||||
fields: [{
|
fields: [{
|
||||||
name: "In Channels",
|
name: "In Channels",
|
||||||
value: inChannelsText
|
value: inChannels.map(c => `<#${c.id}>`).join(" • ")
|
||||||
}, {
|
}, {
|
||||||
name: "\u200b",
|
name: "\u200b",
|
||||||
value: idInfo
|
value: idInfo
|
||||||
|
|
|
||||||
|
|
@ -85,118 +85,3 @@ test("matrix info: shows info for matrix source message", async t => {
|
||||||
)
|
)
|
||||||
t.equal(called, 1)
|
t.equal(called, 1)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("matrix info: shows username for per-message profile", async t => {
|
|
||||||
let called = 0
|
|
||||||
const msg = await _interact({
|
|
||||||
data: {
|
|
||||||
target_id: "1128118177155526666",
|
|
||||||
resolved: {
|
|
||||||
messages: {
|
|
||||||
"1141501302736695316": data.message.simple_reply_to_matrix_user
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
guild_id: "112760669178241024"
|
|
||||||
}, {
|
|
||||||
api: {
|
|
||||||
async getEvent(roomID, eventID) {
|
|
||||||
called++
|
|
||||||
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
|
|
||||||
t.equal(eventID, "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4")
|
|
||||||
return {
|
|
||||||
event_id: eventID,
|
|
||||||
room_id: roomID,
|
|
||||||
type: "m.room.message",
|
|
||||||
content: {
|
|
||||||
msgtype: "m.text",
|
|
||||||
body: "master chief: i like the halo",
|
|
||||||
format: "org.matrix.custom.html",
|
|
||||||
formatted_body: "<strong>master chief: </strong>i like the halo",
|
|
||||||
"com.beeper.per_message_profile": {
|
|
||||||
has_fallback: true,
|
|
||||||
displayname: "master chief",
|
|
||||||
avatar_url: ""
|
|
||||||
}
|
|
||||||
},
|
|
||||||
sender: "@cadence:cadence.moe"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async getJoinedMembers(roomID) {
|
|
||||||
return {
|
|
||||||
joined: {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async getStateEventOuter(roomID, type, key) {
|
|
||||||
return {
|
|
||||||
content: {
|
|
||||||
room_version: "11"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async getStateEvent(roomID, type, key) {
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
t.equal(msg.data.embeds[0].author.name, "master chief")
|
|
||||||
t.match(msg.data.embeds[0].description, "Sent with a per-message profile")
|
|
||||||
t.equal(called, 1)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("matrix info: shows avatar for per-message profile", async t => {
|
|
||||||
let called = 0
|
|
||||||
const msg = await _interact({
|
|
||||||
data: {
|
|
||||||
target_id: "1128118177155526666",
|
|
||||||
resolved: {
|
|
||||||
messages: {
|
|
||||||
"1141501302736695316": data.message.simple_reply_to_matrix_user
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
guild_id: "112760669178241024"
|
|
||||||
}, {
|
|
||||||
api: {
|
|
||||||
async getEvent(roomID, eventID) {
|
|
||||||
called++
|
|
||||||
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
|
|
||||||
t.equal(eventID, "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4")
|
|
||||||
return {
|
|
||||||
event_id: eventID,
|
|
||||||
room_id: roomID,
|
|
||||||
type: "m.room.message",
|
|
||||||
content: {
|
|
||||||
msgtype: "m.text",
|
|
||||||
body: "?",
|
|
||||||
format: "org.matrix.custom.html",
|
|
||||||
formatted_body: "?",
|
|
||||||
"com.beeper.per_message_profile": {
|
|
||||||
avatar_url: "mxc://cadence.moe/HXfFuougamkURPPMflTJRxGc"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
sender: "@mystery:cadence.moe"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async getJoinedMembers(roomID) {
|
|
||||||
return {
|
|
||||||
joined: {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async getStateEventOuter(roomID, type, key) {
|
|
||||||
return {
|
|
||||||
content: {
|
|
||||||
room_version: "11"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async getStateEvent(roomID, type, key) {
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
t.equal(msg.data.embeds[0].author.name, "@mystery:cadence.moe")
|
|
||||||
t.equal(msg.data.embeds[0].author.icon_url, "https://bridge.example.org/download/matrix/cadence.moe/HXfFuougamkURPPMflTJRxGc")
|
|
||||||
t.match(msg.data.embeds[0].description, "Sent with a per-message profile")
|
|
||||||
t.equal(called, 1)
|
|
||||||
})
|
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,9 @@ const utils = sync.require("../../matrix/utils")
|
||||||
* @returns {AsyncGenerator<{[k in keyof InteractionMethods]?: Parameters<InteractionMethods[k]>[2]}>}
|
* @returns {AsyncGenerator<{[k in keyof InteractionMethods]?: Parameters<InteractionMethods[k]>[2]}>}
|
||||||
*/
|
*/
|
||||||
async function* _interact({data}, {api}) {
|
async function* _interact({data}, {api}) {
|
||||||
const events = from("event_message").join("message_room", "message_id").join("historical_channel_room", "historical_room_index")
|
const row = from("event_message").join("message_room", "message_id").join("historical_channel_room", "historical_room_index")
|
||||||
.select("event_id", "room_id").where({message_id: data.target_id}).and("ORDER BY reaction_part").all()
|
.select("event_id", "room_id").where({message_id: data.target_id}).get()
|
||||||
if (!events.length) {
|
if (!row) {
|
||||||
return yield {createInteractionResponse: {
|
return yield {createInteractionResponse: {
|
||||||
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
|
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
|
||||||
data: {
|
data: {
|
||||||
|
|
@ -35,23 +35,17 @@ async function* _interact({data}, {api}) {
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|
||||||
const reactions = []
|
const reactions = await api.getFullRelations(row.room_id, row.event_id, "m.annotation")
|
||||||
for (const event of events) {
|
|
||||||
const reactionsOnEvent = await api.getFullRelations(event.room_id, event.event_id, "m.annotation")
|
|
||||||
reactions.push(...reactionsOnEvent)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @type {Map<string, Set<string>>} reaction key (the emoji) -> displaynames */
|
/** @type {Map<string, string[]>} */
|
||||||
const inverted = new Map()
|
const inverted = new Map()
|
||||||
for (const reaction of reactions) {
|
for (const reaction of reactions) {
|
||||||
if (utils.eventSenderIsFromDiscord(reaction.sender)) continue
|
if (utils.eventSenderIsFromDiscord(reaction.sender)) continue
|
||||||
const key = reaction.content["m.relates_to"].key
|
const key = reaction.content["m.relates_to"].key
|
||||||
const displayname = select("member_cache", "displayname", {mxid: reaction.sender, room_id: events[0].room_id}).pluck().get() || reaction.sender
|
const displayname = select("member_cache", "displayname", {mxid: reaction.sender, room_id: row.room_id}).pluck().get() || reaction.sender
|
||||||
if (!inverted.has(key)) {
|
if (!inverted.has(key)) inverted.set(key, [])
|
||||||
inverted.set(key, new Set())
|
|
||||||
}
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
inverted.get(key).add(displayname)
|
inverted.get(key).push(displayname)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (inverted.size === 0) {
|
if (inverted.size === 0) {
|
||||||
|
|
@ -61,7 +55,7 @@ async function* _interact({data}, {api}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return yield {editOriginalInteractionResponse: {
|
return yield {editOriginalInteractionResponse: {
|
||||||
content: [...inverted.entries()].sort((a, b) => b[1].size - a[1].size).map(([key, value]) => `${key} ⮞ ${[...value].join(" ⬩ ")}`).join("\n"),
|
content: [...inverted.entries()].map(([key, value]) => `${key} ⮞ ${value.join(" ⬩ ")}`).join("\n"),
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ test("reactions: different response if nobody reacted", async t => {
|
||||||
})
|
})
|
||||||
|
|
||||||
test("reactions: shows reactions if there are some, ignoring discord users", async t => {
|
test("reactions: shows reactions if there are some, ignoring discord users", async t => {
|
||||||
let called = 0
|
let called = 1
|
||||||
const msgs = await fromAsync(_interact({
|
const msgs = await fromAsync(_interact({
|
||||||
data: {
|
data: {
|
||||||
target_id: "1126786462646550579"
|
target_id: "1126786462646550579"
|
||||||
|
|
@ -51,7 +51,6 @@ test("reactions: shows reactions if there are some, ignoring discord users", asy
|
||||||
}, {
|
}, {
|
||||||
api: {
|
api: {
|
||||||
async getFullRelations(roomID, eventID) {
|
async getFullRelations(roomID, eventID) {
|
||||||
called++
|
|
||||||
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
|
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
|
||||||
t.equal(eventID, "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg")
|
t.equal(eventID, "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg")
|
||||||
return [{
|
return [{
|
||||||
|
|
@ -98,57 +97,3 @@ test("reactions: shows reactions if there are some, ignoring discord users", asy
|
||||||
)
|
)
|
||||||
t.equal(called, 1)
|
t.equal(called, 1)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("reactions: combines reactions from multiple events", async t => {
|
|
||||||
let called = 0
|
|
||||||
const msgs = await fromAsync(_interact({
|
|
||||||
data: {
|
|
||||||
target_id: "1141501302736695316"
|
|
||||||
}
|
|
||||||
}, {
|
|
||||||
api: {
|
|
||||||
async getFullRelations(roomID, eventID) {
|
|
||||||
called++
|
|
||||||
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
|
|
||||||
if (eventID === "$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA") {
|
|
||||||
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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}]
|
|
||||||
} else if (eventID === "$51f4yqHinwnSbPEQ9dCgoyy4qiIJSX0QYYVUnvwyTCI") {
|
|
||||||
return [{
|
|
||||||
sender: "@cadence:cadence.moe",
|
|
||||||
content: {
|
|
||||||
"m.relates_to": {
|
|
||||||
key: "🐈⬛",
|
|
||||||
rel_type: "m.annotation"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}]
|
|
||||||
} else {
|
|
||||||
t.fail(`tried to read reactions on wrong event ${eventID}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
t.equal(msgs.length, 2)
|
|
||||||
t.equal(
|
|
||||||
msgs[1].editOriginalInteractionResponse.content,
|
|
||||||
"🐈 ⮞ cadence [they] ⬩ @rnl:cadence.moe"
|
|
||||||
+ "\n🐈⬛ ⮞ cadence [they]"
|
|
||||||
)
|
|
||||||
t.equal(called, 2)
|
|
||||||
})
|
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ const ping = sync.require("./interactions/ping.js")
|
||||||
// User must have EVERY permission in default_member_permissions to be able to use the command
|
// User must have EVERY permission in default_member_permissions to be able to use the command
|
||||||
|
|
||||||
function registerInteractions() {
|
function registerInteractions() {
|
||||||
discord.snow.interaction.editApplicationCommands(id, [{
|
discord.snow.interaction.bulkOverwriteApplicationCommands(id, [{
|
||||||
name: "Matrix info",
|
name: "Matrix info",
|
||||||
contexts: [DiscordTypes.InteractionContextType.Guild],
|
contexts: [DiscordTypes.InteractionContextType.Guild],
|
||||||
type: DiscordTypes.ApplicationCommandType.Message,
|
type: DiscordTypes.ApplicationCommandType.Message,
|
||||||
|
|
@ -91,6 +91,15 @@ function registerInteractions() {
|
||||||
async function dispatchInteraction(interaction) {
|
async function dispatchInteraction(interaction) {
|
||||||
const interactionId = interaction.data?.["custom_id"] || interaction.data?.["name"]
|
const interactionId = interaction.data?.["custom_id"] || interaction.data?.["name"]
|
||||||
try {
|
try {
|
||||||
|
if (interaction.type === DiscordTypes.InteractionType.MessageComponent || interaction.type === DiscordTypes.InteractionType.ModalSubmit) {
|
||||||
|
// All we get is custom_id, don't know which context the button was clicked in.
|
||||||
|
// So we namespace these ourselves in the custom_id. Currently the only existing namespace is POLL_.
|
||||||
|
if (interaction.data.custom_id.startsWith("POLL_")) {
|
||||||
|
await poll.interact(interaction)
|
||||||
|
} else {
|
||||||
|
throw new Error(`Unknown message component ${interaction.data.custom_id}`)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
if (interactionId === "Matrix info") {
|
if (interactionId === "Matrix info") {
|
||||||
await matrixInfo.interact(interaction)
|
await matrixInfo.interact(interaction)
|
||||||
} else if (interactionId === "invite") {
|
} else if (interactionId === "invite") {
|
||||||
|
|
@ -113,11 +122,10 @@ async function dispatchInteraction(interaction) {
|
||||||
await ping.interact(interaction)
|
await ping.interact(interaction)
|
||||||
} else if (interactionId === "privacy") {
|
} else if (interactionId === "privacy") {
|
||||||
await privacy.interact(interaction)
|
await privacy.interact(interaction)
|
||||||
} else if (interactionId.startsWith("POLL_")) {
|
|
||||||
await poll.interact(interaction)
|
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`Unknown interaction ${interactionId}`)
|
throw new Error(`Unknown interaction ${interactionId}`)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
let stackLines = null
|
let stackLines = null
|
||||||
if (e.stack) {
|
if (e.stack) {
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ const assert = require("assert").strict
|
||||||
|
|
||||||
const {reg} = require("../matrix/read-registration")
|
const {reg} = require("../matrix/read-registration")
|
||||||
|
|
||||||
const {db, select} = require("../passthrough")
|
const {db} = require("../passthrough")
|
||||||
|
|
||||||
/** @type {import("xxhash-wasm").XXHashAPI} */ // @ts-ignore
|
/** @type {import("xxhash-wasm").XXHashAPI} */ // @ts-ignore
|
||||||
let hasher = null
|
let hasher = null
|
||||||
|
|
@ -58,15 +58,6 @@ function getPermissions(guildID, userRoles, guildRoles, userID, channelOverwrite
|
||||||
return allowed
|
return allowed
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {{id: string, roles: DiscordTypes.APIGuild["roles"]}} guild
|
|
||||||
* @param {DiscordTypes.APIGuildChannel["permission_overwrites"]} [channel]
|
|
||||||
*/
|
|
||||||
function getDefaultPermissions(guild, channel) {
|
|
||||||
const defaultRoles = select("role_default", "role_id", {guild_id: guild.id}).pluck().all()
|
|
||||||
return getPermissions(guild.id, defaultRoles, guild.roles, undefined, channel)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Note: You can only provide one permission bit to permissionToCheckFor. To check multiple permissions, call `hasAllPermissions` or `hasSomePermissions`.
|
* Note: You can only provide one permission bit to permissionToCheckFor. To check multiple permissions, call `hasAllPermissions` or `hasSomePermissions`.
|
||||||
* It is designed like this to avoid developer error with bit manipulations.
|
* It is designed like this to avoid developer error with bit manipulations.
|
||||||
|
|
@ -114,7 +105,7 @@ function hasAllPermissions(resolvedPermissions, permissionsToCheckFor) {
|
||||||
* @param {DiscordTypes.APIMessage} message
|
* @param {DiscordTypes.APIMessage} message
|
||||||
*/
|
*/
|
||||||
function isWebhookMessage(message) {
|
function isWebhookMessage(message) {
|
||||||
return message.webhook_id && message.type !== DiscordTypes.MessageType.ChatInputCommand && message.type !== DiscordTypes.MessageType.ContextMenuCommand
|
return message.webhook_id && message.type !== DiscordTypes.MessageType.ChatInputCommand
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -182,408 +173,7 @@ function filterTo(xs, fn) {
|
||||||
return filtered
|
return filtered
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The parameters correspond to the columns of the channel_room table.
|
|
||||||
* @param {string} rowChannelID thread ID, OR channel ID if there is no thread
|
|
||||||
* @param {string | null | undefined} rowThreadParent channel ID if there is a thread
|
|
||||||
*/
|
|
||||||
function swapThreadID(rowChannelID, rowThreadParent) {
|
|
||||||
return {
|
|
||||||
channelID: rowThreadParent ? rowThreadParent : rowChannelID,
|
|
||||||
threadID: rowThreadParent ? rowChannelID : undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const supportedPlaintextPreviewExtensions = new Set([
|
|
||||||
"4d",
|
|
||||||
"abnf",
|
|
||||||
"accesslog",
|
|
||||||
"actionscript",
|
|
||||||
"ada",
|
|
||||||
"adoc",
|
|
||||||
"alan",
|
|
||||||
"angelscript",
|
|
||||||
"ansi",
|
|
||||||
"apache",
|
|
||||||
"apacheconf",
|
|
||||||
"applescript",
|
|
||||||
"arcade",
|
|
||||||
"arduino",
|
|
||||||
"arm",
|
|
||||||
"armasm",
|
|
||||||
"as",
|
|
||||||
"asc",
|
|
||||||
"asciidoc",
|
|
||||||
"aspectj",
|
|
||||||
"ass",
|
|
||||||
"atom",
|
|
||||||
"autohotkey",
|
|
||||||
"autoit",
|
|
||||||
"avrasm",
|
|
||||||
"awk",
|
|
||||||
"axapta",
|
|
||||||
"bash",
|
|
||||||
"basic",
|
|
||||||
"bat",
|
|
||||||
"bbcode",
|
|
||||||
"bf",
|
|
||||||
"bind",
|
|
||||||
"blade",
|
|
||||||
"bnf",
|
|
||||||
"brainfuck",
|
|
||||||
"c",
|
|
||||||
"c++",
|
|
||||||
"cal",
|
|
||||||
"capnp",
|
|
||||||
"capnproto",
|
|
||||||
"cc",
|
|
||||||
"chaos",
|
|
||||||
"chapel",
|
|
||||||
"chpl",
|
|
||||||
"cisco",
|
|
||||||
"clj",
|
|
||||||
"clojure",
|
|
||||||
"cls",
|
|
||||||
"cmake.in",
|
|
||||||
"cmake",
|
|
||||||
"cmd",
|
|
||||||
"coffee",
|
|
||||||
"coffeescript",
|
|
||||||
"console",
|
|
||||||
"coq",
|
|
||||||
"cos",
|
|
||||||
"cpc",
|
|
||||||
"cpp",
|
|
||||||
"cr",
|
|
||||||
"craftcms",
|
|
||||||
"crm",
|
|
||||||
"crmsh",
|
|
||||||
"crystal",
|
|
||||||
"cs",
|
|
||||||
"csharp",
|
|
||||||
"cshtml",
|
|
||||||
"cson",
|
|
||||||
"csp",
|
|
||||||
"css",
|
|
||||||
"csv",
|
|
||||||
"cxx",
|
|
||||||
"cypher",
|
|
||||||
"d",
|
|
||||||
"dart",
|
|
||||||
"delphi",
|
|
||||||
"dfm",
|
|
||||||
"diff",
|
|
||||||
"django",
|
|
||||||
"dns",
|
|
||||||
"docker",
|
|
||||||
"dockerfile",
|
|
||||||
"dos",
|
|
||||||
"dpr",
|
|
||||||
"dsconfig",
|
|
||||||
"dst",
|
|
||||||
"dts",
|
|
||||||
"dust",
|
|
||||||
"dylan",
|
|
||||||
"ebnf",
|
|
||||||
"elixir",
|
|
||||||
"elm",
|
|
||||||
"erl",
|
|
||||||
"erlang",
|
|
||||||
"ex",
|
|
||||||
"extempore",
|
|
||||||
"f90",
|
|
||||||
"f95",
|
|
||||||
"fix",
|
|
||||||
"fortran",
|
|
||||||
"freepascal",
|
|
||||||
"fs",
|
|
||||||
"fsharp",
|
|
||||||
"gams",
|
|
||||||
"gauss",
|
|
||||||
"gawk",
|
|
||||||
"gcode",
|
|
||||||
"gdscript",
|
|
||||||
"gemspec",
|
|
||||||
"gf",
|
|
||||||
"gherkin",
|
|
||||||
"glsl",
|
|
||||||
"gms",
|
|
||||||
"gn",
|
|
||||||
"gni",
|
|
||||||
"go",
|
|
||||||
"godot",
|
|
||||||
"golang",
|
|
||||||
"golo",
|
|
||||||
"gololang",
|
|
||||||
"gradle",
|
|
||||||
"graph",
|
|
||||||
"groovy",
|
|
||||||
"gss",
|
|
||||||
"gyp",
|
|
||||||
"h",
|
|
||||||
"h++",
|
|
||||||
"haml",
|
|
||||||
"handlebars",
|
|
||||||
"haskell",
|
|
||||||
"haxe",
|
|
||||||
"hbs",
|
|
||||||
"hcl",
|
|
||||||
"hh",
|
|
||||||
"hpp",
|
|
||||||
"hs",
|
|
||||||
"html.handlebars",
|
|
||||||
"html.hbs",
|
|
||||||
"html",
|
|
||||||
"http",
|
|
||||||
"https",
|
|
||||||
"hx",
|
|
||||||
"hxx",
|
|
||||||
"hy",
|
|
||||||
"hylang",
|
|
||||||
"i",
|
|
||||||
"i7",
|
|
||||||
"iced",
|
|
||||||
"iecst",
|
|
||||||
"inform7",
|
|
||||||
"ini",
|
|
||||||
"ino",
|
|
||||||
"instances",
|
|
||||||
"iol",
|
|
||||||
"irb",
|
|
||||||
"irpf90",
|
|
||||||
"java",
|
|
||||||
"javascript",
|
|
||||||
"jinja",
|
|
||||||
"jolie",
|
|
||||||
"js",
|
|
||||||
"json",
|
|
||||||
"jsp",
|
|
||||||
"jsx",
|
|
||||||
"julia-repl",
|
|
||||||
"julia",
|
|
||||||
"k",
|
|
||||||
"kaos",
|
|
||||||
"kdb",
|
|
||||||
"kotlin",
|
|
||||||
"kt",
|
|
||||||
"lasso",
|
|
||||||
"lassoscript",
|
|
||||||
"lazarus",
|
|
||||||
"ldif",
|
|
||||||
"leaf",
|
|
||||||
"lean",
|
|
||||||
"less",
|
|
||||||
"lfm",
|
|
||||||
"lisp",
|
|
||||||
"livecodeserver",
|
|
||||||
"livescript",
|
|
||||||
"ln",
|
|
||||||
"lock",
|
|
||||||
"log",
|
|
||||||
"lpr",
|
|
||||||
"ls",
|
|
||||||
"ls",
|
|
||||||
"lua",
|
|
||||||
"mak",
|
|
||||||
"make",
|
|
||||||
"makefile",
|
|
||||||
"markdown",
|
|
||||||
"mathematica",
|
|
||||||
"matlab",
|
|
||||||
"mawk",
|
|
||||||
"maxima",
|
|
||||||
"md",
|
|
||||||
"mel",
|
|
||||||
"mercury",
|
|
||||||
"mirc",
|
|
||||||
"mizar",
|
|
||||||
"mk",
|
|
||||||
"mkd",
|
|
||||||
"mkdown",
|
|
||||||
"ml",
|
|
||||||
"ml",
|
|
||||||
"mm",
|
|
||||||
"mma",
|
|
||||||
"mojolicious",
|
|
||||||
"monkey",
|
|
||||||
"moon",
|
|
||||||
"moonscript",
|
|
||||||
"mrc",
|
|
||||||
"n1ql",
|
|
||||||
"nawk",
|
|
||||||
"nc",
|
|
||||||
"never",
|
|
||||||
"nginx",
|
|
||||||
"nginxconf",
|
|
||||||
"nim",
|
|
||||||
"nimrod",
|
|
||||||
"nix",
|
|
||||||
"nsis",
|
|
||||||
"obj-c",
|
|
||||||
"obj-c++",
|
|
||||||
"objc",
|
|
||||||
"objective-c++",
|
|
||||||
"objectivec",
|
|
||||||
"ocaml",
|
|
||||||
"ocl",
|
|
||||||
"ol",
|
|
||||||
"openscad",
|
|
||||||
"osascript",
|
|
||||||
"oxygene",
|
|
||||||
"p21",
|
|
||||||
"parser3",
|
|
||||||
"pas",
|
|
||||||
"pascal",
|
|
||||||
"patch",
|
|
||||||
"pcmk",
|
|
||||||
"perl",
|
|
||||||
"pf.conf",
|
|
||||||
"pf",
|
|
||||||
"pgsql",
|
|
||||||
"php",
|
|
||||||
"php3",
|
|
||||||
"php4",
|
|
||||||
"php5",
|
|
||||||
"php6",
|
|
||||||
"php7",
|
|
||||||
"pl",
|
|
||||||
"plaintext",
|
|
||||||
"plist",
|
|
||||||
"pm",
|
|
||||||
"podspec",
|
|
||||||
"pony",
|
|
||||||
"postgres",
|
|
||||||
"postgresql",
|
|
||||||
"powershell",
|
|
||||||
"pp",
|
|
||||||
"processing",
|
|
||||||
"profile",
|
|
||||||
"prolog",
|
|
||||||
"properties",
|
|
||||||
"proto",
|
|
||||||
"protobuf",
|
|
||||||
"ps",
|
|
||||||
"ps1",
|
|
||||||
"puppet",
|
|
||||||
"py",
|
|
||||||
"pycon",
|
|
||||||
"python-repl",
|
|
||||||
"python",
|
|
||||||
"qml",
|
|
||||||
"r",
|
|
||||||
"razor-cshtml",
|
|
||||||
"razor",
|
|
||||||
"rb",
|
|
||||||
"re",
|
|
||||||
"reasonml",
|
|
||||||
"rebol",
|
|
||||||
"red-system",
|
|
||||||
"red",
|
|
||||||
"redbol",
|
|
||||||
"rf",
|
|
||||||
"rib",
|
|
||||||
"robot",
|
|
||||||
"rpm-spec",
|
|
||||||
"rpm-specfile",
|
|
||||||
"rpm",
|
|
||||||
"rs",
|
|
||||||
"rsl",
|
|
||||||
"rss",
|
|
||||||
"ruby",
|
|
||||||
"ruleslanguage",
|
|
||||||
"rust",
|
|
||||||
"sas",
|
|
||||||
"SAS",
|
|
||||||
"sc",
|
|
||||||
"scad",
|
|
||||||
"scala",
|
|
||||||
"scheme",
|
|
||||||
"sci",
|
|
||||||
"scilab",
|
|
||||||
"scl",
|
|
||||||
"scss",
|
|
||||||
"sh",
|
|
||||||
"shell",
|
|
||||||
"shexc",
|
|
||||||
"smali",
|
|
||||||
"smalltalk",
|
|
||||||
"sml",
|
|
||||||
"sol",
|
|
||||||
"solidity",
|
|
||||||
"spec",
|
|
||||||
"specfile",
|
|
||||||
"sql",
|
|
||||||
"srt",
|
|
||||||
"ssa",
|
|
||||||
"st",
|
|
||||||
"stan",
|
|
||||||
"stanfuncs",
|
|
||||||
"stata",
|
|
||||||
"step",
|
|
||||||
"stp",
|
|
||||||
"structured-text",
|
|
||||||
"styl",
|
|
||||||
"stylus",
|
|
||||||
"subunit",
|
|
||||||
"supercollider",
|
|
||||||
"svelte",
|
|
||||||
"svg",
|
|
||||||
"swift",
|
|
||||||
"tao",
|
|
||||||
"tap",
|
|
||||||
"tcl",
|
|
||||||
"terraform",
|
|
||||||
"tex",
|
|
||||||
"text",
|
|
||||||
"tf",
|
|
||||||
"thor",
|
|
||||||
"thrift",
|
|
||||||
"tk",
|
|
||||||
"toml",
|
|
||||||
"tp",
|
|
||||||
"ts",
|
|
||||||
"tsql",
|
|
||||||
"tsx",
|
|
||||||
"ttml",
|
|
||||||
"twig",
|
|
||||||
"txt",
|
|
||||||
"typescript",
|
|
||||||
"unicorn-rails-log",
|
|
||||||
"v",
|
|
||||||
"vala",
|
|
||||||
"vb",
|
|
||||||
"vba",
|
|
||||||
"vbnet",
|
|
||||||
"vbs",
|
|
||||||
"vbscript",
|
|
||||||
"verilog",
|
|
||||||
"vhdl",
|
|
||||||
"vim",
|
|
||||||
"vtt",
|
|
||||||
"wl",
|
|
||||||
"x++",
|
|
||||||
"x86asm",
|
|
||||||
"xhtml",
|
|
||||||
"xjb",
|
|
||||||
"xl",
|
|
||||||
"xml",
|
|
||||||
"xpath",
|
|
||||||
"xq",
|
|
||||||
"xquery",
|
|
||||||
"xsd",
|
|
||||||
"xsl",
|
|
||||||
"xtlang",
|
|
||||||
"xtm",
|
|
||||||
"yaml",
|
|
||||||
"yml",
|
|
||||||
"zep",
|
|
||||||
"zephir",
|
|
||||||
"zone",
|
|
||||||
"zsh"
|
|
||||||
])
|
|
||||||
|
|
||||||
module.exports.getPermissions = getPermissions
|
module.exports.getPermissions = getPermissions
|
||||||
module.exports.getDefaultPermissions = getDefaultPermissions
|
|
||||||
module.exports.hasPermission = hasPermission
|
module.exports.hasPermission = hasPermission
|
||||||
module.exports.hasSomePermissions = hasSomePermissions
|
module.exports.hasSomePermissions = hasSomePermissions
|
||||||
module.exports.hasAllPermissions = hasAllPermissions
|
module.exports.hasAllPermissions = hasAllPermissions
|
||||||
|
|
@ -594,5 +184,3 @@ module.exports.timestampToSnowflakeInexact = timestampToSnowflakeInexact
|
||||||
module.exports.getPublicUrlForCdn = getPublicUrlForCdn
|
module.exports.getPublicUrlForCdn = getPublicUrlForCdn
|
||||||
module.exports.howOldUnbridgedMessage = howOldUnbridgedMessage
|
module.exports.howOldUnbridgedMessage = howOldUnbridgedMessage
|
||||||
module.exports.filterTo = filterTo
|
module.exports.filterTo = filterTo
|
||||||
module.exports.swapThreadID = swapThreadID
|
|
||||||
module.exports.supportedPlaintextPreviewExtensions = supportedPlaintextPreviewExtensions
|
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ const retrigger = sync.require("../../d2m/actions/retrigger")
|
||||||
*/
|
*/
|
||||||
async function addReaction(event) {
|
async function addReaction(event) {
|
||||||
// Wait until the corresponding channel and message have already been bridged
|
// Wait until the corresponding channel and message have already been bridged
|
||||||
if (!await retrigger.waitForEvent(event.content["m.relates_to"].event_id)) return
|
if (retrigger.eventNotFoundThenRetrigger(event.content["m.relates_to"].event_id, () => as.emit("type:m.reaction", event))) return
|
||||||
|
|
||||||
// These will exist because it passed retrigger
|
// These will exist because it passed retrigger
|
||||||
const row = from("event_message").join("message_room", "message_id").join("historical_channel_room", "historical_room_index")
|
const row = from("event_message").join("message_room", "message_id").join("historical_channel_room", "historical_room_index")
|
||||||
|
|
@ -50,8 +50,6 @@ async function addReaction(event) {
|
||||||
}
|
}
|
||||||
|
|
||||||
db.prepare("REPLACE INTO reaction (hashed_event_id, message_id, encoded_emoji, original_encoding) VALUES (?, ?, ?, ?)").run(utils.getEventIDHash(event.event_id), messageID, discordPreferredEncoding, key)
|
db.prepare("REPLACE INTO reaction (hashed_event_id, message_id, encoded_emoji, original_encoding) VALUES (?, ?, ?, ?)").run(utils.getEventIDHash(event.event_id), messageID, discordPreferredEncoding, key)
|
||||||
|
|
||||||
retrigger.finishedBridging(event.event_id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports.addReaction = addReaction
|
module.exports.addReaction = addReaction
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ async function getAndConvertEmoji(mxc) {
|
||||||
const res = await api.getMedia(mxc, {signal: abortController.signal})
|
const res = await api.getMedia(mxc, {signal: abortController.signal})
|
||||||
if (res.status !== 200) {
|
if (res.status !== 200) {
|
||||||
const root = await res.json()
|
const root = await res.json()
|
||||||
throw new mreq.MatrixServerError(root, res.status, {mxc})
|
throw new mreq.MatrixServerError(root, {mxc})
|
||||||
}
|
}
|
||||||
const readable = stream.Readable.fromWeb(res.body)
|
const readable = stream.Readable.fromWeb(res.body)
|
||||||
return emojiSheetConverter.convertImageStream(readable, () => {
|
return emojiSheetConverter.convertImageStream(readable, () => {
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,6 @@ const utils = sync.require("../../matrix/utils")
|
||||||
/** @type {import("../../d2m/actions/retrigger")} */
|
/** @type {import("../../d2m/actions/retrigger")} */
|
||||||
const retrigger = sync.require("../../d2m/actions/retrigger")
|
const retrigger = sync.require("../../d2m/actions/retrigger")
|
||||||
|
|
||||||
/** @type {{messageID: string, emojiIdOrName: string}[]} */
|
|
||||||
const m2dDeletedReactions = []
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {Ty.Event.Outer_M_Room_Redaction} event
|
* @param {Ty.Event.Outer_M_Room_Redaction} event
|
||||||
*/
|
*/
|
||||||
|
|
@ -27,21 +24,6 @@ async function deleteMessage(event) {
|
||||||
db.prepare("DELETE FROM message_room WHERE message_id = ?").run(rows[0].message_id)
|
db.prepare("DELETE FROM message_room WHERE message_id = ?").run(rows[0].message_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {Ty.Event.Outer_M_Room_Redaction} event
|
|
||||||
*/
|
|
||||||
async function removeMessageEvent(event) {
|
|
||||||
// Could be for removing a message or suppressing embeds. For more information, the message needs to be bridged first.
|
|
||||||
if (!await retrigger.waitForEvent(event.redacts)) return
|
|
||||||
|
|
||||||
const row = select("event_message", ["event_type", "event_subtype", "part"], {event_id: event.redacts}).get()
|
|
||||||
if (row && row.event_type === "m.room.message" && row.event_subtype === "m.notice" && row.part === 1) {
|
|
||||||
await suppressEmbeds(event)
|
|
||||||
} else {
|
|
||||||
await deleteMessage(event)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {Ty.Event.Outer_M_Room_Redaction} event
|
* @param {Ty.Event.Outer_M_Room_Redaction} event
|
||||||
*/
|
*/
|
||||||
|
|
@ -59,20 +41,11 @@ async function suppressEmbeds(event) {
|
||||||
* @param {Ty.Event.Outer_M_Room_Redaction} event
|
* @param {Ty.Event.Outer_M_Room_Redaction} event
|
||||||
*/
|
*/
|
||||||
async function removeReaction(event) {
|
async function removeReaction(event) {
|
||||||
if (!await retrigger.waitForReactionEvent(event.redacts)) return
|
|
||||||
|
|
||||||
const hash = utils.getEventIDHash(event.redacts)
|
const hash = utils.getEventIDHash(event.redacts)
|
||||||
const row = from("reaction").join("message_room", "message_id").join("historical_channel_room", "historical_room_index")
|
const row = from("reaction").join("message_room", "message_id").join("historical_channel_room", "historical_room_index")
|
||||||
.select("reference_channel_id", "message_id", "encoded_emoji").where({hashed_event_id: hash}).get()
|
.select("reference_channel_id", "message_id", "encoded_emoji").where({hashed_event_id: hash}).get()
|
||||||
if (!row) return
|
if (!row) return
|
||||||
// See how many Matrix-side reactions there are, and delete if it's the last one
|
|
||||||
const numberOfReactions = from("reaction").where({message_id: row.message_id, encoded_emoji: row.encoded_emoji}).pluckUnsafe("count(*)").get()
|
|
||||||
if (numberOfReactions === 1) {
|
|
||||||
// If a unicode emoji, the name is already the Discord preferred version because that's what was added and stored to encoded_emoji
|
|
||||||
const emojiIdOrName = decodeURIComponent(row.encoded_emoji).split(":").slice(-1)[0]
|
|
||||||
m2dDeletedReactions.push({messageID: row.message_id, emojiIdOrName})
|
|
||||||
await discord.snow.channel.deleteReactionSelf(row.reference_channel_id, row.message_id, row.encoded_emoji)
|
await discord.snow.channel.deleteReactionSelf(row.reference_channel_id, row.message_id, row.encoded_emoji)
|
||||||
}
|
|
||||||
db.prepare("DELETE FROM reaction WHERE hashed_event_id = ?").run(hash)
|
db.prepare("DELETE FROM reaction WHERE hashed_event_id = ?").run(hash)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -81,12 +54,18 @@ async function removeReaction(event) {
|
||||||
* @param {Ty.Event.Outer_M_Room_Redaction} event
|
* @param {Ty.Event.Outer_M_Room_Redaction} event
|
||||||
*/
|
*/
|
||||||
async function handle(event) {
|
async function handle(event) {
|
||||||
// Don't know if it's a redaction for a reaction or an event, try both at the same time (otherwise waitFor will block)
|
// If this is for removing a reaction, try it
|
||||||
await Promise.all([
|
await removeReaction(event)
|
||||||
removeMessageEvent(event),
|
|
||||||
removeReaction(event)
|
// Or, it might be for removing a message or suppressing embeds. But to do that, the message needs to be bridged first.
|
||||||
])
|
if (retrigger.eventNotFoundThenRetrigger(event.redacts, () => as.emit("type:m.room.redaction", event))) return
|
||||||
|
|
||||||
|
const row = select("event_message", ["event_type", "event_subtype", "part"], {event_id: event.redacts}).get()
|
||||||
|
if (row && row.event_type === "m.room.message" && row.event_subtype === "m.notice" && row.part === 1) {
|
||||||
|
await suppressEmbeds(event)
|
||||||
|
} else {
|
||||||
|
await deleteMessage(event)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports.handle = handle
|
module.exports.handle = handle
|
||||||
module.exports.m2dDeletedReactions = m2dDeletedReactions
|
|
||||||
|
|
@ -9,7 +9,7 @@ const sharp = require("sharp")
|
||||||
const api = sync.require("../../matrix/api")
|
const api = sync.require("../../matrix/api")
|
||||||
/** @type {import("../../matrix/mreq")} */
|
/** @type {import("../../matrix/mreq")} */
|
||||||
const mreq = sync.require("../../matrix/mreq")
|
const mreq = sync.require("../../matrix/mreq")
|
||||||
const {streamType} = require("@cloudrac3r/stream-type")
|
const streamMimeType = require("stream-mime-type")
|
||||||
|
|
||||||
const WIDTH = 160
|
const WIDTH = 160
|
||||||
const HEIGHT = 160
|
const HEIGHT = 160
|
||||||
|
|
@ -22,17 +22,17 @@ async function getAndResizeSticker(mxc) {
|
||||||
const res = await api.getMedia(mxc)
|
const res = await api.getMedia(mxc)
|
||||||
if (res.status !== 200) {
|
if (res.status !== 200) {
|
||||||
const root = await res.json()
|
const root = await res.json()
|
||||||
throw new mreq.MatrixServerError(root, res.status, {mxc})
|
throw new mreq.MatrixServerError(root, {mxc})
|
||||||
}
|
}
|
||||||
|
|
||||||
const streamIn = Readable.fromWeb(res.body)
|
const streamIn = Readable.fromWeb(res.body)
|
||||||
const {streamThrough, type} = await streamType(streamIn)
|
const { stream, mime } = await streamMimeType.getMimeType(streamIn)
|
||||||
const animated = ["image/gif", "image/webp"].includes(type)
|
const animated = ["image/gif", "image/webp"].includes(mime)
|
||||||
|
|
||||||
const transformer = sharp({animated: animated})
|
const transformer = sharp({animated: animated})
|
||||||
.resize(WIDTH, HEIGHT, {fit: "inside", background: {r: 0, g: 0, b: 0, alpha: 0}})
|
.resize(WIDTH, HEIGHT, {fit: "inside", background: {r: 0, g: 0, b: 0, alpha: 0}})
|
||||||
.webp()
|
.webp()
|
||||||
streamThrough.pipe(transformer)
|
stream.pipe(transformer)
|
||||||
return Readable.toWeb(transformer)
|
return Readable.toWeb(transformer)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
// @ts-check
|
|
||||||
|
|
||||||
const {discord} = require("../../passthrough")
|
|
||||||
|
|
||||||
/** @type {Set<string>} channel ids that are currently typing */
|
|
||||||
const isTyping = new Set()
|
|
||||||
/** @type {Map<string, NodeJS.Timeout>} channel id -> interval */
|
|
||||||
const intervals = new Map()
|
|
||||||
|
|
||||||
/** @param {string} channelID */
|
|
||||||
function startTyping(channelID) {
|
|
||||||
isTyping.add(channelID)
|
|
||||||
// Start a new typing session if needed
|
|
||||||
if (!intervals.has(channelID)) {
|
|
||||||
intervals.set(channelID, setInterval(refreshTyping, 8e3, channelID))
|
|
||||||
discord.snow.channel.startChannelTyping(channelID).catch(() => {})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @param {string} channelID */
|
|
||||||
function refreshTyping(channelID) {
|
|
||||||
if (isTyping.has(channelID)) {
|
|
||||||
// Continue typing session
|
|
||||||
discord.snow.channel.startChannelTyping(channelID).catch(() => {})
|
|
||||||
} else {
|
|
||||||
// End typing session
|
|
||||||
clearInterval(intervals.get(channelID))
|
|
||||||
intervals.delete(channelID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @param {string} channelID */
|
|
||||||
function stopTyping(channelID) {
|
|
||||||
// Schedule typing session to end
|
|
||||||
isTyping.delete(channelID)
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports.startTyping = startTyping
|
|
||||||
module.exports.stopTyping = stopTyping
|
|
||||||
|
|
@ -13,12 +13,12 @@ async function updatePins(pins, prev) {
|
||||||
const diff = diffPins.diffPins(pins, prev)
|
const diff = diffPins.diffPins(pins, prev)
|
||||||
for (const [event_id, added] of diff) {
|
for (const [event_id, added] of diff) {
|
||||||
const row = from("event_message").join("message_room", "message_id").join("historical_channel_room", "historical_room_index")
|
const row = from("event_message").join("message_room", "message_id").join("historical_channel_room", "historical_room_index")
|
||||||
.select("reference_channel_id", "message_id").where({event_id}).and("ORDER BY part ASC").get()
|
.select("reference_channel_id", "message_id").get()
|
||||||
if (!row) continue
|
if (!row) continue
|
||||||
if (added) {
|
if (added) {
|
||||||
discord.snow.channel.createChannelPinnedMessage(row.reference_channel_id, row.message_id, "Message pinned on Matrix")
|
discord.snow.channel.addChannelPinnedMessage(row.reference_channel_id, row.message_id, "Message pinned on Matrix")
|
||||||
} else {
|
} else {
|
||||||
discord.snow.channel.deleteChannelPinnedMessage(row.reference_channel_id, row.message_id, "Message unpinned on Matrix")
|
discord.snow.channel.removeChannelPinnedMessage(row.reference_channel_id, row.message_id, "Message unpinned on Matrix")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,18 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const Ty = require("../../types")
|
const Ty = require("../../types")
|
||||||
|
const DiscordTypes = require("discord-api-types/v10")
|
||||||
|
const {Readable} = require("stream")
|
||||||
const assert = require("assert").strict
|
const assert = require("assert").strict
|
||||||
|
const crypto = require("crypto")
|
||||||
const passthrough = require("../../passthrough")
|
const passthrough = require("../../passthrough")
|
||||||
const {sync, db, select} = passthrough
|
const {sync, discord, db, select} = passthrough
|
||||||
|
|
||||||
/** @type {import("../../discord/utils")} */
|
const {reg} = require("../../matrix/read-registration")
|
||||||
const dUtils = sync.require("../../discord/utils")
|
/** @type {import("../../matrix/api")} */
|
||||||
|
const api = sync.require("../../matrix/api")
|
||||||
|
/** @type {import("../../matrix/utils")} */
|
||||||
|
const utils = sync.require("../../matrix/utils")
|
||||||
/** @type {import("../converters/poll-components")} */
|
/** @type {import("../converters/poll-components")} */
|
||||||
const pollComponents = sync.require("../converters/poll-components")
|
const pollComponents = sync.require("../converters/poll-components")
|
||||||
/** @type {import("./channel-webhook")} */
|
/** @type {import("./channel-webhook")} */
|
||||||
|
|
@ -27,10 +33,9 @@ async function updateVote(event) {
|
||||||
|
|
||||||
// If poll was started on Matrix, the Discord version is using components, so we can update that to the current status
|
// If poll was started on Matrix, the Discord version is using components, so we can update that to the current status
|
||||||
if (messageRow.source === 0) {
|
if (messageRow.source === 0) {
|
||||||
const row = select("channel_room", ["channel_id", "thread_parent"], {room_id: event.room_id}).get()
|
const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get()
|
||||||
assert(row)
|
assert(channelID)
|
||||||
const {channelID, threadID} = dUtils.swapThreadID(row.channel_id, row.thread_parent)
|
await webhook.editMessageWithWebhook(channelID, messageID, pollComponents.getPollComponentsFromDatabase(messageID))
|
||||||
await webhook.editMessageWithWebhook(channelID, messageID, pollComponents.getPollComponentsFromDatabase(messageID), threadID)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ const {pipeline} = require("stream").promises
|
||||||
const sharp = require("sharp")
|
const sharp = require("sharp")
|
||||||
const {GIFrame} = require("@cloudrac3r/giframe")
|
const {GIFrame} = require("@cloudrac3r/giframe")
|
||||||
const {PNG} = require("@cloudrac3r/pngjs")
|
const {PNG} = require("@cloudrac3r/pngjs")
|
||||||
const {streamType} = require("@cloudrac3r/stream-type")
|
const streamMimeType = require("stream-mime-type")
|
||||||
|
|
||||||
const SIZE = 48
|
const SIZE = 48
|
||||||
const RESULT_WIDTH = 400
|
const RESULT_WIDTH = 400
|
||||||
|
|
@ -54,11 +54,11 @@ async function compositeMatrixEmojis(mxcs, mxcDownloader) {
|
||||||
* @returns {Promise<Buffer | undefined>} Uncompressed PNG image
|
* @returns {Promise<Buffer | undefined>} Uncompressed PNG image
|
||||||
*/
|
*/
|
||||||
async function convertImageStream(streamIn, stopStream) {
|
async function convertImageStream(streamIn, stopStream) {
|
||||||
const {streamThrough, type} = await streamType(streamIn)
|
const {stream, mime} = await streamMimeType.getMimeType(streamIn)
|
||||||
assert(["image/png", "image/jpeg", "image/webp", "image/gif", "image/apng"].includes(type), `Mime type ${type} is impossible for emojis`)
|
assert(["image/png", "image/jpeg", "image/webp", "image/gif", "image/apng"].includes(mime), `Mime type ${mime} is impossible for emojis`)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (type === "image/png" || type === "image/jpeg" || type === "image/webp") {
|
if (mime === "image/png" || mime === "image/jpeg" || mime === "image/webp") {
|
||||||
/** @type {{info: sharp.OutputInfo, buffer: Buffer}} */
|
/** @type {{info: sharp.OutputInfo, buffer: Buffer}} */
|
||||||
const result = await new Promise((resolve, reject) => {
|
const result = await new Promise((resolve, reject) => {
|
||||||
const transformer = sharp()
|
const transformer = sharp()
|
||||||
|
|
@ -70,15 +70,15 @@ async function convertImageStream(streamIn, stopStream) {
|
||||||
resolve({info, buffer})
|
resolve({info, buffer})
|
||||||
})
|
})
|
||||||
pipeline(
|
pipeline(
|
||||||
streamThrough,
|
stream,
|
||||||
transformer
|
transformer
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
return result.buffer
|
return result.buffer
|
||||||
|
|
||||||
} else if (type === "image/gif") {
|
} else if (mime === "image/gif") {
|
||||||
const giframe = new GIFrame(0)
|
const giframe = new GIFrame(0)
|
||||||
streamThrough.on("data", chunk => {
|
stream.on("data", chunk => {
|
||||||
giframe.feed(chunk)
|
giframe.feed(chunk)
|
||||||
})
|
})
|
||||||
const frame = await giframe.getFrame()
|
const frame = await giframe.getFrame()
|
||||||
|
|
@ -91,10 +91,10 @@ async function convertImageStream(streamIn, stopStream) {
|
||||||
.toBuffer({resolveWithObject: true})
|
.toBuffer({resolveWithObject: true})
|
||||||
return buffer.data
|
return buffer.data
|
||||||
|
|
||||||
} else if (type === "image/apng") {
|
} else if (mime === "image/apng") {
|
||||||
const png = new PNG({maxFrames: 1})
|
const png = new PNG({maxFrames: 1})
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
streamThrough.pipe(png)
|
stream.pipe(png)
|
||||||
/** @type {Buffer} */ // @ts-ignore
|
/** @type {Buffer} */ // @ts-ignore
|
||||||
const frame = await new Promise(resolve => png.on("parsed", resolve))
|
const frame = await new Promise(resolve => png.on("parsed", resolve))
|
||||||
stopStream()
|
stopStream()
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ const domino = require("domino")
|
||||||
const assert = require("assert").strict
|
const assert = require("assert").strict
|
||||||
const entities = require("entities")
|
const entities = require("entities")
|
||||||
const pb = require("prettier-bytes")
|
const pb = require("prettier-bytes")
|
||||||
const mimeTypes = require("mime-types")
|
|
||||||
const {tag} = require("@cloudrac3r/html-template-tag")
|
const {tag} = require("@cloudrac3r/html-template-tag")
|
||||||
|
|
||||||
const passthrough = require("../../passthrough")
|
const passthrough = require("../../passthrough")
|
||||||
|
|
@ -30,8 +29,6 @@ const pollComponents = sync.require("./poll-components")
|
||||||
const setupEmojis = sync.require("../actions/setup-emojis")
|
const setupEmojis = sync.require("../actions/setup-emojis")
|
||||||
/** @type {import("../../d2m/converters/user-to-mxid")} */
|
/** @type {import("../../d2m/converters/user-to-mxid")} */
|
||||||
const userToMxid = sync.require("../../d2m/converters/user-to-mxid")
|
const userToMxid = sync.require("../../d2m/converters/user-to-mxid")
|
||||||
/** @type {import("../../web/routes/letter-avatar")} */
|
|
||||||
const letterAvatar = sync.require("../../web/routes/letter-avatar")
|
|
||||||
|
|
||||||
/** @type {[RegExp, string][]} */
|
/** @type {[RegExp, string][]} */
|
||||||
const markdownEscapes = [
|
const markdownEscapes = [
|
||||||
|
|
@ -98,6 +95,15 @@ turndownService.addRule("underline", {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
turndownService.addRule("blockquote", {
|
||||||
|
filter: "blockquote",
|
||||||
|
replacement: function (content) {
|
||||||
|
content = content.replace(/^\n+|\n+$/g, "")
|
||||||
|
content = content.replace(/^/gm, "> ")
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
turndownService.addRule("spoiler", {
|
turndownService.addRule("spoiler", {
|
||||||
filter: function (node, options) {
|
filter: function (node, options) {
|
||||||
return node.tagName === "SPAN" && node.hasAttribute("data-mx-spoiler")
|
return node.tagName === "SPAN" && node.hasAttribute("data-mx-spoiler")
|
||||||
|
|
@ -384,7 +390,7 @@ function linkEndOfMessageSpriteSheet(content) {
|
||||||
*/
|
*/
|
||||||
async function handleRoomOrMessageLinks(input, di) {
|
async function handleRoomOrMessageLinks(input, di) {
|
||||||
let offset = 0
|
let offset = 0
|
||||||
for (const match of [...input.matchAll(/("?https:\/\/matrix.to\/#\/((?:#|%23|!|%21)[^"/, ?)]+)(?:\/((?:\$|%24)[^"/ ?)]+))?(?:\?[^",:!? )]*?)?)(">|[,<\n )]|$)/g)]) {
|
for (const match of [...input.matchAll(/("?https:\/\/matrix.to\/#\/((?:#|%23|!)[^"/, ?)]+)(?:\/(\$[^"/ ?)]+))?(?:\?[^",:!? )]*?)?)(">|[,<\n )]|$)/g)]) {
|
||||||
assert(typeof match.index === "number")
|
assert(typeof match.index === "number")
|
||||||
let [_, attributeValue, roomID, eventID, endMarker] = match
|
let [_, attributeValue, roomID, eventID, endMarker] = match
|
||||||
let result
|
let result
|
||||||
|
|
@ -465,8 +471,7 @@ async function checkWrittenMentions(content, senderMxid, roomID, guild, di) {
|
||||||
// @ts-ignore - typescript doesn't know about indices yet
|
// @ts-ignore - typescript doesn't know about indices yet
|
||||||
content: content.slice(0, writtenMentionMatch.indices[1][0]-1) + `@everyone` + content.slice(writtenMentionMatch.indices[1][1]),
|
content: content.slice(0, writtenMentionMatch.indices[1][0]-1) + `@everyone` + content.slice(writtenMentionMatch.indices[1][1]),
|
||||||
ensureJoined: [],
|
ensureJoined: [],
|
||||||
allowedMentionsParse: ["everyone"],
|
allowedMentionsParse: ["everyone"]
|
||||||
allowedMentionsUsers: []
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (writtenMentionMatch[1].length < 40) { // the API supports up to 100 characters, but really if you're searching more than 40, something messed up
|
} else if (writtenMentionMatch[1].length < 40) { // the API supports up to 100 characters, but really if you're searching more than 40, something messed up
|
||||||
|
|
@ -477,8 +482,7 @@ async function checkWrittenMentions(content, senderMxid, roomID, guild, di) {
|
||||||
// @ts-ignore - typescript doesn't know about indices yet
|
// @ts-ignore - typescript doesn't know about indices yet
|
||||||
content: content.slice(0, writtenMentionMatch.indices[1][0]-1) + `<@${results[0].user.id}>` + content.slice(writtenMentionMatch.indices[1][1]),
|
content: content.slice(0, writtenMentionMatch.indices[1][0]-1) + `<@${results[0].user.id}>` + content.slice(writtenMentionMatch.indices[1][1]),
|
||||||
ensureJoined: [results[0].user],
|
ensureJoined: [results[0].user],
|
||||||
allowedMentionsParse: [],
|
allowedMentionsParse: []
|
||||||
allowedMentionsUsers: [results[0].user.id]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -540,34 +544,16 @@ async function eventToMessage(event, guild, channel, di) {
|
||||||
let displayName = event.sender
|
let displayName = event.sender
|
||||||
let avatarURL = undefined
|
let avatarURL = undefined
|
||||||
const allowedMentionsParse = ["users", "roles"]
|
const allowedMentionsParse = ["users", "roles"]
|
||||||
const allowedMentionsUsers = []
|
|
||||||
/** @type {string[]} */
|
/** @type {string[]} */
|
||||||
let messageIDsToEdit = []
|
let messageIDsToEdit = []
|
||||||
let replyLine = ""
|
let replyLine = ""
|
||||||
|
|
||||||
// Extract a basic display name from the sender
|
// Extract a basic display name from the sender
|
||||||
const match = event.sender.match(/^@(.*?):/)
|
const match = event.sender.match(/^@(.*?):/)
|
||||||
if (match) displayName = match[1]
|
if (match) displayName = match[1]
|
||||||
|
|
||||||
// Try to extract an accurate display name and avatar URL from the member event
|
// Try to extract an accurate display name and avatar URL from the member event
|
||||||
const member = await getMemberFromCacheOrHomeserver(event.room_id, event.sender, di?.api)
|
const member = await getMemberFromCacheOrHomeserver(event.room_id, event.sender, di?.api)
|
||||||
if (member.displayname) displayName = member.displayname
|
if (member.displayname) displayName = member.displayname
|
||||||
if (member.avatar_url) avatarURL = mxUtils.getPublicUrlForMxc(member.avatar_url)
|
if (member.avatar_url) avatarURL = mxUtils.getPublicUrlForMxc(member.avatar_url)
|
||||||
|
|
||||||
// MSC4144: Override display name and avatar from per-message profile if present
|
|
||||||
const perMessageProfile = event.content["com.beeper.per_message_profile"]
|
|
||||||
if (perMessageProfile?.displayname) displayName = perMessageProfile.displayname
|
|
||||||
if (perMessageProfile && "avatar_url" in perMessageProfile) {
|
|
||||||
if (perMessageProfile.avatar_url) {
|
|
||||||
// use provided avatar_url
|
|
||||||
avatarURL = mxUtils.getPublicUrlForMxc(perMessageProfile.avatar_url)
|
|
||||||
} else if (perMessageProfile.avatar_url === "") {
|
|
||||||
// empty string avatar_url clears the avatar
|
|
||||||
avatarURL = undefined
|
|
||||||
}
|
|
||||||
// else, omitted/null falls back to member avatar
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the display name is too long to be put into the webhook (80 characters is the maximum),
|
// If the display name is too long to be put into the webhook (80 characters is the maximum),
|
||||||
// put the excess characters into displayNameRunoff, later to be put at the top of the message
|
// put the excess characters into displayNameRunoff, later to be put at the top of the message
|
||||||
let [displayNameShortened, displayNameRunoff] = splitDisplayName(displayName)
|
let [displayNameShortened, displayNameRunoff] = splitDisplayName(displayName)
|
||||||
|
|
@ -576,13 +562,6 @@ async function eventToMessage(event, guild, channel, di) {
|
||||||
displayNameRunoff = ""
|
displayNameRunoff = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Avatar post-processing. Use a thumbnail for media, or generate letter avatar if none present.
|
|
||||||
if (avatarURL) {
|
|
||||||
avatarURL = avatarURL + "?preset=avatar"
|
|
||||||
} else {
|
|
||||||
avatarURL = letterAvatar.getLetterAvatarURL(event.sender, displayNameShortened)
|
|
||||||
}
|
|
||||||
|
|
||||||
let content = event.content["body"] || "" // ultimate fallback
|
let content = event.content["body"] || "" // ultimate fallback
|
||||||
/** @type {{id: string, filename: string}[]} */
|
/** @type {{id: string, filename: string}[]} */
|
||||||
const attachments = []
|
const attachments = []
|
||||||
|
|
@ -614,20 +593,13 @@ async function eventToMessage(event, guild, channel, di) {
|
||||||
captionContent.addLine(`(Spoiler: ${fileSpoilerReason})`)
|
captionContent.addLine(`(Spoiler: ${fileSpoilerReason})`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// If filename is not specified, create one from mimetype
|
|
||||||
let filename = event.content.filename || event.content.body
|
|
||||||
if (!filename) {
|
|
||||||
const extension = mimeTypes.extension(event.content.info?.mimetype)
|
|
||||||
if (!extension) throw new Error("Filename and mimetype missing. Please report this bug to your client.")
|
|
||||||
filename = `file.${extension}`
|
|
||||||
}
|
|
||||||
|
|
||||||
// File link as alternative to uploading
|
// File link as alternative to uploading
|
||||||
if (!("file" in event.content) && event.content.info?.size > getFileSizeForGuild(guild)) {
|
if (!("file" in event.content) && event.content.info?.size > getFileSizeForGuild(guild)) {
|
||||||
// Upload (unencrypted) file as link, because it's too large for Discord
|
// Upload (unencrypted) file as link, because it's too large for Discord
|
||||||
// Do this by constructing a sample Matrix message with the link and then use the text processor to convert that + the original caption.
|
// Do this by constructing a sample Matrix message with the link and then use the text processor to convert that + the original caption.
|
||||||
const url = mxUtils.getPublicUrlForMxc(event.content.url)
|
const url = mxUtils.getPublicUrlForMxc(event.content.url)
|
||||||
assert(url)
|
assert(url)
|
||||||
|
const filename = event.content.filename || event.content.body
|
||||||
const emoji = attachmentEmojis.has(event.content.msgtype) ? attachmentEmojis.get(event.content.msgtype) + " " : ""
|
const emoji = attachmentEmojis.has(event.content.msgtype) ? attachmentEmojis.get(event.content.msgtype) + " " : ""
|
||||||
if (fileIsSpoiler) {
|
if (fileIsSpoiler) {
|
||||||
captionContent.addLine(`${emoji}Uploaded SPOILER file: <${url}> (${pb(event.content.info.size)})`, tag`${emoji}<em>Uploaded <strong>SPOILER</strong> file: <span data-mx-spoiler><a href="${url} ">${filename}</a></span> (${pb(event.content.info.size)})</em>`) // the space is necessary to work around a bug in Discord's URL previewer. the preview still gets blurred in the client.
|
captionContent.addLine(`${emoji}Uploaded SPOILER file: <${url}> (${pb(event.content.info.size)})`, tag`${emoji}<em>Uploaded <strong>SPOILER</strong> file: <span data-mx-spoiler><a href="${url} ">${filename}</a></span> (${pb(event.content.info.size)})</em>`) // the space is necessary to work around a bug in Discord's URL previewer. the preview still gets blurred in the client.
|
||||||
|
|
@ -636,6 +608,7 @@ async function eventToMessage(event, guild, channel, di) {
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Upload file as file
|
// Upload file as file
|
||||||
|
let filename = event.content.filename || event.content.body
|
||||||
if (fileIsSpoiler) filename = "SPOILER_" + filename
|
if (fileIsSpoiler) filename = "SPOILER_" + filename
|
||||||
if ("file" in event.content) {
|
if ("file" in event.content) {
|
||||||
// Encrypted
|
// Encrypted
|
||||||
|
|
@ -773,14 +746,14 @@ async function eventToMessage(event, guild, channel, di) {
|
||||||
repliedToEvent.content = repliedToEvent.content["m.new_content"]
|
repliedToEvent.content = repliedToEvent.content["m.new_content"]
|
||||||
}
|
}
|
||||||
/** @type {string} */
|
/** @type {string} */
|
||||||
let originalRepliedToContent = repliedToEvent.content.formatted_body || repliedToEvent.content.body
|
let repliedToContent = repliedToEvent.content.formatted_body || repliedToEvent.content.body
|
||||||
const fileReplyContentAlternative = attachmentEmojis.get(repliedToEvent.content.msgtype)
|
const fileReplyContentAlternative = attachmentEmojis.get(repliedToEvent.content.msgtype)
|
||||||
let contentPreview
|
let contentPreview
|
||||||
if (fileReplyContentAlternative) {
|
if (fileReplyContentAlternative) {
|
||||||
contentPreview = " " + fileReplyContentAlternative
|
contentPreview = " " + fileReplyContentAlternative
|
||||||
} else if (repliedToEvent.unsigned?.redacted_because) {
|
} else if (repliedToEvent.unsigned?.redacted_because) {
|
||||||
contentPreview = " (in reply to a deleted message)"
|
contentPreview = " (in reply to a deleted message)"
|
||||||
} else if (typeof originalRepliedToContent !== "string") {
|
} else if (typeof repliedToContent !== "string") {
|
||||||
// in reply to a weird metadata event like m.room.name, m.room.member...
|
// in reply to a weird metadata event like m.room.name, m.room.member...
|
||||||
// I'm not implementing text fallbacks for arbitrary room events. this should cover most cases
|
// I'm not implementing text fallbacks for arbitrary room events. this should cover most cases
|
||||||
// this has never ever happened in the wild anyway
|
// this has never ever happened in the wild anyway
|
||||||
|
|
@ -788,10 +761,9 @@ async function eventToMessage(event, guild, channel, di) {
|
||||||
contentPreview = " (channel details edited)"
|
contentPreview = " (channel details edited)"
|
||||||
} else {
|
} else {
|
||||||
// Generate a reply preview for a standard message
|
// Generate a reply preview for a standard message
|
||||||
let repliedToContent = originalRepliedToContent
|
|
||||||
repliedToContent = repliedToContent.replace(/.*<\/mx-reply>/s, "") // Remove everything before replies, so just use the actual message body
|
repliedToContent = repliedToContent.replace(/.*<\/mx-reply>/s, "") // Remove everything before replies, so just use the actual message body
|
||||||
repliedToContent = repliedToContent.replace(/^\s*<blockquote>.*?<\/blockquote>(.....)/s, "$1") // If the message starts with a blockquote, don't count it and use the message body afterwards
|
repliedToContent = repliedToContent.replace(/^\s*<blockquote>.*?<\/blockquote>(.....)/s, "$1") // If the message starts with a blockquote, don't count it and use the message body afterwards
|
||||||
repliedToContent = repliedToContent.replace(/(?:\n|<br ?\/?>)+/g, " ") // Should all be on one line
|
repliedToContent = repliedToContent.replace(/(?:\n|<br>)+/g, " ") // Should all be on one line
|
||||||
repliedToContent = repliedToContent.replace(/<span [^>]*data-mx-spoiler\b[^>]*>.*?<\/span>/g, "[spoiler]") // Good enough method of removing spoiler content. (I don't want to break out the HTML parser unless I have to.)
|
repliedToContent = repliedToContent.replace(/<span [^>]*data-mx-spoiler\b[^>]*>.*?<\/span>/g, "[spoiler]") // Good enough method of removing spoiler content. (I don't want to break out the HTML parser unless I have to.)
|
||||||
repliedToContent = repliedToContent.replace(/<img([^>]*)>/g, (_, att) => { // Convert Matrix emoji images into Discord emoji markdown
|
repliedToContent = repliedToContent.replace(/<img([^>]*)>/g, (_, att) => { // Convert Matrix emoji images into Discord emoji markdown
|
||||||
const mxcUrlMatch = att.match(/\bsrc="(mxc:\/\/[^"]+)"/)
|
const mxcUrlMatch = att.match(/\bsrc="(mxc:\/\/[^"]+)"/)
|
||||||
|
|
@ -803,18 +775,7 @@ async function eventToMessage(event, guild, channel, di) {
|
||||||
const contentPreviewChunks = chunk(repliedToContent, 50)
|
const contentPreviewChunks = chunk(repliedToContent, 50)
|
||||||
if (contentPreviewChunks.length) {
|
if (contentPreviewChunks.length) {
|
||||||
contentPreview = ": " + contentPreviewChunks[0]
|
contentPreview = ": " + contentPreviewChunks[0]
|
||||||
contentPreview = contentPreview.replace(/\bhttps?:\/\/[^ )]*/g, url => {
|
contentPreview = contentPreview.replace(/\bhttps?:\/\/[^ )]*/g, "<$&>")
|
||||||
const originalUrlIndex = originalRepliedToContent.indexOf(url)
|
|
||||||
if (originalUrlIndex !== -1 && originalRepliedToContent[originalUrlIndex + url.length]?.match(/[^ )>"'`]/)) { // URL was truncated by chunking, replace it
|
|
||||||
try {
|
|
||||||
const u = new URL(url)
|
|
||||||
return `<via ${u.hostname}>`
|
|
||||||
} catch (e) {
|
|
||||||
return `<url>`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return `<${url}>` // Full URL is present, escape it
|
|
||||||
})
|
|
||||||
if (contentPreviewChunks.length > 1) contentPreview = contentPreview.replace(/[,.']$/, "") + "..."
|
if (contentPreviewChunks.length > 1) contentPreview = contentPreview.replace(/[,.']$/, "") + "..."
|
||||||
} else {
|
} else {
|
||||||
contentPreview = ""
|
contentPreview = ""
|
||||||
|
|
@ -840,7 +801,7 @@ async function eventToMessage(event, guild, channel, di) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handling mentions of Discord users
|
// Handling mentions of Discord users
|
||||||
input = input.replace(/("https:\/\/matrix.to\/#\/((?:@|%40)[^"]+)")/g, (whole, attributeValue, mxid) => {
|
input = input.replace(/("https:\/\/matrix.to\/#\/((?:@|%40)[^"]+)")>/g, (whole, attributeValue, mxid) => {
|
||||||
mxid = decodeURIComponent(mxid)
|
mxid = decodeURIComponent(mxid)
|
||||||
if (mxUtils.eventSenderIsFromDiscord(mxid)) {
|
if (mxUtils.eventSenderIsFromDiscord(mxid)) {
|
||||||
// Handle mention of an OOYE sim user by their mxid
|
// Handle mention of an OOYE sim user by their mxid
|
||||||
|
|
@ -865,7 +826,7 @@ async function eventToMessage(event, guild, channel, di) {
|
||||||
input = input.replace(/("https:\/\/matrix.to.*?<\/a>):?/g, "$1")
|
input = input.replace(/("https:\/\/matrix.to.*?<\/a>):?/g, "$1")
|
||||||
|
|
||||||
// Element adds a bunch of <br> before </blockquote> but doesn't render them. I can't figure out how this even works in the browser, so let's just delete those.
|
// Element adds a bunch of <br> before </blockquote> but doesn't render them. I can't figure out how this even works in the browser, so let's just delete those.
|
||||||
input = input.replace(/(?:\n|<br ?\/?>\s*)*<\/blockquote>(?:\n|<br ?\/?>\s*)*/g, "</blockquote>")
|
input = input.replace(/(?:\n|<br ?\/?>\s*)*<\/blockquote>/g, "</blockquote>")
|
||||||
|
|
||||||
// The matrix spec hasn't decided whether \n counts as a newline or not, but I'm going to count it, because if it's in the data it's there for a reason.
|
// The matrix spec hasn't decided whether \n counts as a newline or not, but I'm going to count it, because if it's in the data it's there for a reason.
|
||||||
// But I should not count it if it's between block elements.
|
// But I should not count it if it's between block elements.
|
||||||
|
|
@ -895,9 +856,8 @@ async function eventToMessage(event, guild, channel, di) {
|
||||||
const doc = domino.createDocument(
|
const doc = domino.createDocument(
|
||||||
// DOM parsers arrange elements in the <head> and <body>. Wrapping in a custom element ensures elements are reliably arranged in a single element.
|
// DOM parsers arrange elements in the <head> and <body>. Wrapping in a custom element ensures elements are reliably arranged in a single element.
|
||||||
'<x-turndown id="turndown-root">' + input + '</x-turndown>'
|
'<x-turndown id="turndown-root">' + input + '</x-turndown>'
|
||||||
)
|
);
|
||||||
const root = doc.getElementById("turndown-root")
|
const root = doc.getElementById("turndown-root");
|
||||||
assert(root)
|
|
||||||
async function forEachNode(event, node) {
|
async function forEachNode(event, node) {
|
||||||
for (; node; node = node.nextSibling) {
|
for (; node; node = node.nextSibling) {
|
||||||
// Check written mentions
|
// Check written mentions
|
||||||
|
|
@ -911,12 +871,9 @@ async function eventToMessage(event, guild, channel, di) {
|
||||||
}
|
}
|
||||||
// Check for incompatible backticks in code blocks
|
// Check for incompatible backticks in code blocks
|
||||||
let preNode
|
let preNode
|
||||||
let isBackticksTextInPre = node.nodeType === 3 && node.nodeValue.includes("```") && (preNode = nodeIsChildOf(node, ["PRE"]))
|
if (node.nodeType === 3 && node.nodeValue.includes("```") && (preNode = nodeIsChildOf(node, ["PRE"]))) {
|
||||||
let isLongPre = node.tagName === "PRE" && node.textContent.length > 1800 && (preNode = node)
|
|
||||||
if (isBackticksTextInPre || isLongPre) {
|
|
||||||
if (preNode.firstChild?.nodeName === "CODE") {
|
if (preNode.firstChild?.nodeName === "CODE") {
|
||||||
let ext = preNode.firstChild.className.match(/language-(\S+)/)?.[1]
|
const ext = preNode.firstChild.className.match(/language-(\S+)/)?.[1] || "txt"
|
||||||
if (!dUtils.supportedPlaintextPreviewExtensions.has(ext)) ext = "txt"
|
|
||||||
const filename = `inline_code.${ext}`
|
const filename = `inline_code.${ext}`
|
||||||
// Build the replacement <code> node
|
// Build the replacement <code> node
|
||||||
const replacementCode = doc.createElement("code")
|
const replacementCode = doc.createElement("code")
|
||||||
|
|
@ -941,7 +898,7 @@ async function eventToMessage(event, guild, channel, di) {
|
||||||
let shouldSuppress = inBody !== -1 && event.content.body[inBody-1] === "<"
|
let shouldSuppress = inBody !== -1 && event.content.body[inBody-1] === "<"
|
||||||
if (!shouldSuppress && guild?.roles) {
|
if (!shouldSuppress && guild?.roles) {
|
||||||
// Suppress if regular users don't have permission
|
// Suppress if regular users don't have permission
|
||||||
const permissions = dUtils.getDefaultPermissions(guild, channel?.permission_overwrites)
|
const permissions = dUtils.getPermissions(guild.id, [], guild.roles)
|
||||||
const canEmbedLinks = dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.EmbedLinks)
|
const canEmbedLinks = dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.EmbedLinks)
|
||||||
shouldSuppress = !canEmbedLinks
|
shouldSuppress = !canEmbedLinks
|
||||||
}
|
}
|
||||||
|
|
@ -953,7 +910,6 @@ async function eventToMessage(event, guild, channel, di) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await forEachNode(event, root)
|
await forEachNode(event, root)
|
||||||
if (perMessageProfile?.has_fallback) root.querySelectorAll("[data-mx-profile-fallback]").forEach(x => x.remove())
|
|
||||||
|
|
||||||
// SPRITE SHEET EMOJIS FEATURE: Emojis at the end of the message that we don't know about will be reuploaded as a sprite sheet.
|
// SPRITE SHEET EMOJIS FEATURE: Emojis at the end of the message that we don't know about will be reuploaded as a sprite sheet.
|
||||||
// First we need to determine which emojis are at the end.
|
// First we need to determine which emojis are at the end.
|
||||||
|
|
@ -985,10 +941,6 @@ async function eventToMessage(event, guild, channel, di) {
|
||||||
} else {
|
} else {
|
||||||
// Looks like we're using the plaintext body!
|
// Looks like we're using the plaintext body!
|
||||||
content = event.content.body
|
content = event.content.body
|
||||||
if (perMessageProfile?.has_fallback && perMessageProfile.displayname && content.startsWith(perMessageProfile.displayname + ": ")) {
|
|
||||||
// Strip the display name prefix fallback added for clients that don't support per-message profiles
|
|
||||||
content = content.slice(perMessageProfile.displayname.length + 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.content.msgtype === "m.emote") {
|
if (event.content.msgtype === "m.emote") {
|
||||||
content = `* ${displayName} ${content}`
|
content = `* ${displayName} ${content}`
|
||||||
|
|
@ -1009,7 +961,7 @@ async function eventToMessage(event, guild, channel, di) {
|
||||||
|
|
||||||
// Suppress if regular users don't have permission
|
// Suppress if regular users don't have permission
|
||||||
if (!shouldSuppress && guild?.roles) {
|
if (!shouldSuppress && guild?.roles) {
|
||||||
const permissions = dUtils.getDefaultPermissions(guild, channel.permission_overwrites)
|
const permissions = dUtils.getPermissions(guild.id, [], guild.roles, undefined, channel.permission_overwrites)
|
||||||
const canEmbedLinks = dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.EmbedLinks)
|
const canEmbedLinks = dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.EmbedLinks)
|
||||||
shouldSuppress = !canEmbedLinks
|
shouldSuppress = !canEmbedLinks
|
||||||
}
|
}
|
||||||
|
|
@ -1034,34 +986,16 @@ async function eventToMessage(event, guild, channel, di) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Complete content
|
|
||||||
content = displayNameRunoff + replyLine + content
|
content = displayNameRunoff + replyLine + content
|
||||||
|
|
||||||
// Split into 2000 character chunks
|
// Split into 2000 character chunks
|
||||||
const chunks = chunk(content, 2000)
|
const chunks = chunk(content, 2000)
|
||||||
|
|
||||||
// If m.mentions is specified and valid, overwrite allowedMentionsParse with a converted m.mentions
|
|
||||||
let allowed_mentions = {parse: allowedMentionsParse}
|
|
||||||
if (event.content["m.mentions"]) {
|
|
||||||
// Combine requested mentions with detected written mentions to get the full list
|
|
||||||
if (Array.isArray(event.content["m.mentions"].user_ids)) {
|
|
||||||
for (const mxid of event.content["m.mentions"].user_ids) {
|
|
||||||
const user_id = select("sim", "user_id", {mxid}).pluck().get()
|
|
||||||
if (!user_id) continue
|
|
||||||
allowedMentionsUsers.push(
|
|
||||||
select("sim_proxy", "proxy_owner_id", {user_id}).pluck().get() || user_id
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Specific mentions were requested, so do not parse users
|
|
||||||
allowed_mentions.parse = allowed_mentions.parse.filter(x => x !== "users")
|
|
||||||
allowed_mentions.users = allowedMentionsUsers
|
|
||||||
}
|
|
||||||
|
|
||||||
// Assemble chunks into Discord messages content
|
|
||||||
/** @type {(DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | stream.Readable}[]})[]} */
|
/** @type {(DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | stream.Readable}[]})[]} */
|
||||||
const messages = chunks.map(content => ({
|
const messages = chunks.map(content => ({
|
||||||
content,
|
content,
|
||||||
allowed_mentions,
|
allowed_mentions: {
|
||||||
|
parse: allowedMentionsParse
|
||||||
|
},
|
||||||
username: displayNameShortened,
|
username: displayNameShortened,
|
||||||
avatar_url: avatarURL
|
avatar_url: avatarURL
|
||||||
}))
|
}))
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -24,8 +24,6 @@ const vote = sync.require("./actions/vote")
|
||||||
const matrixCommandHandler = sync.require("../matrix/matrix-command-handler")
|
const matrixCommandHandler = sync.require("../matrix/matrix-command-handler")
|
||||||
/** @type {import("../matrix/utils")} */
|
/** @type {import("../matrix/utils")} */
|
||||||
const utils = sync.require("../matrix/utils")
|
const utils = sync.require("../matrix/utils")
|
||||||
/** @type {import("../matrix/mreq")}) */
|
|
||||||
const mreq = sync.require("../matrix/mreq")
|
|
||||||
/** @type {import("../matrix/api")}) */
|
/** @type {import("../matrix/api")}) */
|
||||||
const api = sync.require("../matrix/api")
|
const api = sync.require("../matrix/api")
|
||||||
/** @type {import("../d2m/actions/create-room")} */
|
/** @type {import("../d2m/actions/create-room")} */
|
||||||
|
|
@ -34,10 +32,6 @@ const createRoom = sync.require("../d2m/actions/create-room")
|
||||||
const roomUpgrade = require("../matrix/room-upgrade")
|
const roomUpgrade = require("../matrix/room-upgrade")
|
||||||
/** @type {import("../d2m/actions/retrigger")} */
|
/** @type {import("../d2m/actions/retrigger")} */
|
||||||
const retrigger = sync.require("../d2m/actions/retrigger")
|
const retrigger = sync.require("../d2m/actions/retrigger")
|
||||||
/** @type {import("../matrix/homeserver-status")} */
|
|
||||||
const homeserverStatus = sync.require("../matrix/homeserver-status")
|
|
||||||
/** @type {import("./actions/typing")} */
|
|
||||||
const typing = sync.require("./actions/typing")
|
|
||||||
const {reg} = require("../matrix/read-registration")
|
const {reg} = require("../matrix/read-registration")
|
||||||
|
|
||||||
let lastReportedEvent = 0
|
let lastReportedEvent = 0
|
||||||
|
|
@ -100,11 +94,6 @@ function printError(type, source, e, payload) {
|
||||||
console.dir(payload, {depth: null})
|
console.dir(payload, {depth: null})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param {string} stack */
|
|
||||||
function cleanErrorStack(stack) {
|
|
||||||
return stack.replace(/(\/webhooks\/[0-9]+\/)[a-zA-Z0-9_-]+/g, "$1(redacted)")
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} roomID
|
* @param {string} roomID
|
||||||
* @param {"Discord" | "Matrix"} source
|
* @param {"Discord" | "Matrix"} source
|
||||||
|
|
@ -145,7 +134,7 @@ async function sendError(roomID, source, type, e, payload) {
|
||||||
builder.addLine(errorIntroLine)
|
builder.addLine(errorIntroLine)
|
||||||
|
|
||||||
// Where
|
// Where
|
||||||
const stack = cleanErrorStack(stringifyErrorStack(e))
|
const stack = stringifyErrorStack(e)
|
||||||
builder.addLine(`Error trace:\n${stack}`, tag`<details><summary>Error trace</summary><pre>${stack}</pre></details>`)
|
builder.addLine(`Error trace:\n${stack}`, tag`<details><summary>Error trace</summary><pre>${stack}</pre></details>`)
|
||||||
|
|
||||||
// How
|
// How
|
||||||
|
|
@ -154,7 +143,7 @@ async function sendError(roomID, source, type, e, payload) {
|
||||||
|
|
||||||
// Send
|
// Send
|
||||||
try {
|
try {
|
||||||
const errorEventID = await api.sendEvent(roomID, "m.room.message", {
|
await api.sendEvent(roomID, "m.room.message", {
|
||||||
...builder.get(),
|
...builder.get(),
|
||||||
"moe.cadence.ooye.error": {
|
"moe.cadence.ooye.error": {
|
||||||
source: source.toLowerCase(),
|
source: source.toLowerCase(),
|
||||||
|
|
@ -164,23 +153,7 @@ async function sendError(roomID, source, type, e, payload) {
|
||||||
user_ids: ["@cadence:cadence.moe"]
|
user_ids: ["@cadence:cadence.moe"]
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
// Add reaction indicating that errors may be retried
|
} catch (e) {}
|
||||||
await api.sendEvent(roomID, "m.reaction", {
|
|
||||||
"m.relates_to": {
|
|
||||||
rel_type: "m.annotation",
|
|
||||||
event_id: errorEventID,
|
|
||||||
key: "🔁"
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} catch (e) {
|
|
||||||
if (
|
|
||||||
e.toString().includes("fetch failed")
|
|
||||||
|| (e instanceof mreq.MatrixServerError && [502, 503].includes(e.httpStatus) && e.errcode === "CX_SERVER_ERROR")
|
|
||||||
) {
|
|
||||||
// Matrix homeserver is down (reverse proxy indicated failure)
|
|
||||||
homeserverStatus.homeserverStatus.setErrorWithPacket(payload)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function guard(type, fn) {
|
function guard(type, fn) {
|
||||||
|
|
@ -199,7 +172,6 @@ const errorRetrySema = new Semaphore()
|
||||||
* @param {Ty.Event.Outer<Ty.Event.M_Reaction>} reactionEvent
|
* @param {Ty.Event.Outer<Ty.Event.M_Reaction>} reactionEvent
|
||||||
*/
|
*/
|
||||||
async function onRetryReactionAdd(reactionEvent) {
|
async function onRetryReactionAdd(reactionEvent) {
|
||||||
if (reactionEvent.sender === `@${reg.sender_localpart}:${reg.ooye.server_name}`) return // Don't respond to the bot's own indicative reaction
|
|
||||||
const roomID = reactionEvent.room_id
|
const roomID = reactionEvent.room_id
|
||||||
await errorRetrySema.request(async () => {
|
await errorRetrySema.request(async () => {
|
||||||
const event = await api.getEvent(roomID, reactionEvent.content["m.relates_to"]?.event_id)
|
const event = await api.getEvent(roomID, reactionEvent.content["m.relates_to"]?.event_id)
|
||||||
|
|
@ -239,7 +211,7 @@ async event => {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
await matrixCommandHandler.execute(event)
|
await matrixCommandHandler.execute(event)
|
||||||
}
|
}
|
||||||
retrigger.finishedBridging(event.event_id)
|
retrigger.messageFinishedBridging(event.event_id)
|
||||||
await api.ackEvent(event)
|
await api.ackEvent(event)
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|
@ -250,7 +222,7 @@ sync.addTemporaryListener(as, "type:m.sticker", guard("m.sticker",
|
||||||
async event => {
|
async event => {
|
||||||
if (utils.eventSenderIsFromDiscord(event.sender)) return
|
if (utils.eventSenderIsFromDiscord(event.sender)) return
|
||||||
const messageResponses = await sendEvent.sendEvent(event)
|
const messageResponses = await sendEvent.sendEvent(event)
|
||||||
retrigger.finishedBridging(event.event_id)
|
retrigger.messageFinishedBridging(event.event_id)
|
||||||
await api.ackEvent(event)
|
await api.ackEvent(event)
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|
@ -441,7 +413,6 @@ async event => {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
return await api.leaveRoomWithReason(event.room_id, `I wasn't able to find out what this room is. Please report this as a bug. Check console for more details. (${e.toString()})`)
|
return await api.leaveRoomWithReason(event.room_id, `I wasn't able to find out what this room is. Please report this as a bug. Check console for more details. (${e.toString()})`)
|
||||||
}
|
}
|
||||||
if (inviteRoomState?.encryption) return await api.leaveRoomWithReason(event.room_id, "Encrypted rooms are not supported for bridging. Please use an unencrypted room.")
|
|
||||||
if (!inviteRoomState?.name) return await api.leaveRoomWithReason(event.room_id, `Please only invite me to rooms that have a name/avatar set. Update the room details and reinvite.`)
|
if (!inviteRoomState?.name) return await api.leaveRoomWithReason(event.room_id, `Please only invite me to rooms that have a name/avatar set. Update the room details and reinvite.`)
|
||||||
await api.joinRoom(event.room_id)
|
await api.joinRoom(event.room_id)
|
||||||
db.prepare("REPLACE INTO invite (mxid, room_id, type, name, topic, avatar) VALUES (?, ?, ?, ?, ?, ?)").run(event.sender, event.room_id, inviteRoomState.type, inviteRoomState.name, inviteRoomState.topic, inviteRoomState.avatar)
|
db.prepare("REPLACE INTO invite (mxid, room_id, type, name, topic, avatar) VALUES (?, ?, ?, ?, ?, ?)").run(event.sender, event.room_id, inviteRoomState.type, inviteRoomState.name, inviteRoomState.topic, inviteRoomState.avatar)
|
||||||
|
|
@ -451,10 +422,7 @@ async event => {
|
||||||
|
|
||||||
if (event.content.membership === "leave" || event.content.membership === "ban") {
|
if (event.content.membership === "leave" || event.content.membership === "ban") {
|
||||||
// Member is gone
|
// Member is gone
|
||||||
// if Matrix member, data was cached in member_cache
|
|
||||||
db.prepare("DELETE FROM member_cache WHERE room_id = ? and mxid = ?").run(event.room_id, event.state_key)
|
db.prepare("DELETE FROM member_cache WHERE room_id = ? and mxid = ?").run(event.room_id, event.state_key)
|
||||||
// if Discord member (so kicked/banned by Matrix user), data was cached in sim_member
|
|
||||||
db.prepare("DELETE FROM sim_member WHERE room_id = ? and mxid = ?").run(event.room_id, event.state_key)
|
|
||||||
|
|
||||||
// Unregister room's use as a direct chat and/or an invite target if the bot itself left
|
// Unregister room's use as a direct chat and/or an invite target if the bot itself left
|
||||||
if (event.state_key === utils.bot) {
|
if (event.state_key === utils.bot) {
|
||||||
|
|
@ -515,35 +483,6 @@ async event => {
|
||||||
await roomUpgrade.onTombstone(event, api)
|
await roomUpgrade.onTombstone(event, api)
|
||||||
}))
|
}))
|
||||||
|
|
||||||
sync.addTemporaryListener(as, "type:m.room.encryption", guard("m.room.encryption",
|
|
||||||
/**
|
|
||||||
* @param {Ty.Event.StateOuter<Ty.Event.M_Room_Encryption>} event
|
|
||||||
*/
|
|
||||||
async event => {
|
|
||||||
// Dramatically unbridge rooms if they become encrypted
|
|
||||||
if (event.state_key !== "") return
|
|
||||||
const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get()
|
|
||||||
if (!channelID) return
|
|
||||||
const channel = discord.channels.get(channelID)
|
|
||||||
if (!channel) return
|
|
||||||
await createRoom.unbridgeChannel(channel, channel["guild_id"], "Encrypted rooms are not supported. This room was removed from the bridge.")
|
|
||||||
}))
|
|
||||||
|
|
||||||
sync.addTemporaryListener(as, "ephemeral_type:m.typing", guard("m.typing",
|
|
||||||
/**
|
|
||||||
* @param {Ty.Event.Outer_M_Typing} event
|
|
||||||
*/
|
|
||||||
async event => {
|
|
||||||
const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get()
|
|
||||||
if (!channelID) return
|
|
||||||
if (event.content.user_ids.some(mxid => !utils.eventSenderIsFromDiscord(mxid))) {
|
|
||||||
typing.startTyping(channelID)
|
|
||||||
} else {
|
|
||||||
typing.stopTyping(channelID)
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
|
|
||||||
module.exports.stringifyErrorStack = stringifyErrorStack
|
module.exports.stringifyErrorStack = stringifyErrorStack
|
||||||
module.exports.cleanErrorStack = cleanErrorStack
|
|
||||||
module.exports.sendError = sendError
|
module.exports.sendError = sendError
|
||||||
module.exports.printError = printError
|
module.exports.printError = printError
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const {test} = require("supertape")
|
const {test} = require("supertape")
|
||||||
const {stringifyErrorStack, cleanErrorStack} = require("./event-dispatcher")
|
const {stringifyErrorStack} = require("./event-dispatcher")
|
||||||
|
|
||||||
test("stringify error stack: works", t => {
|
test("stringify error stack: works", t => {
|
||||||
function a() {
|
function a() {
|
||||||
|
|
@ -21,30 +21,3 @@ test("stringify error stack: works", t => {
|
||||||
t.match(str, /^ \[prop\]: 2.1$/m)
|
t.match(str, /^ \[prop\]: 2.1$/m)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("clean error stack: removes webhook token", t => {
|
|
||||||
t.notMatch(
|
|
||||||
cleanErrorStack(`
|
|
||||||
DiscordAPIError: Service resource is being rate limited.
|
|
||||||
at fn (/var/home/cadence/out-of-your-element/node_modules/snowtransfer/src/RequestHandler.ts:591:13)
|
|
||||||
at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
|
|
||||||
at exports.RequestHandler.request (/var/home/cadence/out-of-your-element/node_modules/snowtransfer/src/RequestHandler.ts:546:17)
|
|
||||||
at WebhookMethods.executeWebhook (/var/home/cadence/out-of-your-element/node_modules/snowtransfer/src/methods/Webhook.ts:249:35)
|
|
||||||
at /var/home/cadence/out-of-your-element/src/m2d/actions/channel-webhook.js:65:31
|
|
||||||
at withWebhook (/var/home/cadence/out-of-your-element/src/m2d/actions/channel-webhook.js:47:9)
|
|
||||||
at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
|
|
||||||
at async Object.sendMessageWithWebhook (/var/home/cadence/out-of-your-element/src/m2d/actions/channel-webhook.js:64:17)
|
|
||||||
at async Object.sendEvent (/var/home/cadence/out-of-your-element/src/m2d/actions/send-event.js:132:27)
|
|
||||||
at async /var/home/cadence/out-of-your-element/src/m2d/event-dispatcher.js:208:27
|
|
||||||
at async AppService.<anonymous> (/var/home/cadence/out-of-your-element/src/m2d/event-dispatcher.js:162:11) {
|
|
||||||
[method]: "POST"
|
|
||||||
[path]: "/webhooks/1160903754728611841/pfRqHl9vVZImdqwWWSZxxH8T-JJMnauxroMnHsvC6ARA-3B9_STH_bnHB9pd7QQaUVCG"
|
|
||||||
[code]: 40062
|
|
||||||
[httpStatus]: 429
|
|
||||||
[request]: {"endpoint":"/webhooks/1160903754728611841/pfRqHl9vVZImdqwWWSZxxH8T-JJMnauxroMnHsvC6ARA-3B9_STH_bnHB9pd7QQaUVCG","method":"POST","dataType":"json","data":{"content":"https://discordstatus.com/#day\nOnly what discord tell us right now","allowed_mentions":{"parse":["roles"],"users":[]},"username":"lewri","avatar_url":"https://bridge.cadence.moe/download/matrix/matrix.org/URWwrtSUONGOYhfMsdUzcrir"}}
|
|
||||||
[response]: {}
|
|
||||||
[name]: "DiscordAPIError"`
|
|
||||||
),
|
|
||||||
/pfRqHl9v/
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
|
||||||
|
|
@ -172,7 +172,7 @@ function getStateEventOuter(roomID, type, key) {
|
||||||
/**
|
/**
|
||||||
* @param {string} roomID
|
* @param {string} roomID
|
||||||
* @param {{unsigned?: {invite_room_state?: Ty.Event.InviteStrippedState[]}}} [event]
|
* @param {{unsigned?: {invite_room_state?: Ty.Event.InviteStrippedState[]}}} [event]
|
||||||
* @returns {Promise<{name: string?, topic: string?, avatar: string?, type: string?, encryption: string?}>}
|
* @returns {Promise<{name: string?, topic: string?, avatar: string?, type: string?}>}
|
||||||
*/
|
*/
|
||||||
async function getInviteState(roomID, event) {
|
async function getInviteState(roomID, event) {
|
||||||
function getFromInviteRoomState(strippedState, nskey, key) {
|
function getFromInviteRoomState(strippedState, nskey, key) {
|
||||||
|
|
@ -191,8 +191,7 @@ async function getInviteState(roomID, event) {
|
||||||
name: getFromInviteRoomState(event.unsigned.invite_room_state, "m.room.name", "name"),
|
name: getFromInviteRoomState(event.unsigned.invite_room_state, "m.room.name", "name"),
|
||||||
topic: getFromInviteRoomState(event.unsigned.invite_room_state, "m.room.topic", "topic"),
|
topic: getFromInviteRoomState(event.unsigned.invite_room_state, "m.room.topic", "topic"),
|
||||||
avatar: getFromInviteRoomState(event.unsigned.invite_room_state, "m.room.avatar", "url"),
|
avatar: getFromInviteRoomState(event.unsigned.invite_room_state, "m.room.avatar", "url"),
|
||||||
type: getFromInviteRoomState(event.unsigned.invite_room_state, "m.room.create", "type"),
|
type: getFromInviteRoomState(event.unsigned.invite_room_state, "m.room.create", "type")
|
||||||
encryption: getFromInviteRoomState(event.unsigned.invite_room_state, "m.room.encryption", "algorithm")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -228,8 +227,7 @@ async function getInviteState(roomID, event) {
|
||||||
name: getFromInviteRoomState(strippedState, "m.room.name", "name"),
|
name: getFromInviteRoomState(strippedState, "m.room.name", "name"),
|
||||||
topic: getFromInviteRoomState(strippedState, "m.room.topic", "topic"),
|
topic: getFromInviteRoomState(strippedState, "m.room.topic", "topic"),
|
||||||
avatar: getFromInviteRoomState(strippedState, "m.room.avatar", "url"),
|
avatar: getFromInviteRoomState(strippedState, "m.room.avatar", "url"),
|
||||||
type: getFromInviteRoomState(strippedState, "m.room.create", "type"),
|
type: getFromInviteRoomState(strippedState, "m.room.create", "type")
|
||||||
encryption: getFromInviteRoomState(strippedState, "m.room.encryption", "algorithm")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|
@ -242,8 +240,7 @@ async function getInviteState(roomID, event) {
|
||||||
name: room.name ?? null,
|
name: room.name ?? null,
|
||||||
topic: room.topic ?? null,
|
topic: room.topic ?? null,
|
||||||
avatar: room.avatar_url ?? null,
|
avatar: room.avatar_url ?? null,
|
||||||
type: room.room_type ?? null,
|
type: room.room_type ?? null
|
||||||
encryption: (room.encryption || room["im.nheko.summary.encryption"]) ?? null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -452,8 +449,7 @@ async function ping() {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${reg.as_token}`
|
Authorization: `Bearer ${reg.as_token}`
|
||||||
},
|
},
|
||||||
body: "{}",
|
body: "{}"
|
||||||
signal: AbortSignal.timeout(15e3)
|
|
||||||
})
|
})
|
||||||
const root = await res.json()
|
const root = await res.json()
|
||||||
return {
|
return {
|
||||||
|
|
@ -464,29 +460,17 @@ async function ping() {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Given an mxc:// URL, and optional parameters for thumbnailing, get the file from the content repository. Returns res.
|
* Given an mxc:// URL, and an optional height for thumbnailing, get the file from the content repository. Returns res.
|
||||||
*
|
|
||||||
* Note that Synapse currently doesn't support animated thumbnails: https://github.com/element-hq/synapse/pull/18831
|
|
||||||
* @see https://spec.matrix.org/v1.18/client-server-api/#get_matrixclientv1mediathumbnailservernamemediaid
|
|
||||||
* @param {string} mxc
|
* @param {string} mxc
|
||||||
* @param {RequestInit & {thumbnail?: {height?: number | string, width?: number | string, animated?: boolean, method?: "crop" | "scale"}}} [init]
|
* @param {RequestInit & {height?: number | string}} [init]
|
||||||
* @return {Promise<Response & {body: streamWeb.ReadableStream<Uint8Array>}>}
|
* @return {Promise<Response & {body: streamWeb.ReadableStream<Uint8Array>}>}
|
||||||
*/
|
*/
|
||||||
async function getMedia(mxc, init = {}) {
|
async function getMedia(mxc, init = {}) {
|
||||||
init = {...init}
|
const mediaParts = mxc?.match(/^mxc:\/\/([^/]+)\/(\w+)$/)
|
||||||
|
|
||||||
const mediaParts = mxc?.match(/^mxc:\/\/([^/]+)\/([a-zA-Z0-9_-]+)$/)
|
|
||||||
assert(mediaParts)
|
assert(mediaParts)
|
||||||
|
const downloadOrThumbnail = init.height ? "thumbnail" : "download"
|
||||||
let route = "download"
|
let url = `${mreq.baseUrl}/client/v1/media/${downloadOrThumbnail}/${mediaParts[1]}/${mediaParts[2]}`
|
||||||
let query = ""
|
if (init.height) url += "?" + new URLSearchParams({height: String(init.height), width: String(init.height)})
|
||||||
|
|
||||||
if (init.thumbnail) {
|
|
||||||
route = "thumbnail"
|
|
||||||
query = "?" + new URLSearchParams(Object.keys(init.thumbnail).map(k => [k, String(init.thumbnail?.[k])]))
|
|
||||||
}
|
|
||||||
|
|
||||||
let url = `${mreq.baseUrl}/client/v1/media/${route}/${mediaParts[1]}/${mediaParts[2]}${query}`
|
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${reg.as_token}`
|
Authorization: `Bearer ${reg.as_token}`
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,6 @@ async function _actuallyUploadDiscordFileToMxc(url) {
|
||||||
writeRegistration(reg)
|
writeRegistration(reg)
|
||||||
return root
|
return root
|
||||||
}
|
}
|
||||||
e.uploadURL = url
|
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,134 +0,0 @@
|
||||||
// @ts-check
|
|
||||||
|
|
||||||
const assert = require("assert")
|
|
||||||
const Denque = require("denque")
|
|
||||||
const StateMachine = require("snowtransfer").StateMachine
|
|
||||||
|
|
||||||
const passthrough = require("../passthrough")
|
|
||||||
const {sync} = passthrough
|
|
||||||
/** @type {import("../d2m/discord-packets")} */
|
|
||||||
const discordPackets = sync.require("../d2m/discord-packets")
|
|
||||||
/** @type {import("../matrix/api")} */
|
|
||||||
const api = sync.require("../matrix/api")
|
|
||||||
|
|
||||||
const DEBUG_HOMESERVER_STATUS = false
|
|
||||||
|
|
||||||
function debugHomeserverStatus(message) {
|
|
||||||
if (DEBUG_HOMESERVER_STATUS) {
|
|
||||||
console.log(message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const homeserverStatus = new class HomeserverStatus {
|
|
||||||
constructor() {
|
|
||||||
/** @private */
|
|
||||||
this.queue = new Denque()
|
|
||||||
|
|
||||||
/** @private */
|
|
||||||
this.pingInterval = undefined
|
|
||||||
|
|
||||||
/** @private */
|
|
||||||
this.sm = new StateMachine("online")
|
|
||||||
.defineState("online")
|
|
||||||
|
|
||||||
.defineState("checking", {
|
|
||||||
onEnter: [async () => {
|
|
||||||
const pingResult = await api.ping().catch(e => ({ok: false}))
|
|
||||||
if (pingResult.ok) {
|
|
||||||
this.sm.doTransition("check ok")
|
|
||||||
} else {
|
|
||||||
this.sm.doTransition("check fail")
|
|
||||||
}
|
|
||||||
}],
|
|
||||||
onLeave: [],
|
|
||||||
transitions: new Map()
|
|
||||||
})
|
|
||||||
|
|
||||||
.defineState("offline", {
|
|
||||||
onEnter: [() => {
|
|
||||||
this.pingInterval = setInterval(async () => {
|
|
||||||
const pingResult = await api.ping().catch(e => ({ok: false, status: "net", root: e.message}))
|
|
||||||
if (pingResult.ok) {
|
|
||||||
this.sm.doTransition("ping ok")
|
|
||||||
}
|
|
||||||
}, 15e3)
|
|
||||||
}],
|
|
||||||
onLeave: [() => {
|
|
||||||
clearInterval(this.pingInterval)
|
|
||||||
}],
|
|
||||||
transitions: new Map()
|
|
||||||
})
|
|
||||||
|
|
||||||
.defineState("recovering", {
|
|
||||||
onEnter: [async () => { // Drain queue.
|
|
||||||
while (!this.queue.isEmpty()) {
|
|
||||||
const packet = this.queue.peekFront() // same position as .shift()
|
|
||||||
debugHomeserverStatus(`homeserver status: ${new Date().toISOString()} dq packet ${packet.t} ${packet.d?.content}`)
|
|
||||||
await discordPackets.dispatchPacketToBridge(passthrough.discord, packet)
|
|
||||||
if (this.sm.currentStateName !== "recovering") return // got kicked out due to another error
|
|
||||||
this.queue.shift()
|
|
||||||
}
|
|
||||||
this.sm.doTransition("recovered")
|
|
||||||
}],
|
|
||||||
onLeave: [],
|
|
||||||
transitions: new Map()
|
|
||||||
})
|
|
||||||
|
|
||||||
.defineUniversalTransition("error", "offline")
|
|
||||||
.defineTransition("offline", "ping ok", "recovering")
|
|
||||||
.defineTransition("recovering", "recovered", "online")
|
|
||||||
.defineTransition("online", "check", "checking")
|
|
||||||
.defineTransition("checking", "check ok", "recovering")
|
|
||||||
.defineTransition("checking", "check fail", "offline")
|
|
||||||
|
|
||||||
this.sm.on("enter", st => debugHomeserverStatus(`homeserver status: ${st}`))
|
|
||||||
this.sm.setMaxListeners(101)
|
|
||||||
this.sm.freeze()
|
|
||||||
}
|
|
||||||
|
|
||||||
isRealTime() {
|
|
||||||
return this.sm.currentStateName === "online"
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @param {boolean} forceCheck */
|
|
||||||
waitForOnline(forceCheck) {
|
|
||||||
const onlinePromise = new Promise(resolve => {
|
|
||||||
// Already online? Start check or just done
|
|
||||||
if (this.sm.currentStateName === "online") {
|
|
||||||
if (forceCheck) {
|
|
||||||
this.sm.doTransition("check")
|
|
||||||
} else {
|
|
||||||
return resolve(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Checking or not online. Wait for online.
|
|
||||||
const onlineListener = stateName => {
|
|
||||||
if (stateName === "online") {
|
|
||||||
this.sm.removeListener("enter", onlineListener)
|
|
||||||
resolve(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.sm.on("enter", onlineListener)
|
|
||||||
})
|
|
||||||
return onlinePromise
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* When offline or recovering, call this for incoming packets to queue them to be sent in order later.
|
|
||||||
*/
|
|
||||||
queuePacket(packet) {
|
|
||||||
assert(["offline", "recovering"].includes(this.sm.currentStateName))
|
|
||||||
this.queue.push(packet)
|
|
||||||
}
|
|
||||||
|
|
||||||
setErrorWithPacket(packet) {
|
|
||||||
const wasRecovering = this.sm.currentStateName === "recovering"
|
|
||||||
this.sm.doTransition("error")
|
|
||||||
if (!wasRecovering) { // if was recovering then packet is already in the right place in queue
|
|
||||||
this.queuePacket(packet)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports.homeserverStatus = homeserverStatus
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const assert = require("assert").strict
|
const assert = require("assert").strict
|
||||||
const DiscordTypes = require("discord-api-types/v10")
|
|
||||||
const Ty = require("../types")
|
const Ty = require("../types")
|
||||||
const {pipeline} = require("stream").promises
|
const {pipeline} = require("stream").promises
|
||||||
const sharp = require("sharp")
|
const sharp = require("sharp")
|
||||||
|
|
@ -105,8 +104,7 @@ const commands = [{
|
||||||
// Guard
|
// Guard
|
||||||
/** @type {string} */ // @ts-ignore
|
/** @type {string} */ // @ts-ignore
|
||||||
const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get()
|
const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get()
|
||||||
const channel = discord.channels.get(channelID)
|
const guildID = discord.channels.get(channelID)?.["guild_id"]
|
||||||
const guildID = channel?.["guild_id"]
|
|
||||||
let matrixOnlyReason = null
|
let matrixOnlyReason = null
|
||||||
const matrixOnlyConclusion = "So the emoji will be uploaded on Matrix-side only. It will still be usable over the bridge, but may have degraded functionality."
|
const matrixOnlyConclusion = "So the emoji will be uploaded on Matrix-side only. It will still be usable over the bridge, but may have degraded functionality."
|
||||||
// Check if we can/should upload to Discord, for various causes
|
// Check if we can/should upload to Discord, for various causes
|
||||||
|
|
@ -116,7 +114,7 @@ const commands = [{
|
||||||
const guild = discord.guilds.get(guildID)
|
const guild = discord.guilds.get(guildID)
|
||||||
assert(guild)
|
assert(guild)
|
||||||
const slots = getSlotCount(guild.premium_tier)
|
const slots = getSlotCount(guild.premium_tier)
|
||||||
const permissions = dUtils.getDefaultPermissions(guild, channel["permission_overwrites"])
|
const permissions = dUtils.getPermissions(guild.id, [], guild.roles)
|
||||||
if (guild.emojis.length >= slots) {
|
if (guild.emojis.length >= slots) {
|
||||||
matrixOnlyReason = "CAPACITY"
|
matrixOnlyReason = "CAPACITY"
|
||||||
} else if (!(permissions & 0x40000000n)) { // MANAGE_GUILD_EXPRESSIONS (apparently CREATE_GUILD_EXPRESSIONS isn't good enough...)
|
} else if (!(permissions & 0x40000000n)) { // MANAGE_GUILD_EXPRESSIONS (apparently CREATE_GUILD_EXPRESSIONS isn't good enough...)
|
||||||
|
|
@ -241,8 +239,7 @@ const commands = [{
|
||||||
// Guard
|
// Guard
|
||||||
/** @type {string} */ // @ts-ignore
|
/** @type {string} */ // @ts-ignore
|
||||||
const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get()
|
const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get()
|
||||||
const channel = discord.channels.get(channelID)
|
const guildID = discord.channels.get(channelID)?.["guild_id"]
|
||||||
const guildID = channel?.["guild_id"]
|
|
||||||
if (!guildID) {
|
if (!guildID) {
|
||||||
return api.sendEvent(event.room_id, "m.room.message", {
|
return api.sendEvent(event.room_id, "m.room.message", {
|
||||||
...ctx,
|
...ctx,
|
||||||
|
|
@ -253,7 +250,7 @@ const commands = [{
|
||||||
|
|
||||||
const guild = discord.guilds.get(guildID)
|
const guild = discord.guilds.get(guildID)
|
||||||
assert(guild)
|
assert(guild)
|
||||||
const permissions = dUtils.getDefaultPermissions(guild, channel["permission_overwrites"])
|
const permissions = dUtils.getPermissions(guild.id, [], guild.roles)
|
||||||
if (!(permissions & 0x800000000n)) { // CREATE_PUBLIC_THREADS
|
if (!(permissions & 0x800000000n)) { // CREATE_PUBLIC_THREADS
|
||||||
return api.sendEvent(event.room_id, "m.room.message", {
|
return api.sendEvent(event.room_id, "m.room.message", {
|
||||||
...ctx,
|
...ctx,
|
||||||
|
|
@ -265,59 +262,6 @@ const commands = [{
|
||||||
await discord.snow.channel.createThreadWithoutMessage(channelID, {type: 11, name: words.slice(1).join(" ")})
|
await discord.snow.channel.createThreadWithoutMessage(channelID, {type: 11, name: words.slice(1).join(" ")})
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}, {
|
|
||||||
aliases: ["invite"],
|
|
||||||
execute: replyctx(
|
|
||||||
async (event, realBody, words, ctx) => {
|
|
||||||
// Guard
|
|
||||||
/** @type {string} */ // @ts-ignore
|
|
||||||
const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get()
|
|
||||||
const channel = discord.channels.get(channelID)
|
|
||||||
const guildID = channel?.["guild_id"]
|
|
||||||
if (!guildID) {
|
|
||||||
return api.sendEvent(event.room_id, "m.room.message", {
|
|
||||||
...ctx,
|
|
||||||
msgtype: "m.text",
|
|
||||||
body: "This room isn't bridged to the other side."
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const guild = discord.guilds.get(guildID)
|
|
||||||
assert(guild)
|
|
||||||
const permissions = dUtils.getDefaultPermissions(guild, channel["permission_overwrites"])
|
|
||||||
if (!dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.CreateInstantInvite)) {
|
|
||||||
return api.sendEvent(event.room_id, "m.room.message", {
|
|
||||||
...ctx,
|
|
||||||
msgtype: "m.text",
|
|
||||||
body: "This command creates an invite link to the Discord side. But you aren't allowed to do this, because if you were a Discord user, you wouldn't have the Create Invite permission."
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
var invite = await discord.snow.channel.createChannelInvite(channelID)
|
|
||||||
} catch (e) {
|
|
||||||
if (e.message === `{"message": "Missing Permissions", "code": 50013}`) {
|
|
||||||
return api.sendEvent(event.room_id, "m.room.message", {
|
|
||||||
...ctx,
|
|
||||||
msgtype: "m.text",
|
|
||||||
body: "I don't have permission to create invites to the Discord channel/server."
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const validHours = Math.ceil(invite.max_age / (60 * 60))
|
|
||||||
const validUses =
|
|
||||||
( invite.max_uses === 0 ? "unlimited uses"
|
|
||||||
: invite.max_uses === 1 ? "single-use"
|
|
||||||
: `${invite.max_uses} uses`)
|
|
||||||
return api.sendEvent(event.room_id, "m.room.message", {
|
|
||||||
...ctx,
|
|
||||||
msgtype: "m.text",
|
|
||||||
body: `https://discord.gg/${invite.code}\nValid for next ${validHours} hours, ${validUses}.`
|
|
||||||
})
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}]
|
}]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,14 +9,9 @@ const {reg} = require("./read-registration.js")
|
||||||
const baseUrl = `${reg.ooye.server_origin}/_matrix`
|
const baseUrl = `${reg.ooye.server_origin}/_matrix`
|
||||||
|
|
||||||
class MatrixServerError extends Error {
|
class MatrixServerError extends Error {
|
||||||
/**
|
constructor(data, opts) {
|
||||||
* @param {number} httpStatus}
|
|
||||||
*/
|
|
||||||
constructor(data, httpStatus, opts = {}) {
|
|
||||||
super(data.error || data.errcode)
|
super(data.error || data.errcode)
|
||||||
this.data = data
|
this.data = data
|
||||||
/** @type {number} */
|
|
||||||
this.httpStatus = httpStatus
|
|
||||||
/** @type {string} */
|
/** @type {string} */
|
||||||
this.errcode = data.errcode
|
this.errcode = data.errcode
|
||||||
this.opts = opts
|
this.opts = opts
|
||||||
|
|
@ -49,11 +44,11 @@ async function _convertBody(body) {
|
||||||
async function makeMatrixServerError(res, opts = {}) {
|
async function makeMatrixServerError(res, opts = {}) {
|
||||||
delete opts.headers?.["Authorization"]
|
delete opts.headers?.["Authorization"]
|
||||||
if (res.headers.get("content-type") === "application/json") {
|
if (res.headers.get("content-type") === "application/json") {
|
||||||
return new MatrixServerError(await res.json(), res.status, opts)
|
return new MatrixServerError(await res.json(), opts)
|
||||||
} else if (res.headers.get("content-type")?.startsWith("text/")) {
|
} else if (res.headers.get("content-type")?.startsWith("text/")) {
|
||||||
return new MatrixServerError({errcode: "CX_SERVER_ERROR", error: `Server returned HTTP status ${res.status}`, message: await res.text()}, res.status, opts)
|
return new MatrixServerError({errcode: "CX_SERVER_ERROR", error: `Server returned HTTP status ${res.status}`, message: await res.text()}, opts)
|
||||||
} else {
|
} else {
|
||||||
return new MatrixServerError({errcode: "CX_SERVER_ERROR", error: `Server returned HTTP status ${res.status}`, content_type: res.headers.get("content-type")}, res.status, opts)
|
return new MatrixServerError({errcode: "CX_SERVER_ERROR", error: `Server returned HTTP status ${res.status}`, content_type: res.headers.get("content-type")}, opts)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -83,12 +78,12 @@ async function mreq(method, url, bodyIn, extra = {}) {
|
||||||
var root = JSON.parse(text)
|
var root = JSON.parse(text)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
delete opts.headers?.["Authorization"]
|
delete opts.headers?.["Authorization"]
|
||||||
throw new MatrixServerError(text, res.status, {baseUrl, url, ...opts})
|
throw new MatrixServerError(text, {baseUrl, url, ...opts})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!res.ok || root.errcode) {
|
if (!res.ok || root.errcode) {
|
||||||
delete opts.headers?.["Authorization"]
|
delete opts.headers?.["Authorization"]
|
||||||
throw new MatrixServerError(root, res.status, {baseUrl, url, ...opts})
|
throw new MatrixServerError(root, {baseUrl, url, ...opts})
|
||||||
}
|
}
|
||||||
return root
|
return root
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,10 +17,6 @@ function checkRegistration(reg) {
|
||||||
assert(reg.ooye?.server_origin.match(/^https?:\/\//), "server origin must start with http or https")
|
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")
|
assert.notEqual(reg.ooye?.server_origin.slice(-1), "/", "server origin must not end in slash")
|
||||||
assert.match(reg.url, /^https?:/, "url must start with http:// or https://")
|
assert.match(reg.url, /^https?:/, "url must start with http:// or https://")
|
||||||
if (!reg.receive_ephemeral) {
|
|
||||||
reg.receive_ephemeral = true
|
|
||||||
writeRegistration(reg)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* c8 ignore next 4 */
|
/* c8 ignore next 4 */
|
||||||
|
|
@ -54,7 +50,6 @@ function getTemplateRegistration(serverName) {
|
||||||
],
|
],
|
||||||
sender_localpart: `${namespace_prefix}bot`,
|
sender_localpart: `${namespace_prefix}bot`,
|
||||||
rate_limited: false,
|
rate_limited: false,
|
||||||
receive_ephemeral: true,
|
|
||||||
socket: 6693,
|
socket: 6693,
|
||||||
ooye: {
|
ooye: {
|
||||||
namespace_prefix,
|
namespace_prefix,
|
||||||
|
|
@ -83,15 +78,6 @@ function readRegistration() {
|
||||||
/** @type {import("../types").AppServiceRegistrationConfig} */ // @ts-ignore
|
/** @type {import("../types").AppServiceRegistrationConfig} */ // @ts-ignore
|
||||||
let reg = readRegistration()
|
let reg = readRegistration()
|
||||||
|
|
||||||
if (reg) {
|
|
||||||
fs.watch(registrationFilePath, {persistent: false}, () => {
|
|
||||||
let newReg = readRegistration()
|
|
||||||
if (newReg) {
|
|
||||||
Object.assign(reg, newReg)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports.registrationFilePath = registrationFilePath
|
module.exports.registrationFilePath = registrationFilePath
|
||||||
module.exports.readRegistration = readRegistration
|
module.exports.readRegistration = readRegistration
|
||||||
module.exports.getTemplateRegistration = getTemplateRegistration
|
module.exports.getTemplateRegistration = getTemplateRegistration
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const {tryToCatch} = require("try-to-catch")
|
const tryToCatch = require("try-to-catch")
|
||||||
const {test} = require("supertape")
|
const {test} = require("supertape")
|
||||||
const {reg, checkRegistration, getTemplateRegistration} = require("./read-registration")
|
const {reg, checkRegistration, getTemplateRegistration} = require("./read-registration")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,6 @@ async function onBotMembership(event, api, createRoom) {
|
||||||
assert.equal(event.type, "m.room.member")
|
assert.equal(event.type, "m.room.member")
|
||||||
assert.equal(event.state_key, utils.bot)
|
assert.equal(event.state_key, utils.bot)
|
||||||
|
|
||||||
return await roomUpgradeSema.request(async () => {
|
|
||||||
// Check if an upgrade is pending for this room
|
// Check if an upgrade is pending for this room
|
||||||
const newRoomID = event.room_id
|
const newRoomID = event.room_id
|
||||||
const oldRoomID = select("room_upgrade_pending", "old_room_id", {new_room_id: newRoomID}).pluck().get()
|
const oldRoomID = select("room_upgrade_pending", "old_room_id", {new_room_id: newRoomID}).pluck().get()
|
||||||
|
|
@ -65,6 +64,7 @@ async function onBotMembership(event, api, createRoom) {
|
||||||
// Check if is join/invite
|
// Check if is join/invite
|
||||||
if (event.content.membership !== "invite" && event.content.membership !== "join") return false
|
if (event.content.membership !== "invite" && event.content.membership !== "join") return false
|
||||||
|
|
||||||
|
return await roomUpgradeSema.request(async () => {
|
||||||
// If invited, join
|
// If invited, join
|
||||||
if (event.content.membership === "invite") {
|
if (event.content.membership === "invite") {
|
||||||
await api.joinRoom(newRoomID)
|
await api.joinRoom(newRoomID)
|
||||||
|
|
|
||||||
|
|
@ -225,6 +225,19 @@ async function getViaServersQuery(roomID, api) {
|
||||||
return qs
|
return qs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function generatePermittedMediaHash(mxc) {
|
||||||
|
assert(hasher, "xxhash is not ready yet")
|
||||||
|
const mediaParts = mxc?.match(/^mxc:\/\/([^/]+)\/(\w+)$/)
|
||||||
|
if (!mediaParts) return undefined
|
||||||
|
|
||||||
|
const serverAndMediaID = `${mediaParts[1]}/${mediaParts[2]}`
|
||||||
|
const unsignedHash = hasher.h64(serverAndMediaID)
|
||||||
|
const signedHash = unsignedHash - 0x8000000000000000n // shifting down to signed 64-bit range
|
||||||
|
db.prepare("INSERT OR IGNORE INTO media_proxy (permitted_hash) VALUES (?)").run(signedHash)
|
||||||
|
|
||||||
|
return serverAndMediaID
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Since the introduction of authenticated media, this can no longer just be the /_matrix/media/r0/download URL
|
* Since the introduction of authenticated media, this can no longer just be the /_matrix/media/r0/download URL
|
||||||
* because Discord and Discord users cannot use those URLs. Media now has to be proxied through the bridge.
|
* because Discord and Discord users cannot use those URLs. Media now has to be proxied through the bridge.
|
||||||
|
|
@ -250,7 +263,7 @@ function getPublicUrlForMxc(mxc) {
|
||||||
*/
|
*/
|
||||||
function makeMxcPublic(mxc) {
|
function makeMxcPublic(mxc) {
|
||||||
assert(hasher, "xxhash is not ready yet")
|
assert(hasher, "xxhash is not ready yet")
|
||||||
const mediaParts = mxc?.match(/^mxc:\/\/([^/]+)\/([a-zA-Z0-9_-]+)$/)
|
const mediaParts = mxc?.match(/^mxc:\/\/([^/]+)\/(\w+)$/)
|
||||||
if (!mediaParts) return undefined
|
if (!mediaParts) return undefined
|
||||||
|
|
||||||
const serverAndMediaID = `${mediaParts[1]}/${mediaParts[2]}`
|
const serverAndMediaID = `${mediaParts[1]}/${mediaParts[2]}`
|
||||||
|
|
|
||||||
21
src/stdin.js
21
src/stdin.js
|
|
@ -15,37 +15,18 @@ const mreq = sync.require("./matrix/mreq")
|
||||||
const api = sync.require("./matrix/api")
|
const api = sync.require("./matrix/api")
|
||||||
const file = sync.require("./matrix/file")
|
const file = sync.require("./matrix/file")
|
||||||
const sendEvent = sync.require("./m2d/actions/send-event")
|
const sendEvent = sync.require("./m2d/actions/send-event")
|
||||||
const redact = sync.require("./m2d/actions/redact")
|
|
||||||
const eventDispatcher = sync.require("./d2m/event-dispatcher")
|
const eventDispatcher = sync.require("./d2m/event-dispatcher")
|
||||||
const updatePins = sync.require("./d2m/actions/update-pins")
|
const updatePins = sync.require("./d2m/actions/update-pins")
|
||||||
const speedbump = sync.require("./d2m/actions/speedbump")
|
const speedbump = sync.require("./d2m/actions/speedbump")
|
||||||
const ks = sync.require("./matrix/kstate")
|
const ks = sync.require("./matrix/kstate")
|
||||||
const setPresence = sync.require("./d2m/actions/set-presence")
|
const setPresence = sync.require("./d2m/actions/set-presence")
|
||||||
const channelWebhook = sync.require("./m2d/actions/channel-webhook")
|
const channelWebhook = sync.require("./m2d/actions/channel-webhook")
|
||||||
const dUtils = sync.require("./discord/utils")
|
|
||||||
const mxUtils = sync.require("./matrix/utils")
|
|
||||||
const guildID = "112760669178241024"
|
const guildID = "112760669178241024"
|
||||||
|
|
||||||
async function ping() {
|
|
||||||
const result = await api.ping().catch(e => ({ok: false, status: "net", root: e.message}))
|
|
||||||
if (result.ok) {
|
|
||||||
return "Ping OK. The homeserver and OOYE are talking to each other fine."
|
|
||||||
} else {
|
|
||||||
if (typeof result.root === "string") {
|
|
||||||
var msg = `Cannot reach homeserver: ${result.root}`
|
|
||||||
} else if (result.root.error) {
|
|
||||||
var msg = `Homeserver said: [${result.status}] ${result.root.error}`
|
|
||||||
} else {
|
|
||||||
var msg = `Homeserver said: [${result.status}] ${JSON.stringify(result.root)}`
|
|
||||||
}
|
|
||||||
return msg + "\nMatrix->Discord won't work until you fix this.\nIf your installation has recently changed, consider `npm run setup` again."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (process.stdin.isTTY) {
|
if (process.stdin.isTTY) {
|
||||||
setImmediate(() => {
|
setImmediate(() => {
|
||||||
if (!passthrough.repl) {
|
if (!passthrough.repl) {
|
||||||
const cli = repl.start({prompt: "", eval: customEval, writer: s => s})
|
const cli = repl.start({ prompt: "", eval: customEval, writer: s => s })
|
||||||
Object.assign(cli.context, passthrough)
|
Object.assign(cli.context, passthrough)
|
||||||
passthrough.repl = cli
|
passthrough.repl = cli
|
||||||
}
|
}
|
||||||
|
|
|
||||||
43
src/types.d.ts
vendored
43
src/types.d.ts
vendored
|
|
@ -18,7 +18,6 @@ export type AppServiceRegistrationConfig = {
|
||||||
}
|
}
|
||||||
protocols: [string]
|
protocols: [string]
|
||||||
rate_limited: boolean
|
rate_limited: boolean
|
||||||
receive_ephemeral: boolean
|
|
||||||
socket?: string | number,
|
socket?: string | number,
|
||||||
ooye: {
|
ooye: {
|
||||||
namespace_prefix: string
|
namespace_prefix: string
|
||||||
|
|
@ -36,8 +35,6 @@ export type AppServiceRegistrationConfig = {
|
||||||
web_password: string
|
web_password: string
|
||||||
time_zone?: string
|
time_zone?: string
|
||||||
receive_presences: boolean
|
receive_presences: boolean
|
||||||
plu_ral_api_key?: string
|
|
||||||
bridge_admin_contact?: string
|
|
||||||
}
|
}
|
||||||
old_bridge?: {
|
old_bridge?: {
|
||||||
as_token: string
|
as_token: string
|
||||||
|
|
@ -62,7 +59,6 @@ export type InitialAppServiceRegistrationConfig = {
|
||||||
}
|
}
|
||||||
protocols: [string]
|
protocols: [string]
|
||||||
rate_limited: boolean
|
rate_limited: boolean
|
||||||
receive_ephemeral: boolean
|
|
||||||
socket?: string | number,
|
socket?: string | number,
|
||||||
ooye: {
|
ooye: {
|
||||||
namespace_prefix: string
|
namespace_prefix: string
|
||||||
|
|
@ -126,28 +122,6 @@ export type PkMessage = {
|
||||||
sender: string
|
sender: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PluRalWebhookMessage = {
|
|
||||||
original_id: string | null
|
|
||||||
proxy_id: string
|
|
||||||
author_id: string
|
|
||||||
channel_id: string
|
|
||||||
member_id: string
|
|
||||||
reason: string
|
|
||||||
webhook_id: string
|
|
||||||
member: PluRalMember
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PluRalMember = {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
pronouns: string
|
|
||||||
bio: string
|
|
||||||
birthday: string
|
|
||||||
color: number | null
|
|
||||||
avatar_url: string
|
|
||||||
private: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export namespace Event {
|
export namespace Event {
|
||||||
export type Outer<T> = {
|
export type Outer<T> = {
|
||||||
type: string
|
type: string
|
||||||
|
|
@ -183,7 +157,7 @@ export namespace Event {
|
||||||
type: string
|
type: string
|
||||||
state_key: string
|
state_key: string
|
||||||
sender: string
|
sender: string
|
||||||
content: Event.M_Room_Create | Event.M_Room_Name | Event.M_Room_Avatar | Event.M_Room_Topic | Event.M_Room_JoinRules | Event.M_Room_CanonicalAlias | Event.M_Room_Encryption
|
content: Event.M_Room_Create | Event.M_Room_Name | Event.M_Room_Avatar | Event.M_Room_Topic | Event.M_Room_JoinRules | Event.M_Room_CanonicalAlias
|
||||||
}
|
}
|
||||||
|
|
||||||
export type M_Room_Create = {
|
export type M_Room_Create = {
|
||||||
|
|
@ -416,20 +390,6 @@ export namespace Event {
|
||||||
body: string
|
body: string
|
||||||
replacement_room: string
|
replacement_room: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type M_Room_Encryption = {
|
|
||||||
algorithm: string
|
|
||||||
rotation_period_ms?: number
|
|
||||||
rotation_period_msgs?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Outer_M_Typing = {
|
|
||||||
content: {
|
|
||||||
user_ids: string[]
|
|
||||||
}
|
|
||||||
room_id: string
|
|
||||||
type: "m.typing"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export namespace R {
|
export namespace R {
|
||||||
|
|
@ -477,7 +437,6 @@ export namespace R {
|
||||||
num_joined_members: number
|
num_joined_members: number
|
||||||
room_id: string
|
room_id: string
|
||||||
room_type?: string
|
room_type?: string
|
||||||
encryption?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ResolvedRoom = {
|
export type ResolvedRoom = {
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ async function getManagedGuilds(event) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {h3.H3Event} event
|
* @param {h3.H3Event} event
|
||||||
* @returns {ReturnType<typeof h3.useSession<{userID?: string, mxid?: string, managedGuilds?: string[], state?: string, selfService?: boolean, next?: string, password?: string}>>}
|
* @returns {ReturnType<typeof h3.useSession<{userID?: string, mxid?: string, managedGuilds?: string[], state?: string, selfService?: boolean, password?: string}>>}
|
||||||
*/
|
*/
|
||||||
function useSession(event) {
|
function useSession(event) {
|
||||||
return h3.useSession(event, {password: reg.as_token, maxAge: 365 * 24 * 60 * 60})
|
return h3.useSession(event, {password: reg.as_token, maxAge: 365 * 24 * 60 * 60})
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,6 @@ function renderPath(event, path, locals) {
|
||||||
compile()
|
compile()
|
||||||
fs.watch(path, {persistent: false}, compile)
|
fs.watch(path, {persistent: false}, compile)
|
||||||
fs.watch(join(__dirname, "pug", "includes"), {persistent: false}, compile)
|
fs.watch(join(__dirname, "pug", "includes"), {persistent: false}, compile)
|
||||||
fs.watch(join(__dirname, "pug", "fragments"), {persistent: false}, compile)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const cb = pugCache.get(path)
|
const cb = pugCache.get(path)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
//- locals: guild, guild_id
|
|
||||||
|
|
||||||
include ../includes/default-roles-list.pug
|
|
||||||
+default-roles-list(guild, guild_id)
|
|
||||||
+add-roles-menu(guild, guild_id)
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
extends includes/template.pug
|
extends includes/template.pug
|
||||||
include includes/default-roles-list.pug
|
|
||||||
|
|
||||||
mixin badge-readonly
|
mixin badge-readonly
|
||||||
.s-badge.s-badge__xs.s-badge__icon.s-badge__muted
|
.s-badge.s-badge__xs.s-badge__icon.s-badge__muted
|
||||||
|
|
@ -77,7 +76,7 @@ block body
|
||||||
|
|
||||||
if space_id
|
if space_id
|
||||||
h2.mt48.fs-headline1 Server settings
|
h2.mt48.fs-headline1 Server settings
|
||||||
h3.mt32.fs-category How Matrix users join
|
h3.mt32.fs-category Privacy level
|
||||||
span#privacy-level-loading
|
span#privacy-level-loading
|
||||||
.s-card
|
.s-card
|
||||||
form(hx-post=rel("/api/privacy-level") hx-trigger="change" hx-indicator="#privacy-level-loading" hx-disabled-elt="input")
|
form(hx-post=rel("/api/privacy-level") hx-trigger="change" hx-indicator="#privacy-level-loading" hx-disabled-elt="input")
|
||||||
|
|
@ -106,24 +105,6 @@ block body
|
||||||
p.s-description.m0 Shareable invite links, like Discord
|
p.s-description.m0 Shareable invite links, like Discord
|
||||||
p.s-description.m0 Publicly listed in directory, like Discord server discovery
|
p.s-description.m0 Publicly listed in directory, like Discord server discovery
|
||||||
|
|
||||||
h3.mt32.fs-category Default roles
|
|
||||||
.s-card
|
|
||||||
form(method="post" action=rel("/api/default-roles") hx-post=rel("/api/default-roles") hx-sync="this:drop" hx-indicator="#add-role-loading" hx-target="#default-roles-list" hx-select="#default-roles-list" hx-swap="outerHTML")#default-roles
|
|
||||||
input(type="hidden" name="guild_id" value=guild_id)
|
|
||||||
.d-flex.fw-wrap.g4
|
|
||||||
.s-tag.s-tag__md.fs-body1.s-tag__required @everyone
|
|
||||||
|
|
||||||
+default-roles-list(guild, guild_id)
|
|
||||||
|
|
||||||
button(type="button" popovertarget="role-add").s-btn__dropdown.s-tag.s-tag__md.fs-body1.p0
|
|
||||||
.s-tag--dismiss.m1
|
|
||||||
!= icons.Icons.IconPlusSm
|
|
||||||
|
|
||||||
#role-add.s-popover(popover style="display: revert").ws2.px0.py4.bs-lg.overflow-visible
|
|
||||||
.s-popover--arrow.s-popover--arrow__tc
|
|
||||||
+add-roles-menu(guild, guild_id)
|
|
||||||
p.fc-light.mb0.mt8 Matrix users will start with these roles. If your main channels are gated by a role, use this to let Matrix users skip the gate.
|
|
||||||
|
|
||||||
h3.mt32.fs-category Features
|
h3.mt32.fs-category Features
|
||||||
.s-card.d-grid.px0.g16
|
.s-card.d-grid.px0.g16
|
||||||
form.d-flex.ai-center.g16
|
form.d-flex.ai-center.g16
|
||||||
|
|
@ -191,14 +172,14 @@ block body
|
||||||
label.s-btn.s-btn__muted.ta-left.truncate(for=channel.id)
|
label.s-btn.s-btn__muted.ta-left.truncate(for=channel.id)
|
||||||
+discord(channel, true, "Announcement")
|
+discord(channel, true, "Announcement")
|
||||||
else
|
else
|
||||||
.s-empty-state.p8 No Discord channels available.
|
.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
|
||||||
each room in unlinkedRooms
|
each room in unlinkedRooms
|
||||||
input.s-btn--radio(type="radio" name="matrix" required id=room.room_id value=room.room_id)
|
input.s-btn--radio(type="radio" name="matrix" required id=room.room_id value=room.room_id)
|
||||||
label.s-btn.s-btn__muted.ta-left.truncate(for=room.room_id)
|
label.s-btn.s-btn__muted.ta-left.truncate(for=room.room_id)
|
||||||
+matrix(room, true)
|
+matrix(room, true)
|
||||||
else
|
else
|
||||||
.s-empty-state.p8 No Matrix rooms available.
|
.s-empty-state.p8 All Matrix rooms are linked.
|
||||||
input(type="hidden" name="guild_id" value=guild_id)
|
input(type="hidden" name="guild_id" value=guild_id)
|
||||||
div
|
div
|
||||||
button.s-btn.s-btn__icon.s-btn__filled#link-button
|
button.s-btn.s-btn__icon.s-btn__filled#link-button
|
||||||
|
|
@ -249,11 +230,6 @@ block body
|
||||||
ul.my8.ml24
|
ul.my8.ml24
|
||||||
each row in removedLinkedRooms
|
each row in removedLinkedRooms
|
||||||
li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name
|
li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name
|
||||||
h3.mt24 Unavailable rooms: Encryption not supported
|
|
||||||
.s-card.p0
|
|
||||||
ul.my8.ml24
|
|
||||||
each row in removedEncryptedRooms
|
|
||||||
li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name
|
|
||||||
h3.mt24 Unavailable rooms: Wrong type
|
h3.mt24 Unavailable rooms: Wrong type
|
||||||
.s-card.p0
|
.s-card.p0
|
||||||
ul.my8.ml24
|
ul.my8.ml24
|
||||||
|
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
mixin default-roles-list(guild, guild_id)
|
|
||||||
#default-roles-list(style="display: contents")
|
|
||||||
each roleID in select("role_default", "role_id", {guild_id}).pluck().all()
|
|
||||||
- let r = guild.roles.find(r => r.id === roleID)
|
|
||||||
if r
|
|
||||||
.s-tag.s-tag__md.fs-body1= r.name
|
|
||||||
span(id=`role-loading-${roleID}`)
|
|
||||||
button(name="remove_role" value=roleID hx-post="api/default-roles" hx-trigger="click consume" hx-indicator=`#role-loading-${roleID}`).s-tag--dismiss
|
|
||||||
!= icons.Icons.IconClearSm
|
|
||||||
|
|
||||||
mixin add-roles-menu(guild, guild_id)
|
|
||||||
ul.s-menu(role="menu" hx-swap-oob="true").overflow-y-auto.overflow-x-hidden#add-roles-menu
|
|
||||||
li.s-menu--title.d-flex(role="separator") Select role
|
|
||||||
span#add-role-loading
|
|
||||||
each r in guild.roles.sort((a, b) => b.position - a.position)
|
|
||||||
if r.id !== guild_id && !r.managed
|
|
||||||
- let selected = !!select("role_default", "role_id", {guild_id, role_id: r.id}).get()
|
|
||||||
li(role="menuitem")
|
|
||||||
button(name="toggle_role" value=r.id class={"is-selected": selected}).s-block-link.s-block-link__left= r.name
|
|
||||||
|
|
@ -88,28 +88,9 @@ html(lang="en")
|
||||||
--_ts-multiple-bg: var(--green-400);
|
--_ts-multiple-bg: var(--green-400);
|
||||||
--_ts-multiple-fc: var(--white);
|
--_ts-multiple-fc: var(--white);
|
||||||
}
|
}
|
||||||
.s-avatar {
|
|
||||||
--_av-bg: var(--white);
|
|
||||||
}
|
|
||||||
.s-avatar .s-avatar--letter {
|
|
||||||
color: var(--white);
|
|
||||||
}
|
|
||||||
.s-btn__dropdown:has(+ :popover-open) {
|
.s-btn__dropdown:has(+ :popover-open) {
|
||||||
background-color: var(--theme-topbar-item-background-hover, var(--black-200)) !important;
|
background-color: var(--theme-topbar-item-background-hover, var(--black-200)) !important;
|
||||||
}
|
}
|
||||||
.s-btn__dropdown.s-tag:has(+ :popover-open) .s-tag--dismiss {
|
|
||||||
background-color: var(--black-500) !important;
|
|
||||||
color: var(--black-150) !important;
|
|
||||||
}
|
|
||||||
.s-tag .is-loading {
|
|
||||||
margin-right: -4px;
|
|
||||||
}
|
|
||||||
.s-tag .is-loading + .s-tag--dismiss {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
a.s-block-link, .s-block-link {
|
|
||||||
--_bl-bs-color: var(--green-400);
|
|
||||||
}
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
body.theme-system .s-popover {
|
body.theme-system .s-popover {
|
||||||
--_po-bg: var(--black-100);
|
--_po-bg: var(--black-100);
|
||||||
|
|
@ -157,22 +138,14 @@ html(lang="en")
|
||||||
//- Body
|
//- Body
|
||||||
.mx-auto.w100.wmx9.py24.px8.fs-body1#content
|
.mx-auto.w100.wmx9.py24.px8.fs-body1#content
|
||||||
block body
|
block body
|
||||||
|
|
||||||
if reg.ooye.bridge_admin_contact
|
|
||||||
aside.fs-body0.mt48.pt8.bt.bc-black-350.d-inline-block
|
|
||||||
a(href=rel("/privacy-policy")) Privacy policy
|
|
||||||
//- Guild list popover
|
//- Guild list popover
|
||||||
script.
|
script.
|
||||||
document.querySelectorAll("[popovertarget]").forEach(e => {
|
document.querySelectorAll("[popovertarget]").forEach(e => {
|
||||||
const target = document.getElementById(e.getAttribute("popovertarget"))
|
e.addEventListener("click", () => {
|
||||||
e.addEventListener("click", calculate)
|
const rect = e.getBoundingClientRect()
|
||||||
target.addEventListener("toggle", calculate)
|
const t = `:popover-open { position: absolute; top: ${Math.floor(rect.bottom)}px; left: ${Math.floor(rect.left + rect.width / 2)}px; width: ${Math.floor(rect.width)}px; transform: translateX(-50%); box-sizing: content-box; margin: 0 }`
|
||||||
function calculate() {
|
|
||||||
const buttonRect = e.getBoundingClientRect()
|
|
||||||
const targetRect = target.getBoundingClientRect()
|
|
||||||
const t = `:popover-open { position: absolute; top: ${Math.floor(buttonRect.bottom + window.scrollY)}px; left: ${Math.floor(Math.max(targetRect.width / 2, buttonRect.left + buttonRect.width / 2))}px; width: ${Math.floor(buttonRect.width)}px; transform: translateX(-50%); box-sizing: content-box; margin: 0 }`
|
|
||||||
document.styleSheets[0].insertRule(t, document.styleSheets[0].cssRules.length)
|
document.styleSheets[0].insertRule(t, document.styleSheets[0].cssRules.length)
|
||||||
}
|
})
|
||||||
})
|
})
|
||||||
//- Prevent default
|
//- Prevent default
|
||||||
script.
|
script.
|
||||||
|
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
extends includes/template.pug
|
|
||||||
|
|
||||||
block body
|
|
||||||
if !session.data.userID
|
|
||||||
.s-empty-state.wmx4.p48
|
|
||||||
!= icons.Spots.SpotKeyXL
|
|
||||||
p Please log in to access the opt-out page.
|
|
||||||
.d-flex.jc-center.g8
|
|
||||||
a.s-btn.s-btn__icon.s-btn__featured.s-btn__filled(href=rel("/oauth?next=/opt-out"))
|
|
||||||
!= icons.Icons.IconDiscord
|
|
||||||
= ` Log in with Discord`
|
|
||||||
|
|
||||||
else
|
|
||||||
.s-page-title.mb24
|
|
||||||
h1.s-page-title--header Opt Out
|
|
||||||
|
|
||||||
- let value = !select("opt_out", "user_id", {user_id: session.data.userID}).pluck().get()
|
|
||||||
|
|
||||||
#opt-in-msg(hx-swap-oob="true")
|
|
||||||
if msg
|
|
||||||
.s-notice.s-notice__info.mb16= msg
|
|
||||||
else if !value
|
|
||||||
.s-notice.s-notice__warning.mb16 You are currently opted out.
|
|
||||||
|
|
||||||
.s-card.d-grid.px0.g16
|
|
||||||
form.d-flex.ai-center.g16
|
|
||||||
#opt-in-loading.p8
|
|
||||||
input.s-toggle-switch#opt-in(name="opt_in" type="checkbox" hx-post=rel("/api/opt-out") hx-indicator="#opt-in-loading" hx-disabled-elt="this" checked=value autocomplete="off" hx-swap="none")
|
|
||||||
label.s-label.fl-grow1(for="opt-in")
|
|
||||||
| Bridge my messages to Matrix
|
|
||||||
p.s-description Turn it off to opt out. This applies to all servers.
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
extends includes/template.pug
|
|
||||||
|
|
||||||
block body
|
|
||||||
.s-page-title.mb24
|
|
||||||
h1.s-page-title--header Privacy Policy
|
|
||||||
p.s-page-title--description Last updated 4 August 2026
|
|
||||||
|
|
||||||
.s-prose
|
|
||||||
h2 Summary
|
|
||||||
|
|
||||||
p Out Of Your Element bridges messages between Matrix and Discord. It does this by forwarding all the messages from one platform to the other. Your messages aren't used for any other purpose.
|
|
||||||
|
|
||||||
p Matrix and Discord will store these messages until you delete them, but Out Of Your Element doesn't keep its own copy.
|
|
||||||
|
|
||||||
h2 Important information about the Matrix network
|
|
||||||
|
|
||||||
p The public Matrix Network is a decentralised and openly federated communication network. This means that user messages are replicated on each participant's homeserver, and messages posted to a room are visible to all participants, including new joiners. Anybody can run their own homeserver, so your messages may be widely replicated onto many servers. Out Of Your Element does not control these servers.
|
|
||||||
|
|
||||||
p Out Of Your Element does endeavour to remove the corresponding data from all of these servers when you delete a particular message, opt out, or request erasure. Due to the nature of decentralised systems, deletion might not be immediate or thorough. For example, some servers could be offline and catch up later, or they may have backups of old data.
|
|
||||||
|
|
||||||
p Please keep this in mind when using chatrooms with Out Of Your Element, or any other bridge software.
|
|
||||||
|
|
||||||
h2 What data is collected
|
|
||||||
|
|
||||||
p Data provided by you in the chatroom: Your messages, edits, uploaded files, username, profile picture, emojis, reactions, commands used, online indicator, typing indicator, and generally any other information written in chat will be copied to the other platform. This happens in all chatrooms where you see the Out Of Your Element bot.
|
|
||||||
|
|
||||||
p There is also a non-identifiable #[a(href=rel("/api/stats")) counter of how many people and chatrooms are active].
|
|
||||||
|
|
||||||
h2 What data isn't collected
|
|
||||||
|
|
||||||
p No tracking here. Out Of Your Element #[strong does not] automatically access or share your IP address, device fingerprint, location, read receipts, or your real-world identity.
|
|
||||||
|
|
||||||
h2 How long data is stored for
|
|
||||||
|
|
||||||
p Out Of Your Element doesn't store your message content directly. It only stores non-identifiable metadata, such as message IDs and room IDs.
|
|
||||||
|
|
||||||
p However, the messages are stored forever by Discord and Matrix, or until you delete them. Your messages will remain in the chat even if you leave the chat later.
|
|
||||||
|
|
||||||
p Your online indicator and typing indicator are only stored briefly to show your real-time status. They can't be viewed as history.
|
|
||||||
|
|
||||||
h2 Who can access this data
|
|
||||||
|
|
||||||
ul
|
|
||||||
li Anybody participating in the chatroom (on either platform) can see your messages attributed to your username
|
|
||||||
li Discord, Inc., and their hosting providers, can access that too
|
|
||||||
li People running a Matrix homeserver in the room, and their hosting providers, can access that too (see #[a(href="https://matrix.org/legal/privacy-notice/") Matrix.org privacy policy])
|
|
||||||
li Your data isn't shared with any other third parties (no analytics or monitoring)
|
|
||||||
|
|
||||||
h2 How to control access to your data
|
|
||||||
|
|
||||||
p To remove data in a specific message, you can delete that message. Deletions are bridged across all platforms. Discord and Matrix will discard the message.
|
|
||||||
|
|
||||||
p However, we can't stop people in the chatroom from independently saving or sharing a screenshot of your messages.
|
|
||||||
|
|
||||||
h3 Broad opt-out for Discord account holders
|
|
||||||
p If you don't want any future messages to be bridged across platforms, #[a(href=rel("/opt-out")) opt out (of your element) here]. Keep in mind this could make conversations confusing because people will see different things.
|
|
||||||
p Your old messages will stick around, but if you want those deleted as well, please contact the operator !{reg.ooye.bridge_admin_contact} to request erasure.
|
|
||||||
|
|
||||||
h2 Questions
|
|
||||||
|
|
||||||
p Out Of Your Element is open source, freely available software that anyone can run. The person running this version you're using is probably a different person from the software author.
|
|
||||||
|
|
||||||
p For questions about #[em your] data and how #[em this] version handles it, please contact the operator !{reg.ooye.bridge_admin_contact} to discuss.
|
|
||||||
|
|
||||||
p For general questions and clarifications about how the software handles data more broadly, please contact #[a(href="https://cadence.moe/contact") the author of the Out Of Your Element software.]
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const assert = require("assert").strict
|
const assert = require("assert").strict
|
||||||
const {tryToCatch} = require("try-to-catch")
|
const tryToCatch = require("try-to-catch")
|
||||||
const {test} = require("supertape")
|
const {test} = require("supertape")
|
||||||
const {router} = require("../../../test/web")
|
const {router} = require("../../../test/web")
|
||||||
const {_cache} = require("./download-discord")
|
const {_cache} = require("./download-discord")
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,6 @@
|
||||||
const assert = require("assert/strict")
|
const assert = require("assert/strict")
|
||||||
const {defineEventHandler, getValidatedRouterParams, setResponseStatus, setResponseHeader, createError, H3Event, getValidatedQuery} = require("h3")
|
const {defineEventHandler, getValidatedRouterParams, setResponseStatus, setResponseHeader, createError, H3Event, getValidatedQuery} = require("h3")
|
||||||
const {z} = require("zod")
|
const {z} = require("zod")
|
||||||
const {ReadableStream} = require("stream/web")
|
|
||||||
const {Readable} = require("stream")
|
|
||||||
const sharp = require("sharp")
|
|
||||||
|
|
||||||
/** @type {import("xxhash-wasm").XXHashAPI} */ // @ts-ignore
|
/** @type {import("xxhash-wasm").XXHashAPI} */ // @ts-ignore
|
||||||
let hasher = null
|
let hasher = null
|
||||||
|
|
@ -22,27 +19,11 @@ const emojiSheetConverter = sync.require("../../m2d/converters/emoji-sheet")
|
||||||
/** @type {import("../../m2d/actions/sticker")} */
|
/** @type {import("../../m2d/actions/sticker")} */
|
||||||
const sticker = sync.require("../../m2d/actions/sticker")
|
const sticker = sync.require("../../m2d/actions/sticker")
|
||||||
|
|
||||||
// Resizing client-side because server-side is too slow, at least with Synapse. Really need it to be fast because webhook avatars show a placeholder in the interim.
|
|
||||||
/** @type {{[presetKey: string]: (body: ReadableStream) => ReadableStream}} */
|
|
||||||
const MEDIA_THUMBNAIL_PRESETS = {
|
|
||||||
avatar: body =>
|
|
||||||
Readable.toWeb(
|
|
||||||
Readable.fromWeb(body).pipe(
|
|
||||||
sharp()
|
|
||||||
.resize({height: 210, width: 210, fit: "cover"}) // the largest display of the webhook pfp on Discord Android in screen pixels
|
|
||||||
.jpeg({force: false, quality: 90}) // File size works out to up to ~110k for a PNG, less for a JPEG
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const schema = {
|
const schema = {
|
||||||
media: z.object({
|
params: z.object({
|
||||||
server_name: z.string(),
|
server_name: z.string(),
|
||||||
media_id: z.string()
|
media_id: z.string()
|
||||||
}),
|
}),
|
||||||
mediaQuery: z.object({
|
|
||||||
preset: z.enum(Object.keys(MEDIA_THUMBNAIL_PRESETS)) // list of possible thumbnail presets
|
|
||||||
}),
|
|
||||||
sheet: z.object({
|
sheet: z.object({
|
||||||
e: z.array(z.string()).or(z.string())
|
e: z.array(z.string()).or(z.string())
|
||||||
}),
|
}),
|
||||||
|
|
@ -84,8 +65,7 @@ function verifyMediaHash(serverAndMediaID) {
|
||||||
}
|
}
|
||||||
|
|
||||||
as.router.get(`/download/matrix/:server_name/:media_id`, defineEventHandler(async event => {
|
as.router.get(`/download/matrix/:server_name/:media_id`, defineEventHandler(async event => {
|
||||||
const params = await getValidatedRouterParams(event, schema.media.parse)
|
const params = await getValidatedRouterParams(event, schema.params.parse)
|
||||||
const query = await getValidatedQuery(event, schema.mediaQuery.safeParse)
|
|
||||||
|
|
||||||
verifyMediaHash(`${params.server_name}/${params.media_id}`)
|
verifyMediaHash(`${params.server_name}/${params.media_id}`)
|
||||||
const api = getAPI(event)
|
const api = getAPI(event)
|
||||||
|
|
@ -97,17 +77,7 @@ as.router.get(`/download/matrix/:server_name/:media_id`, defineEventHandler(asyn
|
||||||
setResponseStatus(event, res.status)
|
setResponseStatus(event, res.status)
|
||||||
setResponseHeader(event, "Content-Type", contentType)
|
setResponseHeader(event, "Content-Type", contentType)
|
||||||
setResponseHeader(event, "Transfer-Encoding", "chunked")
|
setResponseHeader(event, "Transfer-Encoding", "chunked")
|
||||||
|
|
||||||
const contentDisposition = res.headers.get("content-disposition")
|
|
||||||
if (contentDisposition) {
|
|
||||||
setResponseHeader(event, "Content-Disposition", contentDisposition)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (res.ok && query.success) {
|
|
||||||
return MEDIA_THUMBNAIL_PRESETS[query.data.preset](res.body)
|
|
||||||
} else {
|
|
||||||
return res.body
|
return res.body
|
||||||
}
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
as.router.get(`/download/sheet`, defineEventHandler(async event => {
|
as.router.get(`/download/sheet`, defineEventHandler(async event => {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
const fs = require("fs")
|
const fs = require("fs")
|
||||||
const {convertImageStream} = require("../../m2d/converters/emoji-sheet")
|
const {convertImageStream} = require("../../m2d/converters/emoji-sheet")
|
||||||
const {tryToCatch} = require("try-to-catch")
|
const tryToCatch = require("try-to-catch")
|
||||||
const {test} = require("supertape")
|
const {test} = require("supertape")
|
||||||
const {router} = require("../../../test/web")
|
const {router} = require("../../../test/web")
|
||||||
const streamWeb = require("stream/web")
|
const streamWeb = require("stream/web")
|
||||||
|
|
@ -30,13 +30,12 @@ test("web download matrix: works if a known attachment", async t => {
|
||||||
api: {
|
api: {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
async getMedia(mxc, init) {
|
async getMedia(mxc, init) {
|
||||||
return new Response("", {status: 200, headers: {"content-type": "image/png", "content-disposition": `attachment; filename="wa.png"`}})
|
return new Response("", {status: 200, headers: {"content-type": "image/png"}})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
t.equal(event.node.res.statusCode, 200)
|
t.equal(event.node.res.statusCode, 200)
|
||||||
t.equal(event.node.res.getHeader("content-type"), "image/png")
|
t.equal(event.node.res.getHeader("content-type"), "image/png")
|
||||||
t.equal(event.node.res.getHeader("content-disposition"), `attachment; filename="wa.png"`)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -65,7 +64,7 @@ test("web sheet: single emoji", async t => {
|
||||||
mxcDownloader: mockGetAndConvertEmoji
|
mxcDownloader: mockGetAndConvertEmoji
|
||||||
})
|
})
|
||||||
t.equal(event.node.res.statusCode, 200)
|
t.equal(event.node.res.statusCode, 200)
|
||||||
t.equal(sheet.subarray(0, 90).toString("base64"), "iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAACXBIWXMAAAPoAAAD6AG1e1JrAAALoklEQVRogc1ZaVBU2RU+LZSIGnAvFUtcRkSk6abpbkDH")
|
t.equal(sheet.subarray(0, 90).toString("base64"), "iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAACXBIWXMAAAPoAAAD6AG1e1JrAAALoklEQVR4nM1ZaVBU2RU+LZSIGnAvFUtcRkSk6abpbkDH")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("web sheet: multiple sources", async t => {
|
test("web sheet: multiple sources", async t => {
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,10 @@ const assert = require("assert/strict")
|
||||||
const {z} = require("zod")
|
const {z} = require("zod")
|
||||||
const {defineEventHandler, createError, readValidatedBody, getRequestHeader, setResponseHeader, sendRedirect, H3Event} = require("h3")
|
const {defineEventHandler, createError, readValidatedBody, getRequestHeader, setResponseHeader, sendRedirect, H3Event} = require("h3")
|
||||||
|
|
||||||
const {as, db, sync, select, discord} = require("../../passthrough")
|
const {as, db, sync, select} = require("../../passthrough")
|
||||||
|
|
||||||
/** @type {import("../auth")} */
|
/** @type {import("../auth")} */
|
||||||
const auth = sync.require("../auth")
|
const auth = sync.require("../auth")
|
||||||
/** @type {import("../pug-sync")} */
|
|
||||||
const pugSync = sync.require("../pug-sync")
|
|
||||||
/** @type {import("../../d2m/actions/set-presence")} */
|
/** @type {import("../../d2m/actions/set-presence")} */
|
||||||
const setPresence = sync.require("../../d2m/actions/set-presence")
|
const setPresence = sync.require("../../d2m/actions/set-presence")
|
||||||
|
|
||||||
|
|
@ -22,14 +20,6 @@ function getCreateSpace(event) {
|
||||||
return event.context.createSpace || sync.require("../../d2m/actions/create-space")
|
return event.context.createSpace || sync.require("../../d2m/actions/create-space")
|
||||||
}
|
}
|
||||||
|
|
||||||
const schema = {
|
|
||||||
defaultRoles: z.object({
|
|
||||||
guild_id: z.string(),
|
|
||||||
toggle_role: z.string().optional(),
|
|
||||||
remove_role: z.string().optional()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef Options
|
* @typedef Options
|
||||||
* @prop {(value: string?) => number} transform
|
* @prop {(value: string?) => number} transform
|
||||||
|
|
@ -104,39 +94,3 @@ as.router.post("/api/privacy-level", defineToggle("privacy_level", {
|
||||||
await createSpace.syncSpaceFully(guildID) // this is inefficient but OK to call infrequently on user request
|
await createSpace.syncSpaceFully(guildID) // this is inefficient but OK to call infrequently on user request
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
|
||||||
as.router.post("/api/default-roles", defineEventHandler(async event => {
|
|
||||||
const parsedBody = await readValidatedBody(event, schema.defaultRoles.parse)
|
|
||||||
|
|
||||||
const managed = await auth.getManagedGuilds(event)
|
|
||||||
const guildID = parsedBody.guild_id
|
|
||||||
if (!managed.has(guildID)) throw createError({status: 403, message: "Forbidden", data: "Can't change settings for a guild you don't have Manage Server permissions in"})
|
|
||||||
|
|
||||||
const roleID = parsedBody.toggle_role || parsedBody.remove_role
|
|
||||||
assert(roleID)
|
|
||||||
assert.notEqual(guildID, roleID) // the @everyone role is always default
|
|
||||||
|
|
||||||
const guild = discord.guilds.get(guildID)
|
|
||||||
assert(guild)
|
|
||||||
|
|
||||||
let shouldRemove = !!parsedBody.remove_role
|
|
||||||
if (!shouldRemove) {
|
|
||||||
shouldRemove = !!select("role_default", "role_id", {guild_id: guildID, role_id: roleID}).get()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldRemove) {
|
|
||||||
db.prepare("DELETE FROM role_default WHERE guild_id = ? AND role_id = ?").run(guildID, roleID)
|
|
||||||
} else {
|
|
||||||
assert(guild.roles.find(r => r.id === roleID))
|
|
||||||
db.prepare("INSERT OR IGNORE INTO role_default (guild_id, role_id) VALUES (?, ?)").run(guildID, roleID)
|
|
||||||
}
|
|
||||||
|
|
||||||
const createSpace = getCreateSpace(event)
|
|
||||||
await createSpace.syncSpaceFully(guildID) // this is inefficient but OK to call infrequently on user request
|
|
||||||
|
|
||||||
if (getRequestHeader(event, "HX-Request")) {
|
|
||||||
return pugSync.render(event, "fragments/default-roles-list.pug", {guild, guild_id: guildID})
|
|
||||||
} else {
|
|
||||||
return sendRedirect(event, `/guild?guild_id=${guildID}`, 302)
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const {tryToCatch} = require("try-to-catch")
|
const tryToCatch = require("try-to-catch")
|
||||||
const {router, test} = require("../../../test/web")
|
const {router, test} = require("../../../test/web")
|
||||||
const {select} = require("../../passthrough")
|
const {select} = require("../../passthrough")
|
||||||
const {MatrixServerError} = require("../../matrix/mreq")
|
const {MatrixServerError} = require("../../matrix/mreq")
|
||||||
|
|
|
||||||
|
|
@ -123,14 +123,13 @@ function getChannelRoomsLinks(guild, rooms, roles) {
|
||||||
let unlinkedRooms = [...rooms]
|
let unlinkedRooms = [...rooms]
|
||||||
let removedLinkedRooms = dUtils.filterTo(unlinkedRooms, r => !linkedRoomIDs.includes(r.room_id))
|
let removedLinkedRooms = dUtils.filterTo(unlinkedRooms, r => !linkedRoomIDs.includes(r.room_id))
|
||||||
let removedWrongTypeRooms = dUtils.filterTo(unlinkedRooms, r => !r.room_type)
|
let removedWrongTypeRooms = dUtils.filterTo(unlinkedRooms, r => !r.room_type)
|
||||||
let removedEncryptedRooms = dUtils.filterTo(unlinkedRooms, r => !r.encryption && !r["im.nheko.summary.encryption"])
|
|
||||||
// https://discord.com/developers/docs/topics/threads#active-archived-threads
|
// https://discord.com/developers/docs/topics/threads#active-archived-threads
|
||||||
// need to filter out linked archived threads from unlinkedRooms, will just do that by comparing against the name
|
// need to filter out linked archived threads from unlinkedRooms, will just do that by comparing against the name
|
||||||
let removedArchivedThreadRooms = dUtils.filterTo(unlinkedRooms, r => r.name && !r.name.match(/^\[(🔒)?⛓️\]/))
|
let removedArchivedThreadRooms = dUtils.filterTo(unlinkedRooms, r => r.name && !r.name.match(/^\[(🔒)?⛓️\]/))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
linkedChannelsWithDetails, unlinkedChannels, unlinkedRooms,
|
linkedChannelsWithDetails, unlinkedChannels, unlinkedRooms,
|
||||||
removedUncachedChannels, removedWrongTypeChannels, removedPrivateChannels, removedLinkedRooms, removedWrongTypeRooms, removedArchivedThreadRooms, removedEncryptedRooms
|
removedUncachedChannels, removedWrongTypeChannels, removedPrivateChannels, removedLinkedRooms, removedWrongTypeRooms, removedArchivedThreadRooms
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -158,13 +157,13 @@ as.router.get("/guild", defineEventHandler(async event => {
|
||||||
|
|
||||||
// Permission problems
|
// Permission problems
|
||||||
if (!guild_id || !guild || !managed.has(guild_id) || !row) {
|
if (!guild_id || !guild || !managed.has(guild_id) || !row) {
|
||||||
return pugSync.render(event, "guild-access-denied.pug", {guild_id, row})
|
return pugSync.render(event, "guild_access_denied.pug", {guild_id, row})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Self-service guild that hasn't been linked yet - needs a special page encouraging the link flow
|
// Self-service guild that hasn't been linked yet - needs a special page encouraging the link flow
|
||||||
if (!row.space_id && row.autocreate === 0) {
|
if (!row.space_id && row.autocreate === 0) {
|
||||||
const spaces = session.data.mxid ? getInviteTargetSpaces(session.data.mxid) : []
|
const spaces = session.data.mxid ? getInviteTargetSpaces(session.data.mxid) : []
|
||||||
return pugSync.render(event, "guild-not-linked.pug", {guild, guild_id, spaces})
|
return pugSync.render(event, "guild_not_linked.pug", {guild, guild_id, spaces})
|
||||||
}
|
}
|
||||||
|
|
||||||
const roles = guild.members?.find(m => m.user.id === botID)?.roles || []
|
const roles = guild.members?.find(m => m.user.id === botID)?.roles || []
|
||||||
|
|
@ -191,7 +190,7 @@ as.router.get("/qr", defineEventHandler(async event => {
|
||||||
|
|
||||||
// Permission problems
|
// Permission problems
|
||||||
if (!guild_id || !guild || !managed.has(guild_id) || !row) {
|
if (!guild_id || !guild || !managed.has(guild_id) || !row) {
|
||||||
return pugSync.render(event, "guild-access-denied.pug", {guild_id, row})
|
return pugSync.render(event, "guild_access_denied.pug", {guild_id, row})
|
||||||
}
|
}
|
||||||
|
|
||||||
const nonce = randomUUID()
|
const nonce = randomUUID()
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const DiscordTypes = require("discord-api-types/v10")
|
const DiscordTypes = require("discord-api-types/v10")
|
||||||
const {tryToCatch} = require("try-to-catch")
|
const tryToCatch = require("try-to-catch")
|
||||||
const {router, test} = require("../../../test/web")
|
const {router, test} = require("../../../test/web")
|
||||||
const {MatrixServerError} = require("../../matrix/mreq")
|
const {MatrixServerError} = require("../../matrix/mreq")
|
||||||
const {_getPosition} = require("./guild")
|
const {_getPosition} = require("./guild")
|
||||||
|
|
|
||||||
|
|
@ -1,117 +0,0 @@
|
||||||
// @ts-check
|
|
||||||
|
|
||||||
const h3 = require("h3")
|
|
||||||
const {defineEventHandler, getValidatedQuery, setResponseHeader} = h3
|
|
||||||
const sharp = require("sharp")
|
|
||||||
const {z} = require("zod")
|
|
||||||
|
|
||||||
const {as} = require("../../passthrough")
|
|
||||||
const {reg} = require("../../matrix/read-registration")
|
|
||||||
|
|
||||||
/*
|
|
||||||
Create a 300x300 avatar image consisting of a dark coloured background, and a single character in a lighter colour centered in the middle.
|
|
||||||
Note: Where dimensions are changed, font size must also be changed too to produce an identical image as before.
|
|
||||||
Simply put, 100px = 60pt for font.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const SIZE = 300
|
|
||||||
const POSSIBLE_HUES = 12
|
|
||||||
|
|
||||||
/** Helper function: To get accurate complimenting colours we need to work in HSL, then convert back to RGB at the end */
|
|
||||||
function hslToRgb(h, s, l) {
|
|
||||||
s /= 100;
|
|
||||||
l /= 100;
|
|
||||||
|
|
||||||
const a = s * Math.min(l, 1 - l);
|
|
||||||
|
|
||||||
const f = n => {
|
|
||||||
const k = (n + h / 30) % 12;
|
|
||||||
return l - a * Math.max(-1, Math.min(Math.min(k - 3, 9 - k), 1));
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
r: Math.round(255 * f(0)),
|
|
||||||
g: Math.round(255 * f(8)),
|
|
||||||
b: Math.round(255 * f(4))
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Use the MXID to generate deterministic avatar colours for each user.
|
|
||||||
* Here, we use the string hash code as a hue value, with a 360 wrap modulo.
|
|
||||||
* @param {string} mxid
|
|
||||||
*/
|
|
||||||
function mxidToHue(mxid) {
|
|
||||||
// Element Classic string hasher
|
|
||||||
let hash = 0;
|
|
||||||
let i;
|
|
||||||
let chr;
|
|
||||||
if (mxid.length === 0) {
|
|
||||||
return hash;
|
|
||||||
}
|
|
||||||
for (i = 0; i < mxid.length; i++) {
|
|
||||||
chr = mxid.charCodeAt(i);
|
|
||||||
hash = ((hash << 5) - hash) + chr;
|
|
||||||
hash |= 0;
|
|
||||||
}
|
|
||||||
hash = Math.abs(hash)
|
|
||||||
return (hash % POSSIBLE_HUES) * (360 / POSSIBLE_HUES)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get first useful character in username to put in the avatar.
|
|
||||||
* @param {string} username
|
|
||||||
*/
|
|
||||||
function usernameToLetter(username) {
|
|
||||||
return (username.match(/[a-z0-9]/i)?.[0] || "#").toUpperCase()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} mxid
|
|
||||||
* @param {string} username
|
|
||||||
*/
|
|
||||||
function getLetterAvatarURL(mxid, username) {
|
|
||||||
const p = new URLSearchParams({letter: usernameToLetter(username), hue: String(mxidToHue(mxid))})
|
|
||||||
return `${reg.ooye.bridge_origin}/download/letter-avatar?${p}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const schema = {
|
|
||||||
letterAvatar: z.object({
|
|
||||||
hue: z.coerce.number().min(0).max(360),
|
|
||||||
letter: z.string().regex(/^[A-Z0-9#]$/)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Produce a PNG letter-avatar from given parameters.
|
|
||||||
* @param {string} letter
|
|
||||||
* @param {number} hue
|
|
||||||
*/
|
|
||||||
as.router.get("/download/letter-avatar", defineEventHandler(async event => {
|
|
||||||
const {letter, hue} = await getValidatedQuery(event, schema.letterAvatar.parse)
|
|
||||||
|
|
||||||
const bg_rgb = hslToRgb(hue, 65, 18);
|
|
||||||
const text_rgb = hslToRgb(hue, 70, 65);
|
|
||||||
const text_rgbahex = `#${text_rgb.r.toString(16).padStart(2, "0")}${text_rgb.g.toString(16).padStart(2, "0")}${text_rgb.b.toString(16).padStart(2, "0")}ff`
|
|
||||||
|
|
||||||
const streamOut = sharp({
|
|
||||||
create: {
|
|
||||||
width: SIZE, height: SIZE, channels: 4,
|
|
||||||
background: {
|
|
||||||
r: bg_rgb.r, g: bg_rgb.g, b: bg_rgb.b, alpha: 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}).composite([{
|
|
||||||
input: {
|
|
||||||
text: {
|
|
||||||
text: `<span foreground="${text_rgbahex}">${letter}</span>`,
|
|
||||||
font: "Noto Sans Bold 180", align: "center", rgba: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}]).png()
|
|
||||||
|
|
||||||
setResponseHeader(event, "content-type", "image/png")
|
|
||||||
return streamOut
|
|
||||||
}))
|
|
||||||
|
|
||||||
module.exports.getLetterAvatarURL = getLetterAvatarURL
|
|
||||||
|
|
@ -204,12 +204,6 @@ as.router.post("/api/link", defineEventHandler(async event => {
|
||||||
throw createError({status: 403, message: e.errcode, data: `${e.errcode} - ${e.message}`})
|
throw createError({status: 403, message: e.errcode, data: `${e.errcode} - ${e.message}`})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check room is not encrypted
|
|
||||||
const encryption = await api.getStateEvent(parsedBody.matrix, "m.room.encryption", "").catch(() => null)
|
|
||||||
if (encryption) {
|
|
||||||
throw createError({status: 400, message: "Bad Request", data: "Encrypted rooms are not supported for bridging. Please replace it with an unencrypted room."})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check bridge has PL 100
|
// Check bridge has PL 100
|
||||||
const {powerLevels, powers: {[utils.bot]: selfPowerLevel}} = await utils.getEffectivePower(parsedBody.matrix, [utils.bot], api)
|
const {powerLevels, powers: {[utils.bot]: selfPowerLevel}} = await utils.getEffectivePower(parsedBody.matrix, [utils.bot], api)
|
||||||
if (selfPowerLevel < (powerLevels?.state_default ?? 50) || selfPowerLevel < 100) throw createError({status: 400, message: "Bad Request", data: "OOYE needs power level 100 (admin) in the target Matrix room"})
|
if (selfPowerLevel < (powerLevels?.state_default ?? 50) || selfPowerLevel < 100) throw createError({status: 400, message: "Bad Request", data: "OOYE needs power level 100 (admin) in the target Matrix room"})
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const {tryToCatch} = require("try-to-catch")
|
const tryToCatch = require("try-to-catch")
|
||||||
const {router, test} = require("../../../test/web")
|
const {router, test} = require("../../../test/web")
|
||||||
const {MatrixServerError} = require("../../matrix/mreq")
|
const {MatrixServerError} = require("../../matrix/mreq")
|
||||||
const {select, db} = require("../../passthrough")
|
const {select, db} = require("../../passthrough")
|
||||||
|
|
@ -73,7 +73,7 @@ test("web link space: check that OOYE is joined", async t => {
|
||||||
api: {
|
api: {
|
||||||
async joinRoom(roomID) {
|
async joinRoom(roomID) {
|
||||||
called++
|
called++
|
||||||
throw new MatrixServerError({errcode: "M_FORBIDDEN", error: "not allowed to join I guess"}, 400)
|
throw new MatrixServerError({errcode: "M_FORBIDDEN", error: "not allowed to join I guess"})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
|
@ -368,7 +368,7 @@ test("web link room: check that bridge can join room (notices lack of via and as
|
||||||
api: {
|
api: {
|
||||||
async joinRoom(roomID) {
|
async joinRoom(roomID) {
|
||||||
called++
|
called++
|
||||||
throw new MatrixServerError({errcode: "M_FORBIDDEN", error: "not allowed to join I guess"}, 400)
|
throw new MatrixServerError({errcode: "M_FORBIDDEN", error: "not allowed to join I guess"})
|
||||||
},
|
},
|
||||||
async *generateFullHierarchy(spaceID) {
|
async *generateFullHierarchy(spaceID) {
|
||||||
called++
|
called++
|
||||||
|
|
@ -402,7 +402,7 @@ test("web link room: check that bridge can join room (uses via for join attempt)
|
||||||
async joinRoom(roomID, _, via) {
|
async joinRoom(roomID, _, via) {
|
||||||
called++
|
called++
|
||||||
t.deepEqual(via, ["cadence.moe", "hashi.re"])
|
t.deepEqual(via, ["cadence.moe", "hashi.re"])
|
||||||
throw new MatrixServerError({errcode: "M_FORBIDDEN", error: "not allowed to join I guess"}, 400)
|
throw new MatrixServerError({errcode: "M_FORBIDDEN", error: "not allowed to join I guess"})
|
||||||
},
|
},
|
||||||
async *generateFullHierarchy(spaceID) {
|
async *generateFullHierarchy(spaceID) {
|
||||||
called++
|
called++
|
||||||
|
|
@ -435,47 +435,6 @@ test("web link room: check that bridge can join room (uses via for join attempt)
|
||||||
t.equal(called, 2)
|
t.equal(called, 2)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("web link room: check that room is not encrypted", async t => {
|
|
||||||
let called = 0
|
|
||||||
const [error] = await tryToCatch(() => router.test("post", "/api/link", {
|
|
||||||
sessionData: {
|
|
||||||
managedGuilds: ["665289423482519565"]
|
|
||||||
},
|
|
||||||
body: {
|
|
||||||
discord: "665310973967597573",
|
|
||||||
matrix: "!NDbIqNpJyPvfKRnNcr:cadence.moe",
|
|
||||||
guild_id: "665289423482519565"
|
|
||||||
},
|
|
||||||
api: {
|
|
||||||
async joinRoom(roomID) {
|
|
||||||
called++
|
|
||||||
return roomID
|
|
||||||
},
|
|
||||||
async *generateFullHierarchy(spaceID) {
|
|
||||||
called++
|
|
||||||
t.equal(spaceID, "!zTMspHVUBhFLLSdmnS:cadence.moe")
|
|
||||||
yield {
|
|
||||||
room_id: "!NDbIqNpJyPvfKRnNcr:cadence.moe",
|
|
||||||
children_state: [],
|
|
||||||
guest_can_join: false,
|
|
||||||
num_joined_members: 2
|
|
||||||
}
|
|
||||||
/* c8 ignore next */
|
|
||||||
},
|
|
||||||
async getStateEvent(roomID, type, key) {
|
|
||||||
called++
|
|
||||||
t.equal(roomID, "!NDbIqNpJyPvfKRnNcr:cadence.moe")
|
|
||||||
if (type === "m.room.encryption" && key === "") {
|
|
||||||
return {algorithm: "m.megolm.v1.aes-sha2"}
|
|
||||||
}
|
|
||||||
throw new Error("Unknown state event")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
t.equal(error.data, "Encrypted rooms are not supported for bridging. Please replace it with an unencrypted room.")
|
|
||||||
t.equal(called, 3)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("web link room: check that bridge has PL 100 in target room", async t => {
|
test("web link room: check that bridge has PL 100 in target room", async t => {
|
||||||
let called = 0
|
let called = 0
|
||||||
const [error] = await tryToCatch(() => router.test("post", "/api/link", {
|
const [error] = await tryToCatch(() => router.test("post", "/api/link", {
|
||||||
|
|
@ -506,10 +465,9 @@ test("web link room: check that bridge has PL 100 in target room", async t => {
|
||||||
async getStateEvent(roomID, type, key) {
|
async getStateEvent(roomID, type, key) {
|
||||||
called++
|
called++
|
||||||
t.equal(roomID, "!NDbIqNpJyPvfKRnNcr:cadence.moe")
|
t.equal(roomID, "!NDbIqNpJyPvfKRnNcr:cadence.moe")
|
||||||
if (type === "m.room.power_levels" && key === "") {
|
t.equal(type, "m.room.power_levels")
|
||||||
|
t.equal(key, "")
|
||||||
return {users_default: 50}
|
return {users_default: 50}
|
||||||
}
|
|
||||||
throw new Error("Unknown state event")
|
|
||||||
},
|
},
|
||||||
async getStateEventOuter(roomID, type, key) {
|
async getStateEventOuter(roomID, type, key) {
|
||||||
called++
|
called++
|
||||||
|
|
@ -531,7 +489,7 @@ test("web link room: check that bridge has PL 100 in target room", async t => {
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
t.equal(error.data, "OOYE needs power level 100 (admin) in the target Matrix room")
|
t.equal(error.data, "OOYE needs power level 100 (admin) in the target Matrix room")
|
||||||
t.equal(called, 5)
|
t.equal(called, 4)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("web link room: successfully calls createRoom", async t => {
|
test("web link room: successfully calls createRoom", async t => {
|
||||||
|
|
@ -710,7 +668,7 @@ test("web unlink room: checks that the channel is bridged", async t => {
|
||||||
}))
|
}))
|
||||||
t.equal(error.data, "Channel ID 665310973967597573 is not currently bridged")
|
t.equal(error.data, "Channel ID 665310973967597573 is not currently bridged")
|
||||||
|
|
||||||
db.prepare("INSERT INTO channel_room (channel_id, room_id, name, nick, thread_parent, custom_avatar, last_bridged_pin_timestamp, speedbump_checked, guild_id, custom_topic) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)").run(row.channel_id, row.room_id, row.name, row.nick, row.thread_parent, row.custom_avatar, row.last_bridged_pin_timestamp, row.speedbump_checked, row.guild_id, row.custom_topic)
|
db.prepare("INSERT INTO channel_room (channel_id, room_id, name, nick, thread_parent, custom_avatar, last_bridged_pin_timestamp, speedbump_id, speedbump_checked, speedbump_webhook_id, guild_id, custom_topic) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)").run(row.channel_id, row.room_id, row.name, row.nick, row.thread_parent, row.custom_avatar, row.last_bridged_pin_timestamp, row.speedbump_id, row.speedbump_checked, row.speedbump_webhook_id, row.guild_id, row.custom_topic)
|
||||||
const new_row = db.prepare("SELECT * FROM channel_room WHERE channel_id = '665310973967597573'").get()
|
const new_row = db.prepare("SELECT * FROM channel_room WHERE channel_id = '665310973967597573'").get()
|
||||||
t.deepEqual(row, new_row)
|
t.deepEqual(row, new_row)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const {tryToCatch} = require("try-to-catch")
|
const tryToCatch = require("try-to-catch")
|
||||||
const {router, test} = require("../../../test/web")
|
const {router, test} = require("../../../test/web")
|
||||||
const {MatrixServerError} = require("../../matrix/mreq")
|
const {MatrixServerError} = require("../../matrix/mreq")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,7 @@ const redirect_uri = `${reg.ooye.bridge_origin}/oauth`
|
||||||
|
|
||||||
const schema = {
|
const schema = {
|
||||||
first: z.object({
|
first: z.object({
|
||||||
action: z.string().optional(),
|
action: z.string().optional()
|
||||||
next: z.string().optional()
|
|
||||||
}),
|
}),
|
||||||
code: z.object({
|
code: z.object({
|
||||||
state: z.string(),
|
state: z.string(),
|
||||||
|
|
@ -54,10 +53,10 @@ function getOauth2Token(event) {
|
||||||
|
|
||||||
as.router.get("/oauth", defineEventHandler(async event => {
|
as.router.get("/oauth", defineEventHandler(async event => {
|
||||||
const session = await auth.useSession(event)
|
const session = await auth.useSession(event)
|
||||||
const parsedFirstQuery = await getValidatedQuery(event, schema.first.safeParse)
|
|
||||||
let scope = "guilds"
|
let scope = "guilds"
|
||||||
|
|
||||||
if (!reg.ooye.web_password || reg.ooye.web_password === session.data.password) {
|
if (!reg.ooye.web_password || reg.ooye.web_password === session.data.password) {
|
||||||
|
const parsedFirstQuery = await getValidatedQuery(event, schema.first.safeParse)
|
||||||
if (parsedFirstQuery.data?.action === "add") {
|
if (parsedFirstQuery.data?.action === "add") {
|
||||||
scope = "bot+guilds"
|
scope = "bot+guilds"
|
||||||
await session.update({selfService: false})
|
await session.update({selfService: false})
|
||||||
|
|
@ -70,9 +69,6 @@ as.router.get("/oauth", defineEventHandler(async event => {
|
||||||
async function tryAgain() {
|
async function tryAgain() {
|
||||||
const newState = randomUUID()
|
const newState = randomUUID()
|
||||||
await session.update({state: newState})
|
await session.update({state: newState})
|
||||||
if (parsedFirstQuery.data?.next) {
|
|
||||||
await session.update({next: parsedFirstQuery.data?.next})
|
|
||||||
}
|
|
||||||
return sendRedirect(event, `https://discord.com/oauth2/authorize?client_id=${id}&scope=${scope}&permissions=${permissions}&response_type=code&redirect_uri=${redirect_uri}&state=${newState}`)
|
return sendRedirect(event, `https://discord.com/oauth2/authorize?client_id=${id}&scope=${scope}&permissions=${permissions}&response_type=code&redirect_uri=${redirect_uri}&state=${newState}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -90,10 +86,8 @@ as.router.get("/oauth", defineEventHandler(async event => {
|
||||||
const client = getClient(event)(parsedToken.access_token)
|
const client = getClient(event)(parsedToken.access_token)
|
||||||
|
|
||||||
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, userID, state: undefined})
|
||||||
const savedNext = session.data.next
|
|
||||||
await session.update({managedGuilds, userID, state: undefined, next: undefined})
|
|
||||||
|
|
||||||
// Set auto-create for the guild
|
// Set auto-create for the guild
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
|
|
@ -106,5 +100,5 @@ as.router.get("/oauth", defineEventHandler(async event => {
|
||||||
return sendRedirect(event, getRelativePath(event.path, `/guild?guild_id=${parsedQuery.data.guild_id}`), 302)
|
return sendRedirect(event, getRelativePath(event.path, `/guild?guild_id=${parsedQuery.data.guild_id}`), 302)
|
||||||
}
|
}
|
||||||
|
|
||||||
return sendRedirect(event, getRelativePath(event.path, savedNext || "/"), 302)
|
return sendRedirect(event, getRelativePath(event.path, "/"), 302)
|
||||||
}))
|
}))
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const DiscordTypes = require("discord-api-types/v10")
|
const DiscordTypes = require("discord-api-types/v10")
|
||||||
const {tryToCatch} = require("try-to-catch")
|
const tryToCatch = require("try-to-catch")
|
||||||
const assert = require("assert/strict")
|
const assert = require("assert/strict")
|
||||||
const {router, test} = require("../../../test/web")
|
const {router, test} = require("../../../test/web")
|
||||||
|
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue