Compare commits

..

No commits in common. "main" and "main" have entirely different histories.

127 changed files with 2373 additions and 6959 deletions

View file

@ -1,11 +1,5 @@
{
"editor.insertSpaces": false,
"editor.detectIndentation": false,
"editor.tabSize": 3,
"[jade]": {
"editor.stickyScroll.enabled": true,
"editor.stickyScroll.maxLineCount": 6,
"editor.tabSize": 2,
"editor.insertSpaces": true
}
"editor.tabSize": 3
}

View file

@ -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
Total transitive production dependencies: 114
Total transitive production dependencies: 134
### <font size="+2">🦕</font>
* (29) sharp: Image resizing and compositing. OOYE needs this for the emoji sprite sheets. It has libvips prebuilts for each platform.
* (26) @cloudrac3r/pug: Language for dynamic web pages. This is my fork. (I released code that hadn't made it to npm, and removed the heavy pug-filters feature.)
* (9) h3: Web server. OOYE needs this for the web UI, appservice listener, authmedia proxy, and more.
* (1) 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.
* (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.)
* (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 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>
@ -107,42 +108,22 @@ Total transitive production dependencies: 114
* (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/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/tell-me-when: Parse English natural language dates and times for the m->d //time command.
* (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.
* (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.
* (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) 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) get-relative-path: Looks fine. No dependencies.
* (1) heatsync: Module hot-reloader that I trust.
* (0) htmx.org: Declarative partial page reloads that do just enough. I'm not totally happy with this one, but not sure what I'd do instead.
* (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) snowtransfer: Discord API library with bring-your-own-caching that I trust.
* (0) temporal-polyfill-lite: Needed for the //time command. The Temporal API has only been stabilised as of Node.js v26.
* (0) try-to-catch: Not strictly necessary, but it's already pulled in by supertape, so I may as well.
* (0) uqr: QR code SVG generator. Used on the website to scan in an invite link.
* (0) xxhash-wasm: Used where cryptographically secure hashing is not required.
* (0) zod: Input validation for the web server. It's popular and easy to use.
### Outdated versions
* @chriscdn/promise-semaphore
* Highest usable version is 3.1.3 because the author **dropped CJS** in 4.0.0.
* @stackoverflow/stacks
* Highest usable version is 2.9.0 (and 6.9.0 for stacks-icons) because the company **ruined their design** in 3.0.0.
* cross-env
* Highest usable version is 7.0.3 because the author **dropped CJS** in 10.0.0.
* entities
* Using version 5.0.0. The author messed things up in 6.0.0, started using LLMs in 7.0.0, and **dropped CJS** in 8.0.0.
* h3
* Using major version 1. Version 2 has been stuck as **release candidates for months**, and all the code comments are Claude.
* htmx.org
* Using major version 2. Version 4 exists, and **I'd be okay updating to it**, but the author changed a lot and the entire site would need to be re-tested.

View file

@ -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.***
- 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 Katharos Technology: https://github.com/katharostech/docker_ooye
- by Emma: https://cgit.rory.gay/nix/OOYE-module.git/tree

View file

@ -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!
# Extras
## /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.
----
<br><br><br><br><br>
# Appendix

View file

@ -84,7 +84,7 @@ Discord display names for normal users are limited to 32 characters. For webhook
If the bridge software was restarted, it will attempt to catch up on messages missed while it was offline.
From Discord, for any given channel, if fewer than 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.

2002
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{
"name": "out-of-your-element",
"version": "3.7.0",
"version": "3.4.0",
"description": "A bridge between Matrix and Discord",
"main": "index.js",
"repository": {
@ -19,38 +19,35 @@
},
"dependencies": {
"@chriscdn/promise-semaphore": "^3.0.1",
"@cloudrac3r/discord-markdown": "^2.9.0",
"@cloudrac3r/discord-markdown": "^2.6.10",
"@cloudrac3r/giframe": "^0.4.3",
"@cloudrac3r/html-template-tag": "^5.0.1",
"@cloudrac3r/in-your-element": "^1.1.1",
"@cloudrac3r/mixin-deep": "^3.0.1",
"@cloudrac3r/pngjs": "^7.0.3",
"@cloudrac3r/pug": "^4.0.4",
"@cloudrac3r/stream-type": "^1.0.0",
"@cloudrac3r/tell-me-when": "^2.0.0",
"@cloudrac3r/turndown": "^7.1.5",
"@cloudrac3r/turndown": "^7.1.4",
"@stackoverflow/stacks": "^2.5.4",
"@stackoverflow/stacks-icons": "^6.0.2",
"ansi-colors": "^4.1.3",
"better-sqlite3": "^13.0.3",
"better-sqlite3": "^12.2.0",
"chunk-text": "^2.0.1",
"cloudstorm": "^0.19.0",
"denque": "^2.1.0",
"discord-api-types": "^0.38.52",
"cloudstorm": "^0.15.2",
"discord-api-types": "^0.38.38",
"domino": "^2.1.6",
"enquirer": "^2.4.1",
"entities": "^5.0.0",
"get-relative-path": "^1.0.2",
"h3": "^1.15.10",
"heatsync": "^3.3.0",
"h3": "^1.15.1",
"heatsync": "^2.7.2",
"htmx.org": "^2.0.4",
"lru-cache": "^11.0.2",
"mime-types": "^3.0.2",
"mime-types": "^2.1.35",
"prettier-bytes": "^1.0.4",
"sharp": "^0.35.3",
"snowtransfer": "^0.19.0",
"temporal-polyfill-lite": "^0.4.3",
"try-to-catch": "^4.0.5",
"sharp": "^0.34.5",
"snowtransfer": "^0.17.1",
"stream-mime-type": "^1.0.2",
"try-to-catch": "^3.0.1",
"uqr": "^0.1.2",
"xxhash-wasm": "^1.0.2",
"zod": "^4.0.17"
@ -61,9 +58,9 @@
"devDependencies": {
"@cloudrac3r/tap-dot": "^2.0.3",
"@types/node": "^22.17.1",
"c8": "^12.0.0",
"c8": "^10.1.2",
"cross-env": "^7.0.3",
"supertape": "^13.2.0"
"supertape": "^12.0.12"
},
"scripts": {
"start": "node --enable-source-maps start.js",
@ -71,8 +68,5 @@
"addbot": "node addbot.js",
"test": "cross-env FORCE_COLOR=true supertape --no-check-assertions-count --format tap --no-worker test/test.js | tap-dot",
"cover": "c8 -o test/coverage --skip-full -x db/migrations -x src/m2d/event-dispatcher.js -x src/matrix/file.js -x src/matrix/api.js -x src/d2m/converters/rlottie-wasm.js -r html -r text supertape --no-check-assertions-count --format fail --no-worker test/test.js -- --slow"
},
"allowScripts": {
"better-sqlite3": true
}
}

View file

@ -8,10 +8,6 @@ Modern Matrix-to-Discord appservice bridge, created by [@cadence:cadence.moe](ht
![](https://cadence.moe/i/f42a3f)
## AI policy
Out Of Your Element is 100% organically cultivated ethical hand-fed code. Let's work together to conserve that!
## Why a new bridge?
* Modern: Supports new Discord features like replies, threads and stickers, and new Matrix features like edits, spaces and space membership.

View file

@ -10,6 +10,7 @@ if (!channelID) {
process.exit(1)
}
const assert = require("assert/strict")
const sqlite = require("better-sqlite3")
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()
@ -37,8 +38,12 @@ passthrough.select = orm.select
/** @type {import("../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 () => {
await discord.cloud.connect()
@ -55,8 +60,6 @@ async function event(event) {
if (!channel) return
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"
console.log(`OK, processing messages for #${channel.name}, continuing from ${last}`)
@ -76,8 +79,4 @@ async function event(event) {
}
process.exit()
} catch (e) {
console.error(e)
process.exit(1) // won't exit automatically on thrown error due to living discord connection, so manual exit is necessary
}
}

View file

@ -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.")
})()

View file

@ -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
}
}
}
})()

View file

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

View file

@ -359,12 +359,12 @@ function defineEchoHandler() {
console.log("✅ Emojis are ready...")
// 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...")
// 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.)")
await api.profileSetDisplayname(mxid, "Out Of Your Element (bot)")
await api.profileSetDisplayname(mxid, "Out Of Your Element")
await api.profileSetAvatarUrl(mxid, avatarUrl)
console.log("✅ Matrix profile updated...")

View file

@ -1,6 +1,9 @@
#!/usr/bin/env node
// @ts-check
const {createServer} = require("http")
const EventEmitter = require("events")
const {createApp, createRouter, toNodeListener} = require("h3")
const sqlite = require("better-sqlite3")
const migrate = require("../src/db/migrate")
const HeatSync = require("heatsync")
@ -13,10 +16,6 @@ const sync = new HeatSync()
Object.assign(passthrough, {sync, db})
const orm = sync.require("../src/db/orm")
passthrough.from = orm.from
passthrough.select = orm.select
const DiscordClient = require("../src/d2m/discord-client")
const discord = new DiscordClient(reg.ooye.discord_token, "half")
@ -25,6 +24,10 @@ passthrough.discord = discord
const {as} = require("../src/matrix/appservice")
passthrough.as = as
const orm = sync.require("../src/db/orm")
passthrough.from = orm.from
passthrough.select = orm.select
;(async () => {
await migrate.migrate(db)
await discord.cloud.connect()

View file

@ -122,7 +122,7 @@ async function channelToKState(channel, guild, di) {
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 everyoneCanMentionEveryone = dUtils.hasPermission(everyonePermissions, DiscordTypes.PermissionFlagsBits.MentionEveryone)
@ -163,7 +163,6 @@ async function channelToKState(channel, guild, di) {
events_default: everyoneCanSend ? 0 : READ_ONLY_ROOM_EVENTS_DEFAULT_POWER,
events: {
"m.reaction": 0,
"org.matrix.msc3381.poll.response": 0,
"m.room.redaction": 0, // only affects redactions of own events, required to be able to un-react
...pollStartPowerLevel
},
@ -194,16 +193,6 @@ async function channelToKState(channel, guild, di) {
// Don't overwrite room topic if the topic has been customised
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
// (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()
@ -267,7 +256,7 @@ async function createRoom(channel, guild, spaceID, kstate, privacyLevel) {
/**
* 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.
* https://github.com/matrix-org/synapse/blob/develop/synapse/handlers/room.py#L1170-L1210
* https://github.com/matrix-org/matrix-spec/issues/492
@ -453,9 +442,8 @@ function syncRoom(channelID) {
/**
* @param {{id: string, topic?: string?}} channel channel-ish (just needs an id, topic is optional)
* @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()
assert.ok(roomID)
const row = from("guild_space").join("guild_active", "guild_id").select("space_id", "autocreate").where({guild_id: guildID}).get()
@ -505,7 +493,7 @@ async function unbridgeChannel(channel, guildID, messageBeforeLeave = "This room
// send a notification in the room
await api.sendEvent(roomID, "m.room.message", {
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

View file

@ -147,7 +147,6 @@ test("channel2room: read-only discord channel", async t => {
events_default: 50, // <-- it should be read-only!
events: {
"m.reaction": 0,
"org.matrix.msc3381.poll.response": 0,
"m.room.redaction": 0
},
notifications: {
@ -191,17 +190,6 @@ test("channel2room: read-only discord channel", async t => {
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 => {
t.deepEqual(
_convertNameAndTopic({id: "123", name: "the-twilight-zone", topic: "Spooky stuff here. :ghost:", type: 0}, {id: "456"}, "hauntings"),

View file

@ -8,8 +8,6 @@ const {reg} = require("../../matrix/read-registration")
const passthrough = require("../../passthrough")
const {discord, sync, db, select} = passthrough
/** @type {import("../../matrix/mreq")} */
const mreq = sync.require("../../matrix/mreq")
/** @type {import("../../matrix/api")} */
const api = sync.require("../../matrix/api")
/** @type {import("../../matrix/file")} */
@ -239,12 +237,8 @@ async function syncSpaceExpressions(data, checkBeforeSync) {
try {
existing = await api.getStateEvent(spaceID, "im.ponies.room_emotes", eventKey)
} 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.
existing = fn([], guild)
} else {
throw e
}
}
if (isDeepStrictEqual(existing, content)) return
}

View file

@ -1,24 +1,24 @@
// @ts-check
const assert = require("assert").strict
const passthrough = require("../../passthrough")
const {sync, db, select, from} = passthrough
const {reg} = require("../../matrix/read-registration")
/** @type {import("../converters/edit-to-changes")} */
const editToChanges = sync.require("../converters/edit-to-changes")
/** @type {import("./speedbump")} */
const speedbump = sync.require("./speedbump")
/** @type {import("./register-pk-user")} */
const registerPkUser = sync.require("./register-pk-user")
/** @type {import("../../matrix/api")} */
const api = sync.require("../../matrix/api")
/** @type {import("../../matrix/mreq")} */
const mreq = sync.require("../../matrix/mreq")
/** @type {import("../../discord/utils")} */
const dUtils = sync.require("../../discord/utils")
/**
* @param {import("discord-api-types/v10").GatewayMessageCreateDispatchData} message
* @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 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
@ -27,9 +27,11 @@ async function editMessage(message, guild) {
let {roomID, eventsToRedact, eventsToReplace, eventsToSend, senderMxid, promotions} = await editToChanges.editToChanges(message, guild, api)
// Sync proxy user profile (if sent by proxy)
if (dUtils.isWebhookMessage(message)) {
senderMxid = await speedbump.getWebhookSenderId(message, guild.id, roomID)
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)
}
}
// 1. Replace all the things.

View file

@ -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.
return
}
e["emoji"] = {
name: emoji.name,
id: emoji.id
}
console.error(`Trying to handle emoji ${emoji.name} (${emoji.id}), but...`)
throw e
})
))

View file

@ -1,9 +1,8 @@
// @ts-check
const assert = require("assert").strict
const assert = require("assert")
const {reg} = require("../../matrix/read-registration")
const Ty = require("../../types")
const {scheduler} = require("timers/promises")
const passthrough = require("../../passthrough")
const {sync, db, select, from} = passthrough
@ -15,21 +14,14 @@ const file = sync.require("../../matrix/file")
const registerUser = sync.require("./register-user")
/** @returns {Promise<Ty.PkMessage>} */
async function fetchMessage(messageID, attempt = 1) {
async function fetchMessage(messageID) {
try {
var res = await fetch(`https://api.pluralkit.me/v2/messages/${messageID}`)
} catch (networkError) {
// Network issue, raise a more readable message
throw new Error(`Failed to connect to PK API: ${networkError.toString()}`)
}
if (!res.ok) {
if (attempt < 2) {
await scheduler.wait(5000)
return fetchMessage(messageID, attempt + 1)
} else {
throw new Error(`PK API returned an error: ${await res.text()}`)
}
}
if (!res.ok) throw new Error(`PK API returned an error: ${await res.text()}`)
/** @type {any} */
const root = await res.json()
if (!root.member) throw new Error(`PK API didn't return member data: ${JSON.stringify(root)}`)
@ -111,7 +103,6 @@ async function memberToStateContent(pkMessage, 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 = pkMessage.member.avatar_url || pkMessage.member.webhook_avatar_url || pkMessage.system.avatar_url || file.userAvatar(author)
const color = pkMessage.member.color ?? pkMessage.system.color
const content = {
displayname: author.username,
@ -119,12 +110,6 @@ async function memberToStateContent(pkMessage, author) {
"moe.cadence.ooye.pk_member": pkMessage.member
}
if (avatar) content.avatar_url = await file.uploadDiscordFileToMxc(avatar)
if (color) {
content["eu.she-a.color"] = {
on_dark: `#${color}`,
on_light: `#${color}`
}
}
return content
}
@ -147,10 +132,10 @@ async function syncUser(messageID, author, roomID, shouldActuallySync) {
try {
// API lookup
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) {
// 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
return senderMxid
}

View file

@ -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

View file

@ -154,7 +154,7 @@ function memberToPowerLevel(user, member, guild, channel) {
if (!member) return 0
const permissions = dUtils.getPermissions(guild.id, member.roles, guild.roles, user.id, channel.permission_overwrites)
const everyonePermissions = dUtils.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:
* - Administrator.
@ -194,9 +194,7 @@ function memberToPowerLevel(user, member, guild, channel) {
* @param {number} powerLevel
*/
function _hashProfileContent(content, powerLevel) {
let toHash = `${content.displayname}\u0000${content.avatar_url}\u0000${powerLevel}`
if (content["eu.she-a.color"]?.on_dark) toHash += `\u0000${content["eu.she-a.color"].on_dark}`
const unsignedHash = hasher.h64(toHash)
const unsignedHash = hasher.h64(`${content.displayname}\u0000${content.avatar_url}\u0000${powerLevel}`)
const signedHash = unsignedHash - 0x8000000000000000n // shifting down to signed 64-bit range
return signedHash
}
@ -208,16 +206,14 @@ function _hashProfileContent(content, powerLevel) {
* 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
* 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 {Omit<DiscordTypes.APIGuildMember, "user"> | undefined} member
* @param {DiscordTypes.APIGuildChannel} channel
* @param {DiscordTypes.APIGuild} guild
* @param {string} roomID
* @param {DiscordTypes.APIMessageInteractionMetadata} [interactionMetadata]
* @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 content = await memberToStateContent(user, member, guild.id)
const powerLevel = memberToPowerLevel(user, member, guild, channel)
@ -226,12 +222,6 @@ async function syncUser(user, member, channel, guild, roomID, interactionMetadat
allowOverwrite: !!member,
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
}
@ -245,7 +235,6 @@ async function syncUser(user, member, channel, guild, roomID, interactionMetadat
async function _sendSyncUser(roomID, mxid, content, powerLevel, options) {
const currentHash = _hashProfileContent(content, powerLevel ?? 0)
const existingHash = select("sim_member", "hashed_profile_content", {room_id: roomID, mxid}).safeIntegers().pluck().get()
// console.log(roomID, mxid, existingHash, currentHash)
// only do the actual sync if the hash has changed since we last looked
const hashHasChanged = existingHash !== currentHash
// always okay to add new data. for overwriting, restrict based on options.allowOverwrite, if present

View file

@ -1,6 +1,6 @@
// @ts-check
const assert = require("assert").strict
const assert = require("assert")
const {reg} = require("../../matrix/read-registration")
const Ty = require("../../types")

View file

@ -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

View file

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

View file

@ -4,16 +4,17 @@ const assert = require("assert").strict
const DiscordTypes = require("discord-api-types/v10")
const passthrough = require("../../passthrough")
const {discord, sync, db, select, from} = passthrough
const {reg} = require("../../matrix/read-registration")
const { discord, sync, db, select, from} = passthrough
/** @type {import("../converters/message-to-event")} */
const messageToEvent = sync.require("../converters/message-to-event")
/** @type {import("../../matrix/api")} */
const api = sync.require("../../matrix/api")
/** @type {import("./register-user")} */
const registerUser = sync.require("./register-user")
/** @type {import("./speedbump")} */
const speedbump = sync.require("./speedbump")
/** @type {import("./register-pk-user")} */
const registerPkUser = sync.require("./register-pk-user")
/** @type {import("./register-webhook-user")} */
const registerWebhookUser = sync.require("./register-webhook-user")
/** @type {import("../actions/create-room")} */
const createRoom = sync.require("../actions/create-room")
/** @type {import("../actions/poll-end")} */
@ -27,21 +28,30 @@ const channelWebhook = sync.require("../../m2d/actions/channel-webhook")
* @param {DiscordTypes.GatewayMessageCreateDispatchData} message
* @param {DiscordTypes.APIGuildChannel} channel
* @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 historicalRoomIndex = select("historical_channel_room", "historical_room_index", {room_id: roomID}).pluck().get()
assert(historicalRoomIndex)
let senderMxid = null
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 {
// not a webhook
if (message.author.id === discord.application.id) {
// no need to sync the bot's own user
} else {
senderMxid = await registerUser.syncUser(message.author, message.member, channel, guild, roomID, message.interaction_metadata)
senderMxid = await registerUser.syncUser(message.author, message.member, channel, guild, roomID)
}
}
@ -50,12 +60,13 @@ async function sendMessage(message, channel, guild) {
const detailedResultsMessage = await pollEnd.endPoll(message)
if (detailedResultsMessage) {
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)
}
}
const events = await messageToEvent.messageToEvent(message, guild, {}, {api, snow: discord.snow, fetch})
const events = await messageToEvent.messageToEvent(message, guild, {}, {api, snow: discord.snow})
const eventIDs = []
if (events.length) {
db.prepare("INSERT OR IGNORE INTO message_room (message_id, historical_room_index) VALUES (?, ?)").run(message.id, historicalRoomIndex)

View file

@ -1,7 +1,5 @@
// @ts-check
const assert = require("assert").strict
const passthrough = require("../../passthrough")
const {sync, select} = passthrough
/** @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
const guildPresenceSetting = new class {
/** @private @type {Set<string>} */ guilds = new Set()
/** @private @type {Set<string>} */ guilds
constructor() {
this.update()
}
@ -42,7 +40,7 @@ const guildPresenceSetting = new class {
class Presence extends sync.reloadClassMethods(() => Presence) {
/** @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 {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.
// This random delay will space them out over the whole 28 second cycle.
setTimeout(() => {
assert(this.data)
api.setPresence(this.data, mxid).catch(() => {})
}, this.delay * presenceLoopInterval).unref()
}

View file

@ -1,23 +1,14 @@
// @ts-check
const DiscordTypes = require("discord-api-types/v10")
const passthrough = require("../../passthrough")
const {discord, from, select, db, sync} = 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 {discord, select, db} = passthrough
const DEBUG_SPEEDBUMP = false
function debugSpeedbump(...args) {
function debugSpeedbump(message) {
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>} */
const KNOWN_BOTS = new Set([
"466378653216014359", // PluralKit
"1291501048493768784", // /plu/ral
"466378653216014359" // PluralKit
])
/**
@ -39,81 +29,55 @@ async function updateCache(channelID, lastChecked) {
const now = Math.floor(Date.now() / 1000)
if (lastChecked && now - lastChecked < SPEEDBUMP_UPDATE_FREQUENCY) return
const webhooks = await discord.snow.webhook.getChannelWebhooks(channelID)
const found = webhooks.filter(b => KNOWN_BOTS.has(b.application_id))
db.transaction(() => {
db.prepare("DELETE FROM channel_speedbump WHERE channel_id = ?").run(channelID)
for (const webhook of found) {
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)
})()
const found = webhooks.find(b => KNOWN_BOTS.has(b.application_id))
const foundApplication = found?.application_id
const foundWebhook = found?.id
db.prepare("UPDATE channel_room SET speedbump_id = ?, speedbump_webhook_id = ?, speedbump_checked = ? WHERE channel_id = ?").run(foundApplication, foundWebhook, now, channelID)
}
/**
* @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 */
/** @type {Map<string, number>} messageID -> number of gateway events currently bumping */
const bumping = new Map()
/**
* 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
* @returns whether it was deleted
*/
async function doSpeedbump(isCreate, messageID) {
const entry = bumping.get(messageID) ?? (() => {
const entry = {number: 0, hasCreate: false}
bumping.set(messageID, entry)
return entry
})()
entry.number++
entry.hasCreate ||= isCreate
debugSpeedbump(`[speedbump] WAIT ${messageID}++ =`, entry)
async function doSpeedbump(messageID) {
let value = (bumping.get(messageID) ?? 0) + 1
bumping.set(messageID, value)
debugSpeedbump(`[speedbump] WAIT ${messageID}++ = ${value}`)
await new Promise(resolve => setTimeout(resolve, SPEEDBUMP_SPEED))
if (!bumping.has(messageID)) {
debugSpeedbump(`[speedbump] DELETED ${messageID}`)
return {skip: true, hasCreate: null}
return true
}
if (--entry.number <= 0) {
debugSpeedbump(`[speedbump] OK ${messageID}-- =`, entry)
value = (bumping.get(messageID) ?? 0) - 1
if (value <= 0) {
debugSpeedbump(`[speedbump] OK ${messageID}-- = ${value}`)
bumping.delete(messageID)
return {skip: false, hasCreate: entry.hasCreate}
return false
} else {
debugSpeedbump(`[speedbump] MULTI ${messageID}-- =`, entry)
return {skip: true, hasCreate: null}
debugSpeedbump(`[speedbump] MULTI ${messageID}-- = ${value}`)
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.
* @param {boolean} isCreate
* @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
* @returns whether to skip this message, and whether the message should be created as a creation
* @param {string} channelID
* @param {string} messageID
* @returns whether it was deleted, and data about the channel's (not thread's) speedbump
*/
async function maybeDoSpeedbump(isCreate, message) {
let rows = getSpeedbumpRows(message.channel_id)
if (rows[0]?.thread_parent) rows = getSpeedbumpRows(rows[0].thread_parent) // webhooks belong to the channel, not the thread
if (!rows.length) return {skip: false} // channel not affected, no speedbump
if (message.backfill) return {skip: false} // don't slow messages during backfill
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
async function maybeDoSpeedbump(channelID, messageID) {
let row = select("channel_room", ["thread_parent", "speedbump_id", "speedbump_webhook_id"], {channel_id: channelID}).get()
if (row?.thread_parent) row = select("channel_room", ["thread_parent", "speedbump_id", "speedbump_webhook_id"], {channel_id: row.thread_parent}).get() // webhooks belong to the channel, not the thread
if (!row?.speedbump_webhook_id) return {affected: false, row: null} // not affected, no speedbump
const affected = await doSpeedbump(messageID)
return {affected, row} // maybe affected, and there is a speedbump
}
/**
@ -123,27 +87,7 @@ function onMessageDelete(messageID) {
bumping.delete(messageID)
}
/**
* @param {DiscordTypes.APIMessage} message
* @param {string} guildID
* @param {string} roomID
*/
async function getWebhookSenderId(message, guildID, roomID) {
const isMatrixWebhook = select("webhook", "webhook_id", {webhook_id: message.webhook_id}).pluck().get()
if (isMatrixWebhook) return null
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.doSpeedbump = doSpeedbump
module.exports.maybeDoSpeedbump = maybeDoSpeedbump
module.exports.onMessageDelete = onMessageDelete
module.exports.getWebhookSenderId = getWebhookSenderId

View file

@ -28,7 +28,7 @@ async function updatePins(channelID, roomID, convertedTimestamp) {
try {
var discordPins = await discord.snow.channel.getChannelPinnedMessages(channelID)
} catch (e) {
if (e.message === "Missing Access") {
if (e.message === `{"message": "Missing Access", "code": 50001}`) {
return // Discord sends channel pins update events even for channels that the bot can't view/get pins in, just ignore it
}
throw e

View file

@ -69,8 +69,8 @@ async function editToChanges(message, guild, api) {
// Figure out what we will be replacing them with
const newFallbackContent = await messageToEvent.messageToEvent(message, guild, {includeEditFallbackStar: true}, {api, fetch})
const newInnerContent = await messageToEvent.messageToEvent(message, guild, {includeReplyFallback: false}, {api, fetch})
const newFallbackContent = await messageToEvent.messageToEvent(message, guild, {includeEditFallbackStar: true}, {api})
const newInnerContent = await messageToEvent.messageToEvent(message, guild, {includeReplyFallback: false}, {api})
assert.ok(newFallbackContent.length === newInnerContent.length)
// Match the new events to the old events
@ -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
// 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
// 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) {
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.
}

View file

@ -78,7 +78,7 @@ test("edit2changes: bot response", async t => {
newContent: {
$type: "m.room.message",
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",
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": {
@ -87,7 +87,7 @@ test("edit2changes: bot response", async t => {
// *** Replaced With: ***
"m.new_content": {
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",
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": {
@ -343,18 +343,19 @@ test("edit2changes: generated embed", async t => {
t.deepEqual(eventsToSend, [{
$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"
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| * 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",
+ "\n| * Both players present their best five-or-less-card pok...",
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:`
+ `<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</sub></blockquote>`,
+ `</li><li>Both players present their best five-or-less-card pok...</li></ul></p></blockquote>`,
"m.mentions": {}
}])
t.deepEqual(promotions, [{

View file

@ -1,6 +1,6 @@
// @ts-check
const assert = require("assert").strict
const assert = require("assert")
const {reg} = require("../../matrix/read-registration")
const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex))
@ -107,6 +107,7 @@ function tokenise(name) {
* @returns {ProcessedJoined}
*/
function processJoined(joined) {
joined = joined.filter(j => !userRegex.some(rx => j.mxid.match(rx)))
return {
mxids: joined.map(j => {
const localpart = j.mxid.match(/@([^:]*)/)
@ -140,29 +141,15 @@ function findMention(pjr, maximumWrittenSection, baseOffset, prefix, content) {
/** @type {{mxid: string, scored: {score: number, matchedInputTokens: Token[]}}[]} */
let allItems = pjr.mxids.map(mxid => ({...mxid, scored: scoreLocalpart(mxid.localpart, maximumWrittenSection, mxid.displayname)}))
allItems = allItems.concat(pjr.names.map(name => ({...name, scored: scoreName(name.displaynameTokens, maximumWrittenSectionTokens)})))
// Tiebreaker bonus for Matrix users
for (const item of allItems) {
if (!userRegex.some(rx => item.mxid.match(rx))) item.scored.score += 1
}
const [best, secondBest] = allItems.sort((a, b) => b.scored.score - a.scored.score)
if (userRegex.some(rx => best.mxid.match(rx))) return // Discord user was best match, so it was just a written non-pinging @ for a Discord user. Don't highlight a Matrix user.
if (secondBest && secondBest.scored.score === best.scored.score) return // all tied up
if (best.scored.score > 5) { // requires in smallest case Matrix user tiebreaker + perfect match of 2 characters, or in largest case a partial middle match of 5+ characters in a row
const best = allItems.sort((a, b) => b.scored.score - a.scored.score)[0]
if (best.scored.score > 4) { // requires in smallest case perfect match of 2 characters, or in largest case a partial middle match of 5+ characters in a row
// Highlight the relevant part of the message
const start = baseOffset + best.scored.matchedInputTokens[0].index
const end = baseOffset + prefix.length + best.scored.matchedInputTokens.slice(-1)[0].end
const newNodes = [{
type: "text", content: content.slice(0, start)
}, {
type: "link", target: `https://matrix.to/#/${best.mxid}`, content: [
{type: "text", content: content.slice(start, end)}
]
}, {
type: "text", content: content.slice(end)
}]
const newContent = content.slice(0, start) + "[" + content.slice(start, end) + "](https://matrix.to/#/" + best.mxid + ")" + content.slice(end)
return {
mxid: best.mxid,
newNodes
newContent
}
}
}

View file

@ -117,47 +117,6 @@ test("score name: finds match location", t => {
t.equal(message.slice(startLocation, endLocation), "evil lillith")
})
test("score name: matches the correct jan", t => {
const names = [
"jan Kanoli",
"jan Mijawi",
"jan Mukin"
]
t.deepEqual(names.map(n => scoreName(tokenise(n), tokenise("jan Mijawi")).score), [
6,
18.4,
6
])
})
test("find mention: highlights nothing if a Discord user scored highest", t => {
const found = findMention(processJoined([{
mxid: "@jankanoli:matrix.org",
displayname: "jan Kanoli"
}, {
mxid: "@_ooye_jan_mijawi:cadence.moe",
displayname: "jan Mijawi"
}, {
mxid: "@_ooye_jan_mukin:cadence.moe",
displayname: "jan Mukin"
}]), "jan Mijawi, run this\n.def", 0, "@", "jan Mijawi, run this\n.def")
t.equal(found, undefined)
})
test("find mention: highlights nothing if all tied", t => {
const found = findMention(processJoined([{
mxid: "@jankanoli:matrix.org",
displayname: "jan Kanoli"
}, {
mxid: "@_ooye_jan_mijawi:cadence.moe",
displayname: "jan Mijawi"
}, {
mxid: "@_ooye_jan_mukin:cadence.moe",
displayname: "jan Mukin"
}]), "jan Misali, run this\n.def", 0, "@", "jan Misali, run this\n.def")
t.equal(found, undefined)
})
test("find mention: test various tiebreakers", t => {
const found = findMention(processJoined([{
mxid: "@emma:conduit.rory.gay",

View file

@ -1,6 +1,6 @@
// @ts-check
const assert = require("assert").strict
const assert = require("assert")
const stream = require("stream")
const {PNG} = require("@cloudrac3r/pngjs")

View file

@ -18,8 +18,6 @@ const lottie = sync.require("../actions/lottie")
const mxUtils = sync.require("../../matrix/utils")
/** @type {import("../../discord/utils")} */
const dUtils = sync.require("../../discord/utils")
/** @type {import("../../discord/time")} */
const discordTime = sync.require("../../discord/time")
/** @type {import("./find-mentions")} */
const findMentions = sync.require("./find-mentions")
/** @type {import("../../discord/interactions/poll-responses")} */
@ -37,10 +35,10 @@ function getDiscordParseCallbacks(message, guild, useHTML, spoilers = []) {
/** @param {{id: string, type: "discordUser"}} node */
user: node => {
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
|| 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)
|| "unknown-user"
if (mxid && useHTML) {
@ -74,17 +72,6 @@ function getDiscordParseCallbacks(message, guild, useHTML, spoilers = []) {
return `:${node.name}:`
}
},
/** @param {{timestamp: number, style: string | null}} node */
timestamp: node => {
const text = discordTime.convertDiscordTime(node.timestamp, node.style)
if (useHTML) {
let url = `${reg.ooye.bridge_origin}/time/${node.timestamp}`
if (node.style) url += `/${node.style}`
return tag`<a href="${url}">${text}</a>`
} else {
return text
}
},
role: node => {
const role = guild.roles.find(r => r.id === node.id)
if (!role) {
@ -99,14 +86,6 @@ function getDiscordParseCallbacks(message, guild, useHTML, spoilers = []) {
return `@${role.name}:`
}
},
game: node => {
const game = message.mention_games?.find(g => g.id === node.id)
if (!game?.name) {
return "@$" + node.id
} else {
return game.name
}
},
everyone: () => {
if (message.mention_everyone) return "@room"
return "@everyone"
@ -130,11 +109,10 @@ const embedTitleParser = markdown.markdownEngine.parserFor({
/**
* @param {{room?: boolean, user_ids?: string[]}} mentions
* @param {Omit<DiscordTypes.APIAttachment, "id" | "proxy_url"> & {proxy_url?: string}} attachment
* @param {Omit<DiscordTypes.APIAttachment, "id" | "proxy_url">} attachment
* @param {boolean} [alwaysLink]
* @param {{fetch?: typeof fetch}} [di]
*/
async function attachmentToEvent(mentions, attachment, alwaysLink, di) {
async function attachmentToEvent(mentions, attachment, alwaysLink) {
const external_url = dUtils.getPublicUrlForCdn(attachment.url)
const emoji =
attachment.content_type?.startsWith("image/jp") ? "📸"
@ -144,7 +122,7 @@ async function attachmentToEvent(mentions, attachment, alwaysLink, di) {
: 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
if (attachment.filename.startsWith("SPOILER_") || !!((attachment.flags || 0) & DiscordTypes.AttachmentFlags.IsSpoiler)) {
if (attachment.filename.startsWith("SPOILER_")) {
return {
$type: "m.room.message",
"m.mentions": mentions,
@ -154,37 +132,8 @@ async function attachmentToEvent(mentions, attachment, alwaysLink, di) {
formatted_body: `<blockquote>${emoji} Uploaded SPOILER file: <a href="${external_url}">${external_url}</a> (${pb(attachment.size)})</blockquote>`
}
}
// attempt to convert large PNG image files to JPEG, since it's almost certainly a photo that was forced into PNG by the user copy-pasting.
if (
attachment.content_type === "image/png" && attachment.size > reg.ooye.max_file_size
&& !alwaysLink && attachment.proxy_url && attachment.width && attachment.height && di?.fetch
) {
const proxyUrl = new URL(attachment.proxy_url)
proxyUrl.searchParams.set("format", "jpeg")
const jpegUrl = proxyUrl.toString()
const res = await di.fetch(jpegUrl, {method: "HEAD"})
const newFilename = attachment.filename.replace(/\.png$/, ".jpg")
const newSize = Number(res.headers.get("content-length"))
if (res.ok && res.headers.get("content-type") === "image/jpeg" && !res.headers.has("content-encoding") && newSize <= reg.ooye.max_file_size) {
return {
$type: "m.room.message",
"m.mentions": mentions,
msgtype: "m.image",
url: await file.uploadDiscordFileToMxc(jpegUrl),
external_url,
body: attachment.description || newFilename,
filename: newFilename,
info: {
mimetype: "image/jpeg",
w: attachment.width,
h: attachment.height,
size: newSize
}
}
}
}
// for large files, always link them instead of uploading so I don't use up all the space in the content repo
if (alwaysLink || attachment.size > reg.ooye.max_file_size) {
else if (alwaysLink || attachment.size > reg.ooye.max_file_size) {
return {
$type: "m.room.message",
"m.mentions": mentions,
@ -193,8 +142,7 @@ async function attachmentToEvent(mentions, attachment, alwaysLink, di) {
format: "org.matrix.custom.html",
formatted_body: `${emoji} Uploaded file: <a href="${external_url}">${attachment.filename}</a> (${pb(attachment.size)})`
}
}
if (attachment.content_type?.startsWith("image/") && attachment.width && attachment.height) {
} else if (attachment.content_type?.startsWith("image/") && attachment.width && attachment.height) {
return {
$type: "m.room.message",
"m.mentions": mentions,
@ -210,8 +158,7 @@ async function attachmentToEvent(mentions, attachment, alwaysLink, di) {
size: attachment.size
}
}
}
if (attachment.content_type?.startsWith("video/") && attachment.width && attachment.height) {
} else if (attachment.content_type?.startsWith("video/") && attachment.width && attachment.height) {
return {
$type: "m.room.message",
"m.mentions": mentions,
@ -227,8 +174,7 @@ async function attachmentToEvent(mentions, attachment, alwaysLink, di) {
size: attachment.size
}
}
}
if (attachment.content_type?.startsWith("audio/")) {
} else if (attachment.content_type?.startsWith("audio/")) {
return {
$type: "m.room.message",
"m.mentions": mentions,
@ -243,8 +189,7 @@ async function attachmentToEvent(mentions, attachment, alwaysLink, di) {
duration: attachment.duration_secs && Math.round(attachment.duration_secs * 1000)
}
}
}
// else
} else {
return {
$type: "m.room.message",
"m.mentions": mentions,
@ -258,6 +203,7 @@ async function attachmentToEvent(mentions, attachment, alwaysLink, di) {
size: attachment.size
}
}
}
}
/** @param {DiscordTypes.APIPoll} poll */
@ -310,34 +256,8 @@ function getFormattedInteraction(interaction, isThinkingInteraction) {
const username = interaction.member?.nick || interaction.user.global_name || interaction.user.username
const thinkingText = isThinkingInteraction ? " — interaction loading..." : ""
return {
body: `❭ ${username} used \`/${interaction.name}\`${thinkingText}`,
html: `<blockquote><sub>❭ ${mxid ? tag`<a href="https://matrix.to/#/${mxid}">${username}</a>` : username} used <code>/${interaction.name}</code>${thinkingText}</sub></blockquote>`
}
}
/**
* @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)
}
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>`
}
}
@ -349,7 +269,7 @@ function mergeTextEvents(newEvents, events, forceSameMsgtype, forceMerge = false
* - includeEditFallbackStar: false
* - alwaysReturnFormattedBody: false - formatted_body will be skipped if it is the same as body because the message is plaintext. if you want the formatted_body to be returned anyway, for example to merge it with another message, then set this to true.
* - scanTextForMentions: true - needs to be set to false when converting forwarded messages etc which may be from a different channel that can't be scanned.
* @param {{api: import("../../matrix/api"), snow?: import("snowtransfer").SnowTransfer, fetch?: typeof fetch}} di simple-as-nails dependency injection for the matrix API
* @param {{api: import("../../matrix/api"), snow?: import("snowtransfer").SnowTransfer}} di simple-as-nails dependency injection for the matrix API
* @returns {Promise<{$type: string, $sender?: string, [x: string]: any}[]>}
*/
async function messageToEvent(message, guild, options = {}, di) {
@ -414,19 +334,9 @@ async function messageToEvent(message, guild, options = {}, di) {
}]
}
if (message.type === DiscordTypes.MessageType.ChannelFollowAdd) {
return [{
$type: "m.room.message",
msgtype: "m.emote",
body: `set this room to receive announcements from ${message.content}`,
format: "org.matrix.custom.html",
formatted_body: tag`set this room to receive announcements from <strong>${message.content}</strong>`,
"m.mentions": {}
}]
}
let isInteraction = (message.type === DiscordTypes.MessageType.ChatInputCommand || message.type === DiscordTypes.MessageType.ContextMenuCommand) && message.interaction && "name" in message.interaction
let isThinkingInteraction = isInteraction && !!((message.flags || 0) & DiscordTypes.MessageFlags.Loading)
const interaction = message.interaction_metadata || message.interaction
const isInteraction = message.type === DiscordTypes.MessageType.ChatInputCommand && !!interaction && "name" in interaction
const isThinkingInteraction = isInteraction && !!((message.flags || 0) & DiscordTypes.MessageFlags.Loading)
/**
@type {{room?: boolean, user_ids?: string[]}}
@ -467,16 +377,6 @@ async function messageToEvent(message, guild, options = {}, di) {
} else if (message.referenced_message) {
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("↩️")) {
// 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:]")
@ -611,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.
// 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 => {
const id = match[3]
const name = match[2]
@ -619,60 +519,29 @@ async function messageToEvent(message, guild, options = {}, di) {
return emojiToKey.emojiToKey({id, name, animated}, message.id) // Register the custom emoji if needed
}))
async function transformParsedVia(parsed, scanTextForMentions) {
for (let n = 0; n < parsed.length; n++) {
const node = parsed[n]
async function transformParsedVia(parsed) {
for (const node of parsed) {
if (node.type === "discordChannel" || node.type === "discordChannelLink") {
node.row = select("channel_room", ["room_id", "name", "nick"], {channel_id: node.id}).get()
if (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]) {
if (Array.isArray(maybeChildNodesArray)) {
await transformParsedVia(maybeChildNodesArray, scanTextForMentions && ["blockQuote", "list", "paragraph", "em", "strong", "u", "del", "text"].includes(node.type))
await transformParsedVia(maybeChildNodesArray)
}
}
}
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),
...customOptions
}, 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),
discordOnly: true,
escapeHTML: false,
@ -695,9 +564,10 @@ async function messageToEvent(message, guild, options = {}, di) {
html = "* " + html
}
if (((message.flags || 0) & DiscordTypes.MessageFlags.IsCrosspost) && message.author?.username) {
body = `[↷ ${message.author.username}]\n` + body
html = `↷ <strong>${message.author.username}</strong><br>` + html
const flags = message.flags || 0
if (flags & DiscordTypes.MessageFlags.IsCrosspost) {
body = `[🔀 ${message.author.username}]\n` + body
html = `🔀 <strong>${message.author.username}</strong><br>` + html
}
// Fallback body/formatted_body for replies
@ -712,8 +582,7 @@ async function messageToEvent(message, guild, options = {}, di) {
// check that condition 1 or 2 is met
if (repliedToEventInDifferentRoom || repliedToUnknownEvent) {
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) {
if (!referenced) { // backend couldn't be bothered to dereference the message, have to do it ourselves
assert(message.message_reference?.message_id)
referenced = await discord.snow.channel.getChannelMessage(message.message_reference.channel_id, message.message_reference.message_id)
}
@ -725,7 +594,7 @@ async function messageToEvent(message, guild, options = {}, di) {
const match = repliedToEventSenderMxid.match(/^@([^:]*)/)
assert(match)
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 {
repliedToDisplayName = referenced.author.global_name || referenced.author.username || "a Discord user"
repliedToUserHtml = repliedToDisplayName
@ -750,12 +619,6 @@ async function messageToEvent(message, guild, options = {}, di) {
+ html
body = `${repliedToDisplayName}: ${repliedToBody}`.split("\n").map(line => "> " + line).join("\n") // scenario 1 part B for mentions
+ "\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
const dateDisplay = dUtils.howOldUnbridgedMessage(referenced.timestamp, message.timestamp)
html = `<blockquote>In reply to ${dateDisplay} from ${repliedToDisplayName}:`
@ -767,8 +630,8 @@ async function messageToEvent(message, guild, options = {}, di) {
}
}
if (isInteraction && !isThinkingInteraction && message.interaction && events.length === 0) {
const formattedInteraction = getFormattedInteraction(message.interaction, false)
if (isInteraction && !isThinkingInteraction && events.length === 0) {
const formattedInteraction = getFormattedInteraction(interaction, false)
body = `${formattedInteraction.body}\n${body}`
html = `${formattedInteraction.html}${html}`
}
@ -804,12 +667,11 @@ async function messageToEvent(message, guild, options = {}, di) {
}
// Send Klipy GIFs in customised form
const urlPreviewEnabled = select("guild_space", "url_preview", {guild_id: guild?.id}).pluck().get() ?? 1
let isKlipyGIF = false
let isOnlyKlipyGIF = false
if (message.embeds?.length === 1 && message.embeds[0].provider?.name === "Klipy" && message.embeds[0].video?.url && urlPreviewEnabled) {
if (message.embeds?.length === 1 && message.embeds[0].provider?.name === "Klipy" && message.embeds[0].video?.url) {
isKlipyGIF = true
if (message.content?.match(/^https?:\/\/klipy\.com[^ \n]+$/)) {
if (message.content.match(/^https?:\/\/klipy\.com[^ \n]+$/)) {
isOnlyKlipyGIF = true
}
}
@ -825,20 +687,20 @@ async function messageToEvent(message, guild, options = {}, di) {
if (row && "event_id" in row) {
const via = await getViaServersMemo(row.room_id)
forwardedNotice.addLine(
`[↷ Forwarded from #${roomName}]`,
tag`↷ <em>Forwarded from ${roomName} <a href="https://matrix.to/#/${room.room_id}/${row.event_id}?${via}">[jump to event]</a></em>`
`[🔀 Forwarded from #${roomName}]`,
tag`🔀 <em>Forwarded from ${roomName} <a href="https://matrix.to/#/${room.room_id}/${row.event_id}?${via}">[jump to event]</a></em>`
)
} else {
const via = await getViaServersMemo(room.room_id)
forwardedNotice.addLine(
`[↷ Forwarded from #${roomName}]`,
tag`↷ <em>Forwarded from ${roomName} <a href="https://matrix.to/#/${room.room_id}?${via}">[jump to room]</a></em>`
`[🔀 Forwarded from #${roomName}]`,
tag`🔀 <em>Forwarded from ${roomName} <a href="https://matrix.to/#/${room.room_id}?${via}">[jump to room]</a></em>`
)
}
} else {
forwardedNotice.addLine(
`[↷ Forwarded message]`,
tag`↷ <em>Forwarded message</em>`
`[🔀 Forwarded message]`,
tag`🔀 <em>Forwarded message</em>`
)
}
@ -865,65 +727,62 @@ async function messageToEvent(message, guild, options = {}, di) {
events.push(...forwardedEvents)
}
if (isInteraction && isThinkingInteraction && message.interaction) {
const formattedInteraction = getFormattedInteraction(message.interaction, true)
if (isThinkingInteraction) {
const formattedInteraction = getFormattedInteraction(interaction, true)
await addTextEvent(formattedInteraction.body, formattedInteraction.html, "m.notice")
}
// Then text content
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
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}>`
})
const {body, html} = await transformContent(content, {isTheMessageContent: true})
const {body, html} = await transformContent(content)
await addTextEvent(body, html, msgtype)
}
// Then scheduled events
if (message.content && di?.snow) {
for (const match of [...message.content.matchAll(/discord\.gg\/([A-Za-z0-9]+)\?event=([0-9]{18,})|\/events\/([0-9]+)\/([0-9]{18,})/g)]) { // snowflake has minimum 18 because the events feature is at least that old
/** @type {DiscordTypes.APIGuildScheduledEvent | undefined} */
let event
let invite
if (match[1]) {
try {
invite = await di.snow.invite.getInvite(match[1], {guild_scheduled_event_id: match[2]})
event = invite.guild_scheduled_event
} catch (e) {
// Skip expired/invalid invites and events
if (e.message === "Unknown Invite") {
break
} else {
throw e
}
}
} else if (match[3]) {
try {
event = await di.snow.guildScheduledEvent.getGuildScheduledEvent(match[3], match[4])
} catch (e) {
throw e
}
}
for (const match of [...message.content.matchAll(/discord\.gg\/([A-Za-z0-9]+)\?event=([0-9]{18,})/g)]) { // snowflake has minimum 18 because the events feature is at least that old
const invite = await di.snow.invite.getInvite(match[1], {guild_scheduled_event_id: match[2]})
const event = invite.guild_scheduled_event
if (!event) continue // the event ID provided was not valid
const formatter = new Intl.DateTimeFormat("en-NZ", {month: "long", day: "numeric", hour: "numeric", minute: "2-digit", timeZoneName: "shortGeneric", timeZone: reg.ooye.time_zone}) // 9 June at 3:00 pm NZT
const rep = new mxUtils.MatrixStringBuilder()
const unixSeconds = Math.floor(new Date(event.scheduled_start_time).getTime() / 1000)
// Add time
if (event.scheduled_end_time) {
const formatter = new Intl.DateTimeFormat("en-NZ", {weekday: "long", month: "long", day: "numeric", hour: "numeric", minute: "2-digit", timeZone: "UTC"}) // 9 June at 3:00 pm NZT
var displayTime = (formatter.formatRange(new Date(event.scheduled_start_time), new Date(event.scheduled_end_time)) + " (UTC)").replaceAll(/\p{Zs}/gv, " ")
// @ts-ignore - no definition available for formatRange
rep.addParagraph(`Scheduled Event - ${formatter.formatRange(new Date(event.scheduled_start_time), new Date(event.scheduled_end_time))}`)
} else {
var displayTime = discordTime.convertDiscordTime(unixSeconds, "F")
rep.addParagraph(`Scheduled Event - ${formatter.format(new Date(event.scheduled_start_time))}`)
}
rep.addParagraph(
`Scheduled Event - ${displayTime}`,
tag`Scheduled Event - <a href="${reg.ooye.bridge_origin}/time/${unixSeconds.toString()}/F">${displayTime}</a>`
)
// Add details
rep.addLine(`## ${event.name}`, tag`<strong>${event.name}</strong>`)
@ -932,7 +791,7 @@ async function messageToEvent(message, guild, options = {}, di) {
// Add location
if (event.entity_metadata?.location) {
rep.addParagraph(`📍 ${event.entity_metadata.location}`)
} else if (invite?.channel?.name) {
} else if (invite.channel?.name) {
const roomID = select("channel_room", "room_id", {channel_id: invite.channel.id}).pluck().get()
if (roomID) {
const via = await getViaServersMemo(roomID)
@ -952,18 +811,22 @@ async function messageToEvent(message, guild, options = {}, di) {
// Then attachments
if (message.attachments) {
const attachmentEvents = await Promise.all(message.attachments.map(attachment => attachmentToEvent(mentions, attachment, false, {fetch: di?.fetch})))
const attachmentEvents = await Promise.all(message.attachments.map(attachment => attachmentToEvent(mentions, attachment)))
// Try to merge attachment events with the previous event
// This means that if the attachments ended up as a text link, and especially if there were many of them, the events will be joined together.
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
// Shift component-type URL embeds here too
const componentEmbeds = dUtils.filterTo(message.embeds || [], e => e.type !== "components")
message.components ??= []
message.components.push(...componentEmbeds.map(e => e.components).flat(1))
if (message.components?.length) {
const stack = new mxUtils.MatrixStringBuilderStack()
/** @param {DiscordTypes.APIMessageComponent} component */
@ -980,14 +843,7 @@ async function messageToEvent(message, guild, options = {}, di) {
/** @type {{[k in keyof DiscordTypes.APIUnfurledMediaItem]-?: NonNullable<DiscordTypes.APIUnfurledMediaItem[k]>}} */ // @ts-ignore
const file = component.file
assert(component.name && component.size && file.content_type)
const ev = await attachmentToEvent({}, {
filename: component.name,
content_type: file.content_type,
size: component.size,
url: file.url,
height: file.height,
width: file.width,
}, true, {fetch: di?.fetch})
const ev = await attachmentToEvent({}, {...file, filename: component.name, size: component.size}, true)
stack.msb.addLine(ev.body, ev.formatted_body)
}
else if (component.type === DiscordTypes.ComponentType.MediaGallery) {
@ -1049,9 +905,11 @@ async function messageToEvent(message, guild, options = {}, di) {
else if (component.type === DiscordTypes.ComponentType.Button) {
// May only be a section accessory or in an action row (up to 5)
if (component.style === DiscordTypes.ButtonStyle.Link) {
assert(component.label) // required for Discord to validate link buttons
const link = await transformContentMessageLinks(component.url)
stack.msb.add(`[${component.label} ${link}] `, tag`<a href="${link}">${component.label}</a> `)
if (component.label) {
stack.msb.add(`[${component.label} ${component.url}] `, tag`<a href="${component.url}">${component.label}</a> `)
} else {
stack.msb.add(component.url)
}
}
}
@ -1064,30 +922,8 @@ async function messageToEvent(message, guild, options = {}, di) {
const {body, formatted_body} = stack.msb.get()
if (body.trim().length) {
// Create new message if Components V2 (cannot have regular content)
if ((message.flags ?? 0) & DiscordTypes.MessageFlags.IsComponentsV2) {
await addTextEvent(body, formatted_body, "m.text")
}
// Add as m.notice if component URL preview
else if (componentEmbeds.length) {
mergeTextEvents([{
msgtype: "m.notice",
body,
format: "org.matrix.custom.html",
formatted_body,
"m.mentions": mentions
}], events, true, false)
}
// 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
@ -1097,6 +933,7 @@ async function messageToEvent(message, guild, options = {}, di) {
}
// Then embeds
const urlPreviewEnabled = select("guild_space", "url_preview", {guild_id: guild?.id}).pluck().get() ?? 1
for (const embed of message.embeds || []) {
if (!urlPreviewEnabled && !message.author?.bot) {
continue // show embeds for everyone if enabled, or bot users only if disabled (bots often send content in embeds)
@ -1118,10 +955,6 @@ async function messageToEvent(message, guild, options = {}, di) {
continue // If discord creates an embed preview for a discord channel link, don't copy that embed
}
if (embed.provider?.name === "GitHub") {
continue // GitHub url previews are very large and fairly useless
}
if (embed.url && spoilers.some(sp => sp.match(/\bhttps?:\/\/[a-z]/))) {
// If the original message had spoilered URLs, don't generate any embeds for links.
// This logic is the same as the Discord desktop client. It doesn't match specific embeds to specific spoilered text, it's all or nothing.
@ -1131,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
const rep = new mxUtils.MatrixStringBuilder()
let isAdditionalImage = false
if (isKlipyGIF) {
assert(embed.video?.url)
@ -1148,6 +980,15 @@ async function messageToEvent(message, guild, options = {}, di) {
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
let authorNameText = embed.author?.name || ""
if (authorNameText && embed.author?.icon_url) authorNameText = `⏺️ ${authorNameText}` // using the emoji instead of an image
@ -1189,35 +1030,15 @@ async function messageToEvent(message, guild, options = {}, di) {
let chosenImage = embed.image?.url
// 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 (chosenImage) {
isAdditionalImage = !rep.body && !!events.length
rep.addParagraph(`📸 ${dUtils.getPublicUrlForCdn(chosenImage)}`)
}
if (chosenImage) rep.addParagraph(`📸 ${dUtils.getPublicUrlForCdn(chosenImage)}`)
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.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}`)
}
if (embed.footer?.text) rep.addLine(`— ${embed.footer.text}`, tag`— ${embed.footer.text}`)
let {body, formatted_body: html} = rep.get()
body = body.split("\n").map(l => "| " + l).join("\n")
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
await addTextEvent(body, html, "m.notice")
}
@ -1238,7 +1059,7 @@ async function messageToEvent(message, guild, options = {}, di) {
}
} else {
let body = stickerItem.name
const sticker = guild.stickers?.find(sticker => sticker.id === stickerItem.id)
const sticker = guild.stickers.find(sticker => sticker.id === stickerItem.id)
if (sticker && sticker.description) body += ` - ${sticker.description}`
return {
$type: "m.sticker",
@ -1255,7 +1076,7 @@ async function messageToEvent(message, guild, options = {}, di) {
}
// Rich replies
if (repliedToEventRow && !repliedToEventInDifferentRoom && events[0]) {
if (repliedToEventRow && !repliedToEventInDifferentRoom) {
Object.assign(events[0], {
"m.relates_to": {
"m.in_reply_to": {

View file

@ -1,30 +1,6 @@
const {test} = require("supertape")
const {messageToEvent} = require("./message-to-event")
const data = require("../../../test/data")
const {mockGetEffectivePower} = require("../../matrix/utils.test")
test("message2event url preview components: wikipedia", async t => {
const events = await messageToEvent(data.message_with_components.wikipedia_component_url_preview, data.guild.general, {})
t.deepEqual(events, [
{
$type: "m.room.message",
body: "https://en.wikipedia.org/wiki/HTTP/2",
format: "org.matrix.custom.html",
formatted_body: "<a href=\"https://en.wikipedia.org/wiki/HTTP/2\">https://en.wikipedia.org/wiki/HTTP/2</a>",
"m.mentions": {},
msgtype: "m.text"
},
{
body: "| ### [HTTP/2](https://en.wikipedia.org/wiki/HTTP/2?wprov=diim1)"
+ "\n| "
+ "\n| HTTP/2 (originally named HTTP/2.0) is a major revision of the HTTP network protocol used by the World Wide Web. It was derived from the earlier experimental SPDY protocol, originally developed by Google. HTTP/2 was developed by the HTTP Working Group (also called httpbis, where \"bis\" means \"twice\" in Latin) of the Internet Engineering Task Force (…",
format: "org.matrix.custom.html",
formatted_body: "<blockquote><h3><a href=\"https://en.wikipedia.org/wiki/HTTP/2?wprov=diim1\">HTTP/2</a></h3>HTTP/2 (originally named HTTP/2.0) is a major revision of the HTTP network protocol used by the World Wide Web. It was derived from the earlier experimental SPDY protocol, originally developed by Google. HTTP/2 was developed by the HTTP Working Group (also called httpbis, where \"bis\" means \"twice\" in Latin) of the Internet Engineering Task Force (…</blockquote>",
msgtype: "m.notice",
"m.mentions": {}
}
])
})
test("message2event components: pk question mark output", async t => {
const events = await messageToEvent(data.message_with_components.pk_question_mark_response, data.guild.general, {})
@ -67,7 +43,7 @@ test("message2event components: pk question mark output", async t => {
+ "\n| Same hat"
+ "\n| 🖼️ Image: https://bridge.example.org/download/discordcdn/934955898965729280/1466556006527012987/image.png"
+ "\n"
+ "\n-# Original Message ID: 1466556003645657118 · 29 January 2026 at 10:09 pm (UTC)",
+ "\n-# Original Message ID: 1466556003645657118 · <t:1769724599:f>",
format: "org.matrix.custom.html",
formatted_body: "<blockquote>"
+ "<h3>Lillith (INX)</h3>"
@ -89,36 +65,15 @@ test("message2event components: pk question mark output", async t => {
+ "<hr>"
+ "<blockquote><p><strong>System:</strong> INX (<code>xffgnx</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>§b, !, ‼, Ears Port Ping, Ears Update Ping, Yttr Ping, unsup Ping</p>"
+ `🖼️ <a href="https://files.inx.moe/p/cdn/lillith.webp">https://files.inx.moe/p/cdn/lillith.webp</a>`
+ "<hr>"
+ "<p>Same hat</p>"
+ `🖼️ Image: <a href="https://bridge.example.org/download/discordcdn/934955898965729280/1466556006527012987/image.png">image.png</a></blockquote>`
+ "<p><sub>Original Message ID: 1466556003645657118 · <a href=\"https://bridge.example.org/time/1769724599/f\">29 January 2026 at 10:09 pm (UTC)</a></sub></p>",
+ "<p><sub>Original Message ID: 1466556003645657118 · &lt;t:1769724599:f&gt;</sub></p>",
"m.mentions": {},
msgtype: "m.text",
}])
})
test("message2event components: pk ping message legacy components", async t => {
const events = await messageToEvent(data.message_with_components.pk_ping_components_v1, data.guild.general, {}, {
api: {
async getJoinedMembers() {
return {joined: {}}
},
getEffectivePower: mockGetEffectivePower()
}
})
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.text",
body: "❭ cadence used `/🔔 Ping author`"
+ "\nPsst, **Red** (@cadence.worm:), you have been pinged by @cadence.worm:."
+ "\n[Jump https://matrix.to/#/!TqlyQmifxGUggEmdBN:cadence.moe/$l9FMmsEbh9K0NUReeEpWOMZYGRlUOE8yLcm6P-TYHSM?via=cadence.moe] ",
format: "org.matrix.custom.html",
formatted_body: "<blockquote><sub>❭ <a href=\"https://matrix.to/#/@_ooye_cadence:cadence.moe\">cadence</a> used <code>/🔔 Ping author</code></sub></blockquote>Psst, <strong>Red</strong> (<a href=\"https://matrix.to/#/@_ooye_cadence:cadence.moe\">@cadence.worm</a>), you have been pinged by <a href=\"https://matrix.to/#/@_ooye_cadence:cadence.moe\">@cadence.worm</a>.<br><a href=\"https://matrix.to/#/!TqlyQmifxGUggEmdBN:cadence.moe/$l9FMmsEbh9K0NUReeEpWOMZYGRlUOE8yLcm6P-TYHSM?via=cadence.moe\">Jump</a> ",
"m.mentions": {}
}])
})

View file

@ -8,9 +8,9 @@ test("message2event embeds: interaction loading", async t => {
const events = await messageToEvent(data.interaction_message.thinking_interaction, data.guild.general, {})
t.deepEqual(events, [{
$type: "m.room.message",
body: "❭ Brad used `/stats` — interaction loading...",
body: "↪️ Brad used `/stats` — interaction loading...",
format: "org.matrix.custom.html",
formatted_body: "<blockquote><sub>❭ <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">Brad</a> used <code>/stats</code> — interaction loading...</sub></blockquote>",
formatted_body: "<blockquote><sub>↪️ <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">Brad</a> used <code>/stats</code> — interaction loading...</sub></blockquote>",
"m.mentions": {},
msgtype: "m.notice",
}])
@ -22,12 +22,12 @@ test("message2event embeds: nothing but a field", async t => {
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.notice",
body: "❭ PapiOphidian used `/stats`"
body: "↪️ PapiOphidian used `/stats`"
+ "\n| ### Amanda 🎵#2192 :online:"
+ "\n| willow tree, branch 0"
+ "\n| **❯ Uptime:**\n| 3m 55s\n| **❯ Memory:**\n| 64.45MB",
format: "org.matrix.custom.html",
formatted_body: '<blockquote><sub>❭ <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">PapiOphidian</a> used <code>/stats</code></sub></blockquote>'
formatted_body: '<blockquote><sub>↪️ <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">PapiOphidian</a> used <code>/stats</code></sub></blockquote>'
+ '<blockquote><p><strong>Amanda 🎵#2192 <img data-mx-emoticon height=\"32\" src=\"mxc://cadence.moe/LCEqjStXCxvRQccEkuslXEyZ\" title=\":online:\" alt=\":online:\">'
+ '<br>willow tree, branch 0</strong>'
+ '<br><strong>❯ Uptime:</strong><br>3m 55s'
@ -125,8 +125,8 @@ test("message2event embeds: blockquote in embed", async t => {
t.equal(called, 1, "should call getJoinedMembers once")
})
test("message2event embeds: extreme html is all escaped", async t => {
const events = await messageToEvent(data.message_with_embeds.extreme_html_escaping, data.guild.general)
test("message2event embeds: crazy html is all escaped", async t => {
const events = await messageToEvent(data.message_with_embeds.escaping_crazy_html_tags, data.guild.general)
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.notice",
@ -153,10 +153,10 @@ test("message2event embeds: title without url", async t => {
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.notice",
body: "❭ PapiOphidian used `/stats`"
body: "↪️ PapiOphidian used `/stats`"
+ "\n| ## Hi, I'm Amanda!\n| \n| I condone pirating music!",
format: "org.matrix.custom.html",
formatted_body: '<blockquote><sub>❭ <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">PapiOphidian</a> used <code>/stats</code></sub></blockquote>'
formatted_body: '<blockquote><sub>↪️ <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">PapiOphidian</a> used <code>/stats</code></sub></blockquote>'
+ `<blockquote><p><strong>Hi, I'm Amanda!</strong></p><p>I condone pirating music!</p></blockquote>`,
"m.mentions": {}
}])
@ -167,10 +167,10 @@ test("message2event embeds: url without title", async t => {
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.notice",
body: "❭ PapiOphidian used `/stats`"
body: "↪️ PapiOphidian used `/stats`"
+ "\n| I condone pirating music!",
format: "org.matrix.custom.html",
formatted_body: '<blockquote><sub>❭ <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">PapiOphidian</a> used <code>/stats</code></sub></blockquote>'
formatted_body: '<blockquote><sub>↪️ <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">PapiOphidian</a> used <code>/stats</code></sub></blockquote>'
+ `<blockquote><p>I condone pirating music!</p></blockquote>`,
"m.mentions": {}
}])
@ -181,10 +181,10 @@ test("message2event embeds: author without url", async t => {
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.notice",
body: "❭ PapiOphidian used `/stats`"
body: "↪️ PapiOphidian used `/stats`"
+ "\n| ## Amanda\n| \n| I condone pirating music!",
format: "org.matrix.custom.html",
formatted_body: '<blockquote><sub>❭ <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">PapiOphidian</a> used <code>/stats</code></sub></blockquote>'
formatted_body: '<blockquote><sub>↪️ <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">PapiOphidian</a> used <code>/stats</code></sub></blockquote>'
+ `<blockquote><p><strong>Amanda</strong></p><p>I condone pirating music!</p></blockquote>`,
"m.mentions": {}
}])
@ -195,81 +195,15 @@ test("message2event embeds: author url without name", async t => {
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.notice",
body: "❭ PapiOphidian used `/stats`"
body: "↪️ PapiOphidian used `/stats`"
+ "\n| I condone pirating music!",
format: "org.matrix.custom.html",
formatted_body: '<blockquote><sub>❭ <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">PapiOphidian</a> used <code>/stats</code></sub></blockquote>'
formatted_body: '<blockquote><sub>↪️ <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">PapiOphidian</a> used <code>/stats</code></sub></blockquote>'
+ `<blockquote><p>I condone pirating music!</p></blockquote>`,
"m.mentions": {}
}])
})
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 => {
const events = await messageToEvent(data.message_with_embeds.vx_image, data.guild.general)
t.deepEqual(events, [{
@ -282,7 +216,9 @@ test("message2event embeds: vx image", async t => {
}, {
$type: "m.room.message",
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| ## Tomorrow Corporation (@TomorrowCorp) https://vxtwitter.com/TomorrowCorp/status/1760330671074287875"
+ "\n| "
@ -292,16 +228,15 @@ test("message2event embeds: vx image", async t => {
+ "\n| "
+ "\n| 💖 123 🔁 36"
+ "\n| "
+ "\n| 📸 https://pbs.twimg.com/media/GG3zUMGbIAAxs3h.jpg"
+ "\n| via vxTwitter / fixvx https://github.com/dylanpdx/BetterTwitFix",
+ "\n| 📸 https://pbs.twimg.com/media/GG3zUMGbIAAxs3h.jpg",
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>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>💖 123 🔁 36`
+ `</p><p>📸 https://pbs.twimg.com/media/GG3zUMGbIAAxs3h.jpg</p>`
+ `<sub><a href="https://github.com/dylanpdx/BetterTwitFix">vxTwitter / fixvx</a></sub></blockquote>`,
+ `</p><p>📸 https://pbs.twimg.com/media/GG3zUMGbIAAxs3h.jpg</p></blockquote>`,
"m.mentions": {}
}])
})
@ -318,16 +253,16 @@ test("message2event embeds: vx video", async t => {
}, {
$type: "m.room.message",
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| ## 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| via vxTwitter / fixvx https://github.com/dylanpdx/BetterTwitFix",
+ "\n| \n| 🎞️ https://video.twimg.com/ext_tw_video/1759967449548541952/pu/vid/avc1/1280x720/XN1LFIJqAFBdtaoh.mp4?tag=12",
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>🎞️ https://video.twimg.com/ext_tw_video/1759967449548541952/pu/vid/avc1/1280x720/XN1LFIJqAFBdtaoh.mp4?tag=12</p>`
+ `<sub><a href="https://github.com/dylanpdx/BetterTwitFix">vxTwitter / fixvx</a></sub></blockquote>`,
+ `</p><p>🎞️ https://video.twimg.com/ext_tw_video/1759967449548541952/pu/vid/avc1/1280x720/XN1LFIJqAFBdtaoh.mp4?tag=12</p></blockquote>`,
"m.mentions": {}
}])
})
@ -344,7 +279,8 @@ test("message2event embeds: youtube video", async t => {
}, {
$type: "m.room.message",
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| twitter"
+ "\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| 🎞️ https://www.youtube.com/embed/kDMHHw8JqLE",
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>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`

View file

@ -4,7 +4,6 @@ const {MatrixServerError} = require("../../matrix/mreq")
const data = require("../../../test/data")
const {mockGetEffectivePower} = require("../../matrix/utils.test")
const Ty = require("../../types")
const {db} = require("../../passthrough")
/**
* @param {string} roomID
@ -192,38 +191,6 @@ test("message2event: manually constructed unknown roles should use fallback", as
}])
})
test("message2event: simple game mentions", async t => {
const events = await messageToEvent(data.message.simple_game_mention, data.guild.general, {})
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.text",
body: "do you play Minecraft"
}])
})
test("message2event: timestamps", async t => {
const events = await messageToEvent(data.message.timestamps, data.guild.general, {})
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.text",
body: "4 Oct 2026, 9:33:33 am (UTC)"
+ "\n4 October 2026 at 9:33 am (UTC)"
+ "\nSunday, 4 October 2026 at 9:34 am (UTC)"
+ "\n9:34:08 am (UTC)"
+ "\n3 October 2026 at 9:30 am (UTC)"
+ "\n24 December 2026 at 11:00 pm (UTC)",
format: "org.matrix.custom.html",
formatted_body: `<a href="https://bridge.example.org/time/1791106413/S">4 Oct 2026, 9:33:33 am (UTC)</a>`
+ `<br><a href="https://bridge.example.org/time/1791106436/f">4 October 2026 at 9:33 am (UTC)</a>`
+ `<br><a href="https://bridge.example.org/time/1791106440/F">Sunday, 4 October 2026 at 9:34 am (UTC)</a>`
+ `<br><a href="https://bridge.example.org/time/1791106448/T">9:34:08 am (UTC)</a>`
+ `<br><a href="https://bridge.example.org/time/1791019800/R">3 October 2026 at 9:30 am (UTC)</a>`
+ `<br><a href="https://bridge.example.org/time/1798153200/R">24 December 2026 at 11:00 pm (UTC)</a>`,
"m.mentions": {}
}])
})
test("message2event: simple message link", async t => {
let called = 0
const events = await messageToEvent(data.message.simple_message_link, data.guild.general, {}, {
@ -766,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 => {
const events = await messageToEvent(data.message.simple_written_at_mention_for_matrix, data.guild.general, {}, {
api: {
@ -847,7 +789,7 @@ test("message2event: simple written @mention for matrix user", async t => {
]
},
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",
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`
}])
@ -896,7 +838,7 @@ test("message2event: many written @mentions for matrix users", async t => {
]
},
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",
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>`
}])
@ -948,7 +890,7 @@ test("message2event: written @mentions may match part of the name", async t => {
]
},
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",
formatted_body: `I wonder if <a href="https://matrix.to/#/@secret:cadence.moe">@cadence</a> saw this?`
}])
@ -999,7 +941,7 @@ test("message2event: written @mentions may match part of the mxid", async t => {
]
},
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",
formatted_body: `I wonder if <a href="https://matrix.to/#/@huckleton:cadence.moe">@huck</a> saw this?`
}])
@ -1020,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&lt;?&gt;</code>`
}])
})
test("message2event: written @mentions do not match in code block", async t => {
const events = await messageToEvent({
...data.message.advanced_written_at_mention_for_matrix,
content: "```java\npublic @Nullable EntityType<?>\n```"
}, data.guild.general, {}, {})
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.text",
body: "```java\npublic @Nullable EntityType<?>\n```",
format: "org.matrix.custom.html",
formatted_body: `<pre><code class="language-java">public @Nullable EntityType&lt;?&gt;</code></pre>`
}])
})
test("message2event: entire message may match elaborate display name", async t => {
let called = 0
const events = await messageToEvent({
@ -1095,7 +1007,7 @@ test("message2event: entire message may match elaborate display name", async t =
]
},
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",
formatted_body: `<a href="https://matrix.to/#/@wa:cadence.moe">@Cadence, Maid of Creation, Eye of Clarity, Empress of Hope ☆</a>`
}])
@ -1135,45 +1047,6 @@ test("message2event: very large attachment is linked instead of being uploaded",
}])
})
test("message2event: very large png is converted to jpeg for upload", async t => {
const events = await messageToEvent({
content: "",
attachments: [{
filename: "855064e9-bdae-487a-a497-c662bd510487.png",
url: "https://cdn.discordapp.com/attachments/1160894080998461480/1548656385292509274/855064e9-bdae-487a-a497-c662bd510487.png",
proxy_url: "https://media.discordapp.net/attachments/1160894080998461480/1548656385292509274/855064e9-bdae-487a-a497-c662bd510487.png",
content_type: "image/png",
width: 5472,
height: 3648,
size: 14967919
}]
}, data.guild.general, {}, {
async fetch(url, init) {
t.equal(url, "https://media.discordapp.net/attachments/1160894080998461480/1548656385292509274/855064e9-bdae-487a-a497-c662bd510487.png?format=jpeg")
t.equal(init.method, "HEAD")
return new Response("wa", {headers: {
"Content-Type": "image/jpeg",
"Content-Length": 483085
}})
}
})
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.image",
body: "855064e9-bdae-487a-a497-c662bd510487.jpg",
info: {
w: 5472,
h: 3648,
mimetype: "image/jpeg",
size: 483085
},
external_url: "https://bridge.example.org/download/discordcdn/1160894080998461480/1548656385292509274/855064e9-bdae-487a-a497-c662bd510487.png",
filename: "855064e9-bdae-487a-a497-c662bd510487.jpg",
url: "mxc://cadence.moe/zXNXRvJwRDMIzZZVqHUXtGeD"
}])
})
test("message2event: multiple attachments are combined into the same event where possible", async t => {
const events = await messageToEvent({
content: "hey",
@ -1211,7 +1084,7 @@ test("message2event: multiple attachments are combined into the same event where
formatted_body: "hey"
+ `<br>📄 Uploaded file: <a href="https://bridge.example.org/download/discordcdn/123/456/789.mega">hey.jpg</a> (100 MB)`
+ `<br><blockquote>📸 Uploaded SPOILER file: <a href="https://bridge.example.org/download/discordcdn/123/456/SPOILER_secret.jpg">https://bridge.example.org/download/discordcdn/123/456/SPOILER_secret.jpg</a> (38 KB)</blockquote>`
+ `📄 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",
"m.mentions": {},
@ -1239,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 => {
const events = await messageToEvent(data.special_message.thread_start_context, data.guild.general, {}, {
api: {
@ -1344,9 +1204,9 @@ test("message2event: crossposted announcements say where they are crossposted fr
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.text",
body: "[↷ Chewey Bot Official Server #announcements]\nAll text based commands are now inactive on Chewey Bot\nTo continue using commands you'll need to use them as slash commands",
body: "[🔀 Chewey Bot Official Server #announcements]\nAll text based commands are now inactive on Chewey Bot\nTo continue using commands you'll need to use them as slash commands",
format: "org.matrix.custom.html",
formatted_body: "↷ <strong>Chewey Bot Official Server #announcements</strong><br>All text based commands are now inactive on Chewey Bot<br>To continue using commands you'll need to use them as slash commands"
formatted_body: "🔀 <strong>Chewey Bot Official Server #announcements</strong><br>All text based commands are now inactive on Chewey Bot<br>To continue using commands you'll need to use them as slash commands"
}])
})
@ -1415,9 +1275,9 @@ test("message2event: forwarded image", async t => {
t.deepEqual(events, [
{
$type: "m.room.message",
body: "[↷ Forwarded message]",
body: "[🔀 Forwarded message]",
format: "org.matrix.custom.html",
formatted_body: "↷ <em>Forwarded message</em>",
formatted_body: "🔀 <em>Forwarded message</em>",
"m.mentions": {},
msgtype: "m.notice",
},
@ -1456,10 +1316,10 @@ test("message2event: constructed forwarded message", async t => {
t.deepEqual(events, [
{
$type: "m.room.message",
body: "[↷ Forwarded from #wonderland]"
body: "[🔀 Forwarded from #wonderland]"
+ "\n» What's cooking, good looking? :hipposcope:",
format: "org.matrix.custom.html",
formatted_body: `↷ <em>Forwarded from wonderland <a href="https://matrix.to/#/!qzDBLKlildpzrrOnFZ:cadence.moe/$tBIT8mO7XTTCgIINyiAIy6M2MSoPAdJenRl_RLyYuaE?via=cadence.moe&amp;via=matrix.org">[jump to event]</a></em>`
formatted_body: `🔀 <em>Forwarded from wonderland <a href="https://matrix.to/#/!qzDBLKlildpzrrOnFZ:cadence.moe/$tBIT8mO7XTTCgIINyiAIy6M2MSoPAdJenRl_RLyYuaE?via=cadence.moe&amp;via=matrix.org">[jump to event]</a></em>`
+ `<br><blockquote>What's cooking, good looking? <img data-mx-emoticon height="32" src="mxc://cadence.moe/WbYqNlACRuicynBfdnPYtmvc" title=":hipposcope:" alt=":hipposcope:"></blockquote>`,
"m.mentions": {},
msgtype: "m.text",
@ -1515,10 +1375,10 @@ test("message2event: constructed forwarded text", async t => {
t.deepEqual(events, [
{
$type: "m.room.message",
body: "[↷ Forwarded from #amanda-spam]"
body: "[🔀 Forwarded from #amanda-spam]"
+ "\n» What's cooking, good looking?",
format: "org.matrix.custom.html",
formatted_body: `↷ <em>Forwarded from amanda-spam <a href="https://matrix.to/#/!CzvdIdUQXgUjDVKxeU:cadence.moe?via=cadence.moe&amp;via=matrix.org">[jump to room]</a></em>`
formatted_body: `🔀 <em>Forwarded from amanda-spam <a href="https://matrix.to/#/!CzvdIdUQXgUjDVKxeU:cadence.moe?via=cadence.moe&amp;via=matrix.org">[jump to room]</a></em>`
+ `<br><blockquote>What's cooking, good looking?</blockquote>`,
"m.mentions": {},
msgtype: "m.text",
@ -1538,10 +1398,10 @@ test("message2event: don't scan forwarded messages for mentions", async t => {
t.deepEqual(events, [
{
$type: "m.room.message",
body: "[↷ Forwarded message]"
body: "[🔀 Forwarded message]"
+ "\n» If some folks have spare bandwidth then helping out ArchiveTeam with archiving soon to be deleted research and government data might be worthwhile https://social.luca.run/@luca/113950834185678114",
format: "org.matrix.custom.html",
formatted_body: `↷ <em>Forwarded message</em>`
formatted_body: `🔀 <em>Forwarded message</em>`
+ `<br><blockquote>If some folks have spare bandwidth then helping out ArchiveTeam with archiving soon to be deleted research and government data might be worthwhile <a href="https://social.luca.run/@luca/113950834185678114">https://social.luca.run/@luca/113950834185678114</a></blockquote>`,
"m.mentions": {},
msgtype: "m.text"
@ -1589,12 +1449,12 @@ test("message2event: irl invite event renders embed", async t => {
{
$type: "m.room.message",
msgtype: "m.notice",
body: `| Scheduled Event - Sunday, 8 June, 10:00 am – 12:00 pm (UTC)`
body: `| Scheduled Event - 8 June at 10:00 pm NZT – 9 June at 12:00 am NZT`
+ `\n| ## forest exploration`
+ `\n| `
+ `\n| 📍 the dark forest`,
format: "org.matrix.custom.html",
formatted_body: `<blockquote><p>Scheduled Event - <a href=\"https://bridge.example.org/time/1749376800/F\">Sunday, 8 June, 10:00 am – 12:00 pm (UTC)</a></p>`
formatted_body: `<blockquote><p>Scheduled Event - 8 June at 10:00 pm NZT – 9 June at 12:00 am NZT</p>`
+ `<strong>forest exploration</strong>`
+ `<p>📍 the dark forest</p></blockquote>`,
"m.mentions": {}
@ -1622,13 +1482,13 @@ test("message2event: vc invite event renders embed", async t => {
{
$type: "m.room.message",
msgtype: "m.notice",
body: `| Scheduled Event - Monday, 9 June 2025 at 3:00 am (UTC)`
body: `| Scheduled Event - 9 June at 3:00 pm NZT`
+ `\n| ## Cooking (Netrunners)`
+ `\n| Short circuited brain interfaces actually just means your brain is medium rare, yum.`
+ `\n| `
+ `\n| 🔊 Cooking`,
format: "org.matrix.custom.html",
formatted_body: `<blockquote><p>Scheduled Event - <a href=\"https://bridge.example.org/time/1749438000/F\">Monday, 9 June 2025 at 3:00 am (UTC)</a></p>`
formatted_body: `<blockquote><p>Scheduled Event - 9 June at 3:00 pm NZT</p>`
+ `<strong>Cooking (Netrunners)</strong><br>Short circuited brain interfaces actually just means your brain is medium rare, yum.`
+ `<p>🔊 Cooking</p></blockquote>`,
"m.mentions": {}
@ -1664,13 +1524,13 @@ test("message2event: vc invite event renders embed with room link", async t => {
{
$type: "m.room.message",
msgtype: "m.notice",
body: `| Scheduled Event - Monday, 9 June 2025 at 3:00 am (UTC)`
body: `| Scheduled Event - 9 June at 3:00 pm NZT`
+ `\n| ## Cooking (Netrunners)`
+ `\n| Short circuited brain interfaces actually just means your brain is medium rare, yum.`
+ `\n| `
+ `\n| 🔊 Hey. - https://matrix.to/#/!FuDZhlOAtqswlyxzeR:cadence.moe?via=cadence.moe`,
format: "org.matrix.custom.html",
formatted_body: `<blockquote><p>Scheduled Event - <a href=\"https://bridge.example.org/time/1749438000/F\">Monday, 9 June 2025 at 3:00 am (UTC)</a></p>`
formatted_body: `<blockquote><p>Scheduled Event - 9 June at 3:00 pm NZT</p>`
+ `<strong>Cooking (Netrunners)</strong><br>Short circuited brain interfaces actually just means your brain is medium rare, yum.`
+ `<p>🔊 Hey. - <a href="https://matrix.to/#/!FuDZhlOAtqswlyxzeR:cadence.moe?via=cadence.moe">Hey.</a></p></blockquote>`,
"m.mentions": {}
@ -1678,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(`Unknown Invite`)
}
}
}
})
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 => {
let called = 0
const events = await messageToEvent({
@ -1891,9 +1729,9 @@ test("message2event: forwarded message with unreferenced mention", async t => {
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.text",
body: "[↷ Forwarded message]\n» @unknown-user:\n» 🎞️ Uploaded file: https://bridge.example.org/download/discordcdn/893634327722721290/1463174815119704114/2022-10-18_16-49-46.mp4 (51 MB)",
body: "[🔀 Forwarded message]\n» @unknown-user:\n» 🎞️ Uploaded file: https://bridge.example.org/download/discordcdn/893634327722721290/1463174815119704114/2022-10-18_16-49-46.mp4 (51 MB)",
format: "org.matrix.custom.html",
formatted_body: "↷ <em>Forwarded message</em><br><blockquote>@unknown-user:<br>🎞️ Uploaded file: <a href=\"https://bridge.example.org/download/discordcdn/893634327722721290/1463174815119704114/2022-10-18_16-49-46.mp4\">2022-10-18_16-49-46.mp4</a> (51 MB)</blockquote>",
formatted_body: "🔀 <em>Forwarded message</em><br><blockquote>@unknown-user:<br>🎞️ Uploaded file: <a href=\"https://bridge.example.org/download/discordcdn/893634327722721290/1463174815119704114/2022-10-18_16-49-46.mp4\">2022-10-18_16-49-46.mp4</a> (51 MB)</blockquote>",
"m.mentions": {}
}])
})

View file

@ -22,7 +22,7 @@ function pinsToList(pins, kstate) {
/** @type {string[]} */
const result = []
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)
}
result.reverse()

View file

@ -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

View file

@ -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'
}]
})
})

View file

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

View file

@ -1,6 +1,6 @@
// @ts-check
const assert = require("assert").strict
const assert = require("assert")
const {reg} = require("../../matrix/read-registration")
const Ty = require("../../types")
@ -66,7 +66,7 @@ function userToSimName(user) {
// 1. Is sim user already registered?
const existing = select("sim", "user_id", {user_id: user.id}).pluck().get()
assert.equal(existing, undefined, "Shouldn't try to create a new name for an existing sim")
assert.equal(existing, null, "Shouldn't try to create a new name for an existing sim")
// 2. Register based on username (could be new or old format)
// (Unless it's a special user, in which case copy their provided mappings.)
@ -113,7 +113,7 @@ function webhookAuthorToSimName(author) {
// 1. Is sim user already registered?
const fakeUserID = webhookAuthorToFakeUserID(author)
const existing = select("sim", "user_id", {user_id: fakeUserID}).pluck().get()
assert.equal(existing, undefined, "Shouldn't try to create a new name for an existing sim")
assert.equal(existing, null, "Shouldn't try to create a new name for an existing sim")
// 2. Register based on username (could be new or old format)
const downcased = "webhook_" + downcaseUsername(author)

View file

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

View file

@ -14,8 +14,6 @@ const {sync} = passthrough
/** @type {import("./discord-packets")} */
const discordPackets = sync.require("./discord-packets")
const CONNECTION_DEBUG = false
class DiscordClient {
/**
* @param {string} discordToken
@ -26,7 +24,7 @@ class DiscordClient {
const intents = [
"DIRECT_MESSAGES", "DIRECT_MESSAGE_REACTIONS", "DIRECT_MESSAGE_TYPING",
"GUILDS", "GUILD_EMOJIS_AND_STICKERS", "GUILD_MESSAGES", "GUILD_MESSAGE_REACTIONS", "GUILD_MESSAGE_TYPING", "GUILD_WEBHOOKS", "GUILD_MESSAGE_POLLS",
"MESSAGE_CONTENT", "GUILD_MEMBERS"
"MESSAGE_CONTENT"
]
if (reg.ooye.receive_presences !== false) intents.push("GUILD_PRESENCES")
this.discordToken = discordToken
@ -54,12 +52,9 @@ class DiscordClient {
/** @type {Map<string, Array<string>>} */
this.guildChannelMap = new Map()
if (listen !== "no") {
this.cloud.on("event", message => {
discordPackets.onPacket(this, message, listen)
})
this.cloud.on("event", message => discordPackets.onPacket(this, message, listen))
}
if (CONNECTION_DEBUG) {
const addEventLogger = (eventName, logName) => {
this.cloud.on(eventName, (...args) => {
const d = new Date().toISOString().slice(0, 19)
@ -70,7 +65,6 @@ class DiscordClient {
addEventLogger("disconnected", "Disconnected")
addEventLogger("ready", "Ready")
}
}
}
module.exports = DiscordClient

View file

@ -1,46 +1,18 @@
// @ts-check
const assert = require("assert").strict
const {scheduler} = require("timers/promises")
// Discord library internals type beat
const DiscordTypes = require("discord-api-types/v10")
const passthrough = require("../passthrough")
const {sync} = passthrough
/** @type {import("../matrix/homeserver-status")} */
const homeserverStatus = sync.require("../matrix/homeserver-status")
const guildReadyStatus = new class {
/** @type {Set<string> | null} */
unavailableGuilds = null
_allReady = Promise.withResolvers()
const {sync, db} = passthrough
const utils = {
/**
* @param {string} guildID
* @returns {boolean} true if it was the last one
*/
makeReady(guildID) {
assert(this.unavailableGuilds)
if (this.unavailableGuilds.delete(guildID) && this.unavailableGuilds.size === 0) {
this._allReady.resolve(null)
return true
}
return false
}
allReady() {
return this.unavailableGuilds && this.unavailableGuilds.size === 0
}
waitForAllReady() {
return this._allReady.promise
}
}
/**
* @param {import("./discord-client")} client
* @param {import("cloudstorm").IGatewayMessage} message
* @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
/** @type {typeof import("./event-dispatcher")} */
const eventDispatcher = sync.require("./event-dispatcher")
@ -53,31 +25,11 @@ async function onPacket(client, message, listen) {
client.ready = true
client.user = message.d.user
client.application = message.d.application
guildReadyStatus.unavailableGuilds = new Set(message.d.guilds.filter(g => g.unavailable).map(g => g.id))
console.log(`Discord logged in as ${client.user.username}#${client.user.discriminator} (${client.user.id})`)
process.stdout.write("Waiting for guilds to warm up... ")
interactions.registerInteractions()
} else if (message.t === "GUILD_CREATE") {
message.d.members = message.d.members.filter(m => m.user.id === client.user.id) // only keep the bot account's member - it's needed for roles 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
client.guilds.set(message.d.id, message.d)
/*
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.
The "all guilds ready" 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
*/
const firstReady = guildReadyStatus.allReady()
const lastGuildReady = guildReadyStatus.makeReady(message.d.id)
if (lastGuildReady) {
console.log(`ok, ${client.guilds.size} available.`)
}
const arr = []
client.guildChannelMap.set(message.d.id, arr)
for (const channel of message.d.channels || []) {
@ -95,25 +47,12 @@ async function onPacket(client, message, listen) {
if (listen === "full") {
try {
// Wait for guilds to be connected and homeserver to be online. If this is the last guild, a different code path is used to trigger the homeserver check.
if (lastGuildReady) {
process.stdout.write(`Connecting to homeserver... `)
// await guildReadyStatus.waitForAllReady() - no need, we already checked this is the last guild
await homeserverStatus.homeserverStatus.waitForOnline(true)
console.log("ok.\nReplaying past events. Welcome to Out Of Your Element.")
} else {
await guildReadyStatus.waitForAllReady()
await homeserverStatus.homeserverStatus.waitForOnline(false)
}
interactions.registerInteractions()
await eventDispatcher.checkMissedExpressions(message.d)
await eventDispatcher.checkMissedMessages(client, message.d)
await eventDispatcher.checkMissedPins(client, message.d)
await eventDispatcher.checkMissedLeaves(client, message.d)
await eventDispatcher.checkMissedMessages(client, message.d)
} catch (e) {
if (firstReady) {
console.error("Failed to sync missed events. To retry, please fix this error and restart OOYE:")
}
console.error(e)
}
}
@ -212,33 +151,6 @@ async function onPacket(client, message, listen) {
// Event dispatcher for OOYE bridge operations
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 {
if (message.t === "MESSAGE_REACTION_REMOVE" || message.t === "MESSAGE_REACTION_REMOVE_EMOJI" || message.t === "MESSAGE_REACTION_REMOVE_ALL") {
await eventDispatcher.onSomeReactionsRemoved(client, message.d)
@ -253,8 +165,8 @@ async function dispatchPacketToBridge(client, message) {
// Let OOYE try to handle errors too
await eventDispatcher.onError(client, e, message)
}
}
}
}
module.exports.onPacket = onPacket
module.exports.dispatchPacketToBridge = dispatchPacketToBridge
module.exports.guildReadyStatus = guildReadyStatus
module.exports = utils

View file

@ -2,7 +2,6 @@
const assert = require("assert").strict
const DiscordTypes = require("discord-api-types/v10")
const {id: botID} = require("../../addbot")
const {sync, db, select, from} = require("../passthrough")
/** @type {import("./actions/send-message")}) */
@ -33,14 +32,10 @@ const speedbump = sync.require("./actions/speedbump")
const retrigger = sync.require("./actions/retrigger")
/** @type {import("./actions/set-presence")} */
const setPresence = sync.require("./actions/set-presence")
/** @type {import("./actions/remove-member")} */
const removeMember = sync.require("./actions/remove-member")
/** @type {import("./actions/poll-vote")} */
const vote = sync.require("./actions/poll-vote")
/** @type {import("../m2d/event-dispatcher")} */
const matrixEventDispatcher = sync.require("../m2d/event-dispatcher")
/** @type {import("../m2d/actions/redact.js")} */
const redact = sync.require("../m2d/actions/redact.js")
/** @type {import("../discord/interactions/matrix-info")} */
const matrixInfoInteraction = sync.require("../discord/interactions/matrix-info")
@ -60,7 +55,7 @@ module.exports = {
matrixEventDispatcher.printError(gatewayMessage.t, "Discord", e, gatewayMessage)
const channelID = gatewayMessage.d?.["channel_id"]
const channelID = gatewayMessage.d["channel_id"]
if (!channelID) return
const roomID = select("channel_room", "room_id", {channel_id: channelID}).pluck().get()
if (!roomID) return
@ -105,9 +100,9 @@ module.exports = {
// console.log(`[check missed messages] in ${channel.id} (${guild.name} / ${channel.name}) because its last message ${channel.last_message_id} is not in the database`)
let messages
try {
messages = await client.snow.channel.getChannelMessages(channel.id, {limit: 100})
messages = await client.snow.channel.getChannelMessages(channel.id, {limit: 50})
} catch (e) {
if (e.message === "Missing Access") {
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})`)
continue // Sucks.
} else {
@ -128,7 +123,6 @@ module.exports = {
// Send in order
for (let i = Math.min(messages.length, latestBridgedMessageIndex)-1; i >= 0; 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))
await module.exports.MESSAGE_CREATE(client, {
@ -178,31 +172,6 @@ module.exports = {
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.
* 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 {DiscordTypes.GatewayChannelUpdateDispatchData} channelOrThread
@ -288,6 +249,7 @@ module.exports = {
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()
if (!roomID) return // channel wasn't being bridged in the first place
// @ts-ignore
await createRoom.unbridgeChannel(channel, guildID)
},
@ -297,7 +259,6 @@ module.exports = {
*/
async MESSAGE_CREATE(client, message) {
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)
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
const {skip} = await speedbump.maybeDoSpeedbump(true, message)
if (skip) return
const {affected, row} = await speedbump.maybeDoSpeedbump(message.channel_id, message.id)
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!
// 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)
if (skip) return
const {affected, row} = await speedbump.maybeDoSpeedbump(data.channel_id, data.id)
if (affected) return
if (!row) {
// Check that the sending-to room exists, and deal with Eventual Consistency(TM)
if (retrigger.eventNotFoundThenRetrigger(data.id, module.exports.MESSAGE_UPDATE, client, data)) return
}
/** @type {DiscordTypes.GatewayMessageCreateDispatchData} */
// @ts-ignore
const message = data
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.
const guild = client.guilds.get(channel.guild_id)
assert(guild)
if (!hasCreate) {
// Standard path for most message updates
// 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)
}
// @ts-ignore
await retrigger.pauseChanges(message.id, editMessage.editMessage(message, guild, row))
},
/**
@ -362,7 +321,6 @@ module.exports = {
* @param {DiscordTypes.GatewayMessageReactionAddDispatchData} 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.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"]
@ -381,16 +339,6 @@ module.exports = {
* @param {DiscordTypes.GatewayMessageReactionRemoveDispatchData | DiscordTypes.GatewayMessageReactionRemoveEmojiDispatchData | DiscordTypes.GatewayMessageReactionRemoveAllDispatchData} data
*/
async onSomeReactionsRemoved(client, data) {
// Don't attempt to double-bridge our own m2d deleted reactions back to Matrix
if ("user_id" in data && data.user_id === botID) {
const emojiIdOrName = data.emoji.id || data.emoji.name
const i = redact.m2dDeletedReactions.findIndex(x => data.message_id === x.messageID && emojiIdOrName === x.emojiIdOrName)
if (i !== -1) {
redact.m2dDeletedReactions.splice(i, 1)
return
}
}
await removeReaction.removeSomeReactions(data)
},
@ -400,7 +348,7 @@ module.exports = {
*/
async MESSAGE_DELETE(client, data) {
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)
},
@ -417,7 +365,6 @@ module.exports = {
* @param {DiscordTypes.GatewayTypingStartDispatchData} 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()
if (!roomID) return
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
*/
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)
},
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)
},

View file

@ -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;

View file

@ -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;

View file

@ -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 === "Unknown Webhook") {
// OK
} else {
throw e
}
})
db.prepare("DELETE FROM webhook WHERE channel_id = ?").run(webhook.channel_id)
process.stdout.write(String(counter).at(-1) === "0" ? tens[(counter/10)%10] : ones[counter%10])
}
process.stdout.write("\n")
}
}

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -1,5 +0,0 @@
BEGIN TRANSACTION;
ALTER TABLE channel_room ADD COLUMN room_mention_role TEXT;
COMMIT;

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

@ -1,10 +1,4 @@
export type Models = {
app_user_install: {
guild_id: string
app_bot_id: string
user_id: string
}
auto_emoji: {
name: string
emoji_id: string
@ -18,16 +12,11 @@ export type Models = {
thread_parent: string | null
custom_avatar: string | null
last_bridged_pin_timestamp: number | null
speedbump_id: string | null
speedbump_webhook_id: string | null
speedbump_checked: number | null
guild_id: string | null
custom_topic: number
room_mention_role: string | null
}
channel_speedbump: {
channel_id: string
speedbump_webhook_id: string
speedbump_user_id: string
}
direct: {
@ -49,7 +38,6 @@ export type Models = {
event_subtype: string | null
part: number
reaction_part: number
/** 0 = Matrix, 1 = Discord */
source: number
}
@ -116,16 +104,6 @@ export type Models = {
historical_room_index: number
}
opt_out: {
user_id: string
opted_out_at: number
}
role_default: {
guild_id: string
role_id: string
}
room_upgrade_pending: {
new_room_id: string
old_room_id: string
@ -148,8 +126,6 @@ export type Models = {
user_id: string
proxy_owner_id: string
displayname: string
/** 0 = PluralKit, 1 = /plu/ral */
proxy_app: number
}
webhook: {

View file

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

View file

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

View file

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

View file

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

View file

@ -55,10 +55,10 @@ async function* _interact({data, message, member, user}, {api}) {
title: "Poll",
components: [{
type: DiscordTypes.ComponentType.TextDisplay,
content: `## ${pollRow.question_text}`
content: `-# ${pollComponents.getMultiSelectString(pollRow.max_selections, options.length)}`
}, {
type: DiscordTypes.ComponentType.Label,
label: pollComponents.getMultiSelectString(pollRow.max_selections, options.length),
label: pollRow.question_text,
component: {
type: DiscordTypes.ComponentType.RadioGroup,
custom_id: "POLL_MODAL_SELECTION",

View file

@ -16,9 +16,9 @@ const utils = sync.require("../../matrix/utils")
* @returns {AsyncGenerator<{[k in keyof InteractionMethods]?: Parameters<InteractionMethods[k]>[2]}>}
*/
async function* _interact({data}, {api}) {
const events = 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()
if (!events.length) {
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}).get()
if (!row) {
return yield {createInteractionResponse: {
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
data: {
@ -35,23 +35,17 @@ async function* _interact({data}, {api}) {
}
}}
const reactions = []
for (const event of events) {
const reactionsOnEvent = await api.getFullRelations(event.room_id, event.event_id, "m.annotation")
reactions.push(...reactionsOnEvent)
}
const reactions = await api.getFullRelations(row.room_id, row.event_id, "m.annotation")
/** @type {Map<string, Set<string>>} reaction key (the emoji) -> displaynames */
/** @type {Map<string, string[]>} */
const inverted = new Map()
for (const reaction of reactions) {
if (utils.eventSenderIsFromDiscord(reaction.sender)) continue
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
if (!inverted.has(key)) {
inverted.set(key, new Set())
}
const displayname = select("member_cache", "displayname", {mxid: reaction.sender, room_id: row.room_id}).pluck().get() || reaction.sender
if (!inverted.has(key)) inverted.set(key, [])
// @ts-ignore
inverted.get(key).add(displayname)
inverted.get(key).push(displayname)
}
if (inverted.size === 0) {
@ -61,7 +55,7 @@ async function* _interact({data}, {api}) {
}
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"),
}}
}

View file

@ -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 => {
let called = 0
let called = 1
const msgs = await fromAsync(_interact({
data: {
target_id: "1126786462646550579"
@ -51,7 +51,6 @@ test("reactions: shows reactions if there are some, ignoring discord users", asy
}, {
api: {
async getFullRelations(roomID, eventID) {
called++
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
t.equal(eventID, "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg")
return [{
@ -98,57 +97,3 @@ test("reactions: shows reactions if there are some, ignoring discord users", asy
)
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)
})

View file

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

View file

@ -1,51 +0,0 @@
// @ts-check
const assert = require("assert").strict
/*
https://docs.discord.com/developers/reference#message-formatting
Trying to make these formats fairly culture independent since the Matrix text will show up the same for everyone.
That means no ambiguous short dates like 03/04/26. The shortest allowable date is "medium", which spells the month.
It also means no 24-hour time, since this is ambiguous to 12-hour time users. Times must have am/pm.
Relative time is not supported for text conversion and will fall back to the default, "f".
See the test file for sample outputs.
*/
/** @type {Map<string, Intl.DateTimeFormatOptions>} discord style letter abbreviation -> Intl.DateTimeFormat constructor options */
const styles = new Map([
["t", {timeStyle: "short", timeZone: "UTC"}],
["T", {timeStyle: "medium", timeZone: "UTC"}],
["d", {dateStyle: "medium", timeZone: "UTC"}],
["D", {dateStyle: "long", timeZone: "UTC"}],
["f", {dateStyle: "long", timeStyle: "short", timeZone: "UTC"}],
["F", {dateStyle: "full", timeStyle: "short", timeZone: "UTC"}],
["s", {dateStyle: "medium", timeStyle: "short", timeZone: "UTC"}],
["S", {dateStyle: "medium", timeStyle: "medium", timeZone: "UTC"}]
])
const defaultStyle = "f"
/**
* @param {string | null | undefined} [style]
* @returns {Intl.DateTimeFormatOptions} */
function getFormatOptions(style) {
if (!style || !styles.has(style)) style = defaultStyle
const formatOptions = styles.get(style)
assert(formatOptions)
return formatOptions
}
/**
* @param {number} timestamp unix time in seconds
* @param {string | null | undefined} [style] discord style letter abbreviation, if present
* @returns {string}
*/
function convertDiscordTime(timestamp, style) {
const formatter = new Intl.DateTimeFormat("en-NZ", getFormatOptions(style))
const d = new Date(timestamp * 1000)
return (formatter.format(d) + " (UTC)").replaceAll(/\p{Zs}/gv, " ")
}
module.exports.getFormatOptions = getFormatOptions
module.exports.convertDiscordTime = convertDiscordTime

View file

@ -1,48 +0,0 @@
// @ts-check
const time = require("./time")
const {test} = require("supertape")
const d = new Date("2026-02-14T14:23:06.000Z")
const seconds = d.getTime() / 1000
test("time: default style", t => {
t.equal(time.convertDiscordTime(seconds), "14 February 2026 at 2:23 pm (UTC)")
})
test("time: t style", t => {
t.equal(time.convertDiscordTime(seconds, "t"), "2:23 pm (UTC)")
})
test("time: T style", t => {
t.equal(time.convertDiscordTime(seconds, "T"), "2:23:06 pm (UTC)")
})
test("time: d style", t => {
t.equal(time.convertDiscordTime(seconds, "d"), "14 Feb 2026 (UTC)")
})
test("time: D style", t => {
t.equal(time.convertDiscordTime(seconds, "D"), "14 February 2026 (UTC)")
})
test("time: f style", t => {
t.equal(time.convertDiscordTime(seconds, "f"), "14 February 2026 at 2:23 pm (UTC)")
})
test("time: F style", t => {
t.equal(time.convertDiscordTime(seconds, "F"), "Saturday, 14 February 2026 at 2:23 pm (UTC)")
})
test("time: s style", t => {
t.equal(time.convertDiscordTime(seconds, "s"), "14 Feb 2026, 2:23 pm (UTC)")
})
test("time: S style", t => {
t.equal(time.convertDiscordTime(seconds, "S"), "14 Feb 2026, 2:23:06 pm (UTC)")
})
test("time: R style", t => {
// Discord would display this as relative time, but I don't want to do that in a static message.
t.equal(time.convertDiscordTime(seconds, "R"), "14 February 2026 at 2:23 pm (UTC)")
})

View file

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

View file

@ -1,53 +0,0 @@
/**
* This function is adapted from Evan Kaufman's fantastic work.
* The original function and my adapted function are both MIT licensed.
* @url https://github.com/EvanK/npm-loggable-error/
* @param {number} [depth]
* @returns {string}
*/
function stringifyErrorStack(err, depth = 0) {
let collapsed = " ".repeat(depth);
if (!(err instanceof Error)) {
return collapsed + err
}
// add full stack trace if one exists, otherwise convert to string
let stackLines = String(err?.stack ?? err).replace(/^/gm, " ".repeat(depth)).trim().split("\n")
let cloudstormLine = stackLines.findIndex(l => l.includes("/node_modules/cloudstorm/"))
if (cloudstormLine !== -1) {
stackLines = stackLines.slice(0, cloudstormLine - 2)
}
collapsed += stackLines.join("\n")
const props = Object.getOwnPropertyNames(err).filter(p => !["message", "stack"].includes(p))
// only break into object notation if we have additional props to dump
if (props.length) {
const dedent = " ".repeat(depth);
const indent = " ".repeat(depth + 2);
collapsed += " {\n";
// loop and print each (indented) prop name
for (let property of props) {
collapsed += `${indent}[${property}]: `;
// if another error object, stringify it too
if (err[property] instanceof Error) {
collapsed += stringifyErrorStack(err[property], depth + 2).trimStart();
}
// otherwise stringify as JSON
else {
collapsed += JSON.stringify(err[property]);
}
collapsed += "\n";
}
collapsed += `${dedent}}\n`;
}
return collapsed;
}
module.exports.stringifyErrorStack = stringifyErrorStack

View file

@ -1,23 +0,0 @@
// @ts-check
const {test} = require("supertape")
const {stringifyErrorStack} = require("./errors")
test("stringify error stack: works", t => {
function a() {
const e = new Error("message", {cause: new Error("inner")})
// @ts-ignore
e.prop = 2.1
throw e
}
try {
a()
t.fail("shouldn't get here")
} catch (e) {
const str = stringifyErrorStack(e)
t.match(str, /^Error: message$/m)
t.match(str, /^ at a \(.*errors\.test\.js/m)
t.match(str, /^ \[cause\]: Error: inner$/m)
t.match(str, /^ \[prop\]: 2.1$/m)
}
})

View file

@ -17,7 +17,7 @@ const retrigger = sync.require("../../d2m/actions/retrigger")
*/
async function addReaction(event) {
// Wait until the corresponding channel and message have already been bridged
if (!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
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)
retrigger.finishedBridging(event.event_id)
}
module.exports.addReaction = addReaction

View file

@ -45,7 +45,7 @@ async function ensureWebhook(channelID, forceCreate = false) {
async function withWebhook(channelID, callback) {
const webhook = await ensureWebhook(channelID, false)
return callback(webhook).catch(async e => {
if (e.message === "Unknown Webhook") {
if (e.message === `{"message": "Unknown Webhook", "code": 10015}`) { // pathetic error handling from SnowTransfer
// Our webhook is gone. Maybe somebody deleted it, or removed and re-added OOYE from the guild.
const newWebhook = await ensureWebhook(channelID, true)
return callback(newWebhook) // not caught; if the error happens again just throw it instead of looping

View file

@ -23,7 +23,7 @@ async function getAndConvertEmoji(mxc) {
const res = await api.getMedia(mxc, {signal: abortController.signal})
if (res.status !== 200) {
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)
return emojiSheetConverter.convertImageStream(readable, () => {

View file

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

View file

@ -153,7 +153,7 @@ async function sendEvent(event) {
channel_id: messageResponse.channel_id,
guild_id: guild.id,
embeds: messageResponse.embeds
}, guild)
}, guild, null)
)
}
}

View file

@ -9,7 +9,7 @@ const sharp = require("sharp")
const api = sync.require("../../matrix/api")
/** @type {import("../../matrix/mreq")} */
const mreq = sync.require("../../matrix/mreq")
const {streamType} = require("@cloudrac3r/stream-type")
const streamMimeType = require("stream-mime-type")
const WIDTH = 160
const HEIGHT = 160
@ -22,17 +22,17 @@ async function getAndResizeSticker(mxc) {
const res = await api.getMedia(mxc)
if (res.status !== 200) {
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 {streamThrough, type} = await streamType(streamIn)
const animated = ["image/gif", "image/webp"].includes(type)
const { stream, mime } = await streamMimeType.getMimeType(streamIn)
const animated = ["image/gif", "image/webp"].includes(mime)
const transformer = sharp({animated: animated})
.resize(WIDTH, HEIGHT, {fit: "inside", background: {r: 0, g: 0, b: 0, alpha: 0}})
.webp()
streamThrough.pipe(transformer)
stream.pipe(transformer)
return Readable.toWeb(transformer)
}

View file

@ -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

View file

@ -13,12 +13,12 @@ async function updatePins(pins, prev) {
const diff = diffPins.diffPins(pins, prev)
for (const [event_id, added] of diff) {
const row = from("event_message").join("message_room", "message_id").join("historical_channel_room", "historical_room_index")
.select("reference_channel_id", "message_id").where({event_id}).and("ORDER BY part ASC").get()
.select("reference_channel_id", "message_id").get()
if (!row) continue
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 {
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")
}
}
}

View file

@ -1,12 +1,18 @@
// @ts-check
const Ty = require("../../types")
const DiscordTypes = require("discord-api-types/v10")
const {Readable} = require("stream")
const assert = require("assert").strict
const crypto = require("crypto")
const passthrough = require("../../passthrough")
const {sync, db, select} = passthrough
const {sync, discord, db, select} = passthrough
/** @type {import("../../discord/utils")} */
const dUtils = sync.require("../../discord/utils")
const {reg} = require("../../matrix/read-registration")
/** @type {import("../../matrix/api")} */
const api = sync.require("../../matrix/api")
/** @type {import("../../matrix/utils")} */
const utils = sync.require("../../matrix/utils")
/** @type {import("../converters/poll-components")} */
const pollComponents = sync.require("../converters/poll-components")
/** @type {import("./channel-webhook")} */
@ -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 (messageRow.source === 0) {
const row = select("channel_room", ["channel_id", "thread_parent"], {room_id: event.room_id}).get()
assert(row)
const {channelID, threadID} = dUtils.swapThreadID(row.channel_id, row.thread_parent)
await webhook.editMessageWithWebhook(channelID, messageID, pollComponents.getPollComponentsFromDatabase(messageID), threadID)
const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get()
assert(channelID)
await webhook.editMessageWithWebhook(channelID, messageID, pollComponents.getPollComponentsFromDatabase(messageID))
}
}

View file

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

View file

@ -10,7 +10,6 @@ const domino = require("domino")
const assert = require("assert").strict
const entities = require("entities")
const pb = require("prettier-bytes")
const mimeTypes = require("mime-types")
const {tag} = require("@cloudrac3r/html-template-tag")
const passthrough = require("../../passthrough")
@ -30,10 +29,6 @@ const pollComponents = sync.require("./poll-components")
const setupEmojis = sync.require("../actions/setup-emojis")
/** @type {import("../../d2m/converters/user-to-mxid")} */
const userToMxid = sync.require("../../d2m/converters/user-to-mxid")
/** @type {import("../../web/routes/letter-avatar")} */
const letterAvatar = sync.require("../../web/routes/letter-avatar")
/** @type {import("../../d2m/actions/retrigger")} */
const retrigger = sync.require("../../d2m/actions/retrigger")
/** @type {[RegExp, string][]} */
const markdownEscapes = [
@ -100,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", {
filter: function (node, options) {
return node.tagName === "SPAN" && node.hasAttribute("data-mx-spoiler")
@ -386,7 +390,7 @@ function linkEndOfMessageSpriteSheet(content) {
*/
async function handleRoomOrMessageLinks(input, di) {
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")
let [_, attributeValue, roomID, eventID, endMarker] = match
let result
@ -452,24 +456,22 @@ async function handleRoomOrMessageLinks(input, di) {
/**
* @param {string} content
* @param {{content: any, room_id: string, sender: string}} event
* @param {string} senderMxid
* @param {string} roomID
* @param {DiscordTypes.APIGuild} guild
* @param {{api: import("../../matrix/api"), snow: import("snowtransfer").SnowTransfer}} di
*/
async function checkWrittenMentions(content, event, guild, di) {
async function checkWrittenMentions(content, senderMxid, roomID, guild, di) {
let writtenMentionMatch = content.match(/(?:^|[^"[<>/A-Za-z0-9])@([A-Za-z][A-Za-z0-9._\[\]\(\)-]+):?/d) // /d flag for indices requires node.js 16+
if (writtenMentionMatch) {
if (writtenMentionMatch[1] === "room") { // convert @room to discord
if (("m.mentions" in event.content) && !event.content["m.mentions"].room) return // check it's allowed by m.mentions if present
const {powers: {[event.sender]: userPower}, powerLevels} = await mxUtils.getEffectivePower(event.room_id, [event.sender], di.api)
if (writtenMentionMatch[1] === "room") { // convert @room to @everyone
const {powers: {[senderMxid]: userPower}, powerLevels} = await mxUtils.getEffectivePower(roomID, [senderMxid], di.api)
if (userPower >= (powerLevels.notifications?.room ?? 50)) {
const roleID = select("channel_room", "room_mention_role", {room_id: event.room_id}).pluck().get()
return {
// @ts-ignore - typescript doesn't know about indices yet
content: content.slice(0, writtenMentionMatch.indices[1][0]-1) + (roleID ? `<@&${roleID}>` : "@everyone") + content.slice(writtenMentionMatch.indices[1][1]),
content: content.slice(0, writtenMentionMatch.indices[1][0]-1) + `@everyone` + content.slice(writtenMentionMatch.indices[1][1]),
ensureJoined: [],
allowedMentionsParse: roleID ? [] : ["everyone"],
allowedMentionsUsers: []
allowedMentionsParse: ["everyone"]
}
}
} 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
@ -480,8 +482,7 @@ async function checkWrittenMentions(content, event, guild, di) {
// @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]),
ensureJoined: [results[0].user],
allowedMentionsParse: [],
allowedMentionsUsers: [results[0].user.id]
allowedMentionsParse: []
}
}
}
@ -512,7 +513,7 @@ function getFileSizeForGuild(guild) {
if (fileSizeFeature) {
return fileSizeFeature * 1024 * 1024 // discord uses big megabytes
} else {
return 20 * 1024 * 1024 // default file size is 20 MB
return 10 * 1024 * 1024 // default file size is 10 MB
}
}
@ -543,34 +544,16 @@ async function eventToMessage(event, guild, channel, di) {
let displayName = event.sender
let avatarURL = undefined
const allowedMentionsParse = ["users", "roles"]
const allowedMentionsUsers = []
/** @type {string[]} */
let messageIDsToEdit = []
let replyLine = ""
// Extract a basic display name from the sender
const match = event.sender.match(/^@(.*?):/)
if (match) displayName = match[1]
// 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)
if (member.displayname) displayName = member.displayname
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),
// put the excess characters into displayNameRunoff, later to be put at the top of the message
let [displayNameShortened, displayNameRunoff] = splitDisplayName(displayName)
@ -579,13 +562,6 @@ async function eventToMessage(event, guild, channel, di) {
displayNameRunoff = ""
}
// Avatar post-processing. Use a thumbnail for media, or generate letter avatar if none present.
if (avatarURL) {
avatarURL = avatarURL + "?preset=avatar"
} else {
avatarURL = letterAvatar.getLetterAvatarURL(event.sender, displayNameShortened)
}
let content = event.content["body"] || "" // ultimate fallback
/** @type {{id: string, filename: string}[]} */
const attachments = []
@ -617,20 +593,13 @@ async function eventToMessage(event, guild, channel, di) {
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)
assert(extension, "Filename and mimetype missing. Please report this bug to your client.")
filename = `file.${extension}`
}
// File link as alternative to uploading
if (!("file" in event.content) && event.content.info?.size > getFileSizeForGuild(guild)) {
// Upload (unencrypted) file as link, because it's too large for Discord
// Do this by constructing a sample Matrix message with the link and then use the text processor to convert that + the original caption.
const url = mxUtils.getPublicUrlForMxc(event.content.url)
assert(url)
const filename = event.content.filename || event.content.body
const emoji = attachmentEmojis.has(event.content.msgtype) ? attachmentEmojis.get(event.content.msgtype) + " " : ""
if (fileIsSpoiler) {
captionContent.addLine(`${emoji}Uploaded SPOILER file: <${url}> (${pb(event.content.info.size)})`, tag`${emoji}<em>Uploaded <strong>SPOILER</strong> file: <span data-mx-spoiler><a href="${url} ">${filename}</a></span> (${pb(event.content.info.size)})</em>`) // the space is necessary to work around a bug in Discord's URL previewer. the preview still gets blurred in the client.
@ -639,6 +608,7 @@ async function eventToMessage(event, guild, channel, di) {
}
} else {
// Upload file as file
let filename = event.content.filename || event.content.body
if (fileIsSpoiler) filename = "SPOILER_" + filename
if ("file" in event.content) {
// Encrypted
@ -699,7 +669,6 @@ async function eventToMessage(event, guild, channel, di) {
// Check if we have a pointer to what was edited
const originalEventId = relatesTo.event_id
if (!originalEventId) return
if (!await retrigger.waitForEvent(originalEventId)) return
messageIDsToEdit = select("event_message", "message_id", {event_id: originalEventId}, "ORDER BY part").pluck().all()
if (!messageIDsToEdit.length) return
@ -777,14 +746,14 @@ async function eventToMessage(event, guild, channel, di) {
repliedToEvent.content = repliedToEvent.content["m.new_content"]
}
/** @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)
let contentPreview
if (fileReplyContentAlternative) {
contentPreview = " " + fileReplyContentAlternative
} else if (repliedToEvent.unsigned?.redacted_because) {
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...
// I'm not implementing text fallbacks for arbitrary room events. this should cover most cases
// this has never ever happened in the wild anyway
@ -792,10 +761,9 @@ async function eventToMessage(event, guild, channel, di) {
contentPreview = " (channel details edited)"
} else {
// 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(/^\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(/<img([^>]*)>/g, (_, att) => { // Convert Matrix emoji images into Discord emoji markdown
const mxcUrlMatch = att.match(/\bsrc="(mxc:\/\/[^"]+)"/)
@ -807,18 +775,7 @@ async function eventToMessage(event, guild, channel, di) {
const contentPreviewChunks = chunk(repliedToContent, 50)
if (contentPreviewChunks.length) {
contentPreview = ": " + contentPreviewChunks[0]
contentPreview = contentPreview.replace(/\bhttps?:\/\/[^ )]*/g, url => {
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
})
contentPreview = contentPreview.replace(/\bhttps?:\/\/[^ )]*/g, "<$&>")
if (contentPreviewChunks.length > 1) contentPreview = contentPreview.replace(/[,.']$/, "") + "..."
} else {
contentPreview = ""
@ -844,7 +801,7 @@ async function eventToMessage(event, guild, channel, di) {
}
// Handling mentions of Discord users
input = input.replace(/("https:\/\/matrix.to\/#\/((?:@|%40)[^"]+)")/g, (whole, attributeValue, mxid) => {
input = input.replace(/("https:\/\/matrix.to\/#\/((?:@|%40)[^"]+)")>/g, (whole, attributeValue, mxid) => {
mxid = decodeURIComponent(mxid)
if (mxUtils.eventSenderIsFromDiscord(mxid)) {
// Handle mention of an OOYE sim user by their mxid
@ -861,8 +818,6 @@ async function eventToMessage(event, guild, channel, di) {
}
})
input = input.replace(/<a\s[^>]*href="https:\/\/matrix\.to\/#\/(?:!|%21|#|%23)[^"\/]*"[^>]*>@room<\/a>/g, "@room")
// Handling mentions of rooms and room-messages
input = await handleRoomOrMessageLinks(input, di)
@ -871,7 +826,7 @@ async function eventToMessage(event, guild, channel, di) {
input = input.replace(/("https:\/\/matrix.to.*?<\/a>):?/g, "$1")
// Element adds a bunch of <br> before </blockquote> but doesn't render them. I can't figure out how this even works in the browser, so let's just delete those.
input = input.replace(/(?:\n|<br ?\/?>\s*)*<\/blockquote>(?:\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.
// But I should not count it if it's between block elements.
@ -901,20 +856,13 @@ async function eventToMessage(event, guild, channel, di) {
const doc = domino.createDocument(
// DOM parsers arrange elements in the <head> and <body>. Wrapping in a custom element ensures elements are reliably arranged in a single element.
'<x-turndown id="turndown-root">' + input + '</x-turndown>'
)
const root = doc.getElementById("turndown-root")
assert(root)
);
const root = doc.getElementById("turndown-root");
async function forEachNode(event, node) {
for (; node; node = node.nextSibling) {
// Don't process mx-reply further
if (node.nodeType === 1 && node.tagName === "MX-REPLY") {
node.remove()
continue
}
// Check written mentions
if (node.nodeType === 3 && node.nodeValue.includes("@") && !nodeIsChildOf(node, ["A", "CODE", "PRE"])) {
const result = await checkWrittenMentions(node.nodeValue, event, guild, di)
const result = await checkWrittenMentions(node.nodeValue, event.sender, event.room_id, guild, di)
if (result) {
node.nodeValue = result.content
ensureJoined.push(...result.ensureJoined)
@ -923,12 +871,9 @@ async function eventToMessage(event, guild, channel, di) {
}
// Check for incompatible backticks in code blocks
let preNode
let isBackticksTextInPre = node.nodeType === 3 && node.nodeValue.includes("```") && (preNode = nodeIsChildOf(node, ["PRE"]))
let isLongPre = node.tagName === "PRE" && node.textContent.length > 1800 && (preNode = node)
if (isBackticksTextInPre || isLongPre) {
if (node.nodeType === 3 && node.nodeValue.includes("```") && (preNode = nodeIsChildOf(node, ["PRE"]))) {
if (preNode.firstChild?.nodeName === "CODE") {
let ext = preNode.firstChild.className.match(/language-(\S+)/)?.[1]
if (!dUtils.supportedPlaintextPreviewExtensions.has(ext)) ext = "txt"
const ext = preNode.firstChild.className.match(/language-(\S+)/)?.[1] || "txt"
const filename = `inline_code.${ext}`
// Build the replacement <code> node
const replacementCode = doc.createElement("code")
@ -953,7 +898,7 @@ async function eventToMessage(event, guild, channel, di) {
let shouldSuppress = inBody !== -1 && event.content.body[inBody-1] === "<"
if (!shouldSuppress && guild?.roles) {
// 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)
shouldSuppress = !canEmbedLinks
}
@ -965,7 +910,6 @@ async function eventToMessage(event, guild, channel, di) {
}
}
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.
// First we need to determine which emojis are at the end.
@ -997,10 +941,6 @@ async function eventToMessage(event, guild, channel, di) {
} else {
// Looks like we're using the plaintext 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") {
content = `* ${displayName} ${content}`
@ -1021,7 +961,7 @@ async function eventToMessage(event, guild, channel, di) {
// Suppress if regular users don't have permission
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)
shouldSuppress = !canEmbedLinks
}
@ -1032,7 +972,7 @@ async function eventToMessage(event, guild, channel, di) {
}
}
const result = await checkWrittenMentions(content, event, guild, di)
const result = await checkWrittenMentions(content, event.sender, event.room_id, guild, di)
if (result) {
content = result.content
ensureJoined.push(...result.ensureJoined)
@ -1046,34 +986,16 @@ async function eventToMessage(event, guild, channel, di) {
}
}
// Complete content
content = displayNameRunoff + replyLine + content
// Split into 2000 character chunks
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) {
let user_id = select("sim", "user_id", {mxid}).pluck().get()
if (!user_id) continue // only for discord users
user_id = select("sim_proxy", "proxy_owner_id", {user_id}).pluck().get() || user_id // convert to sim proxy owner's ID
if (!user_id.match(/^[0-9]+$/)) continue // mentioned sim does not have a real discord user ID (i.e. it's a webhook)
allowedMentionsUsers.push(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}[]})[]} */
const messages = chunks.map(content => ({
content,
allowed_mentions,
allowed_mentions: {
parse: allowedMentionsParse
},
username: displayNameShortened,
avatar_url: avatarURL
}))

File diff suppressed because it is too large Load diff

View file

@ -34,7 +34,6 @@ function optionsToComponents(isClosed, pollOptions) {
/** @type {DiscordTypes.APIMessageTopLevelComponent[]} */
return pollOptions.map(option => {
const medal = getMedal(topAnswers, option.count)
const label = option.count === 1 ? `${option.count} vote\u2002\u200b` : `${option.count} votes`
return {
type: DiscordTypes.ComponentType.Container,
components: [{
@ -46,7 +45,7 @@ function optionsToComponents(isClosed, pollOptions) {
accessory: {
type: DiscordTypes.ComponentType.Button,
style: medal === "🥇" && isClosed ? DiscordTypes.ButtonStyle.Success : DiscordTypes.ButtonStyle.Secondary,
label: label,
label: option.count.toString(),
custom_id: `POLL_OPTION#${option.matrix_option}`,
disabled: isClosed
}

View file

@ -24,8 +24,6 @@ const vote = sync.require("./actions/vote")
const matrixCommandHandler = sync.require("../matrix/matrix-command-handler")
/** @type {import("../matrix/utils")} */
const utils = sync.require("../matrix/utils")
/** @type {import("../matrix/mreq")}) */
const mreq = sync.require("../matrix/mreq")
/** @type {import("../matrix/api")}) */
const api = sync.require("../matrix/api")
/** @type {import("../d2m/actions/create-room")} */
@ -34,27 +32,68 @@ const createRoom = sync.require("../d2m/actions/create-room")
const roomUpgrade = require("../matrix/room-upgrade")
/** @type {import("../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")
/** @type {import("../js/errors")} */
const errors = sync.require("../js/errors")
const {reg} = require("../matrix/read-registration")
let lastReportedEvent = 0
/**
* This function is adapted from Evan Kaufman's fantastic work.
* The original function and my adapted function are both MIT licensed.
* @url https://github.com/EvanK/npm-loggable-error/
* @param {number} [depth]
* @returns {string}
*/
function stringifyErrorStack(err, depth = 0) {
let collapsed = " ".repeat(depth);
if (!(err instanceof Error)) {
return collapsed + err
}
// add full stack trace if one exists, otherwise convert to string
let stackLines = String(err?.stack ?? err).replace(/^/gm, " ".repeat(depth)).trim().split("\n")
let cloudstormLine = stackLines.findIndex(l => l.includes("/node_modules/cloudstorm/"))
if (cloudstormLine !== -1) {
stackLines = stackLines.slice(0, cloudstormLine - 2)
}
collapsed += stackLines.join("\n")
const props = Object.getOwnPropertyNames(err).filter(p => !["message", "stack"].includes(p))
// only break into object notation if we have additional props to dump
if (props.length) {
const dedent = " ".repeat(depth);
const indent = " ".repeat(depth + 2);
collapsed += " {\n";
// loop and print each (indented) prop name
for (let property of props) {
collapsed += `${indent}[${property}]: `;
// if another error object, stringify it too
if (err[property] instanceof Error) {
collapsed += stringifyErrorStack(err[property], depth + 2).trimStart();
}
// otherwise stringify as JSON
else {
collapsed += JSON.stringify(err[property]);
}
collapsed += "\n";
}
collapsed += `${dedent}}\n`;
}
return collapsed;
}
function printError(type, source, e, payload) {
console.error(`Error while processing a ${type} ${source} event:`)
console.error(e)
console.dir(payload, {depth: null})
}
/** @param {string} stack */
function cleanErrorStack(stack) {
return stack.replace(/(\/webhooks\/[0-9]+\/)[a-zA-Z0-9_-]+/g, "$1(redacted)")
}
/**
* @param {string} roomID
* @param {"Discord" | "Matrix"} source
@ -95,7 +134,7 @@ async function sendError(roomID, source, type, e, payload) {
builder.addLine(errorIntroLine)
// Where
const stack = cleanErrorStack(errors.stringifyErrorStack(e))
const stack = stringifyErrorStack(e)
builder.addLine(`Error trace:\n${stack}`, tag`<details><summary>Error trace</summary><pre>${stack}</pre></details>`)
// How
@ -104,7 +143,7 @@ async function sendError(roomID, source, type, e, payload) {
// Send
try {
const errorEventID = await api.sendEvent(roomID, "m.room.message", {
await api.sendEvent(roomID, "m.room.message", {
...builder.get(),
"moe.cadence.ooye.error": {
source: source.toLowerCase(),
@ -114,23 +153,7 @@ async function sendError(roomID, source, type, e, payload) {
user_ids: ["@cadence:cadence.moe"]
}
})
// Add reaction indicating that errors may be retried
await api.sendEvent(roomID, "m.reaction", {
"m.relates_to": {
rel_type: "m.annotation",
event_id: errorEventID,
key: "🔁"
}
})
} catch (e) {
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)
}
}
} catch (e) {}
}
function guard(type, fn) {
@ -149,7 +172,6 @@ const errorRetrySema = new Semaphore()
* @param {Ty.Event.Outer<Ty.Event.M_Reaction>} reactionEvent
*/
async function onRetryReactionAdd(reactionEvent) {
if (reactionEvent.sender === `@${reg.sender_localpart}:${reg.ooye.server_name}`) return // Don't respond to the bot's own indicative reaction
const roomID = reactionEvent.room_id
await errorRetrySema.request(async () => {
const event = await api.getEvent(roomID, reactionEvent.content["m.relates_to"]?.event_id)
@ -189,7 +211,7 @@ async event => {
// @ts-ignore
await matrixCommandHandler.execute(event)
}
retrigger.finishedBridging(event.event_id)
retrigger.messageFinishedBridging(event.event_id)
await api.ackEvent(event)
}))
@ -200,7 +222,7 @@ sync.addTemporaryListener(as, "type:m.sticker", guard("m.sticker",
async event => {
if (utils.eventSenderIsFromDiscord(event.sender)) return
const messageResponses = await sendEvent.sendEvent(event)
retrigger.finishedBridging(event.event_id)
retrigger.messageFinishedBridging(event.event_id)
await api.ackEvent(event)
}))
@ -391,7 +413,6 @@ async event => {
console.error(e)
return await api.leaveRoomWithReason(event.room_id, `I wasn't able to find out what this room is. Please report this as a bug. Check console for more details. (${e.toString()})`)
}
if (inviteRoomState?.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.`)
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)
@ -401,10 +422,7 @@ async event => {
if (event.content.membership === "leave" || event.content.membership === "ban") {
// 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)
// 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
if (event.state_key === utils.bot) {
@ -465,34 +483,6 @@ async event => {
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.cleanErrorStack = cleanErrorStack
module.exports.stringifyErrorStack = stringifyErrorStack
module.exports.sendError = sendError
module.exports.printError = printError

View file

@ -1,31 +1,23 @@
// @ts-check
const {test} = require("supertape")
const {cleanErrorStack} = require("./event-dispatcher")
const {stringifyErrorStack} = require("./event-dispatcher")
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/
)
test("stringify error stack: works", t => {
function a() {
const e = new Error("message", {cause: new Error("inner")})
// @ts-ignore
e.prop = 2.1
throw e
}
try {
a()
t.fail("shouldn't get here")
} catch (e) {
const str = stringifyErrorStack(e)
t.match(str, /^Error: message$/m)
t.match(str, /^ at a \(.*event-dispatcher\.test\.js/m)
t.match(str, /^ \[cause\]: Error: inner$/m)
t.match(str, /^ \[prop\]: 2.1$/m)
}
})

View file

@ -172,7 +172,7 @@ function getStateEventOuter(roomID, type, key) {
/**
* @param {string} roomID
* @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) {
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"),
topic: getFromInviteRoomState(event.unsigned.invite_room_state, "m.room.topic", "topic"),
avatar: getFromInviteRoomState(event.unsigned.invite_room_state, "m.room.avatar", "url"),
type: getFromInviteRoomState(event.unsigned.invite_room_state, "m.room.create", "type"),
encryption: getFromInviteRoomState(event.unsigned.invite_room_state, "m.room.encryption", "algorithm")
type: getFromInviteRoomState(event.unsigned.invite_room_state, "m.room.create", "type")
}
}
@ -228,8 +227,7 @@ async function getInviteState(roomID, event) {
name: getFromInviteRoomState(strippedState, "m.room.name", "name"),
topic: getFromInviteRoomState(strippedState, "m.room.topic", "topic"),
avatar: getFromInviteRoomState(strippedState, "m.room.avatar", "url"),
type: getFromInviteRoomState(strippedState, "m.room.create", "type"),
encryption: getFromInviteRoomState(strippedState, "m.room.encryption", "algorithm")
type: getFromInviteRoomState(strippedState, "m.room.create", "type")
}
}
} catch (e) {}
@ -242,8 +240,7 @@ async function getInviteState(roomID, event) {
name: room.name ?? null,
topic: room.topic ?? null,
avatar: room.avatar_url ?? null,
type: room.room_type ?? null,
encryption: (room.encryption || room["im.nheko.summary.encryption"]) ?? null
type: room.room_type ?? null
}
}
@ -452,8 +449,7 @@ async function ping() {
headers: {
Authorization: `Bearer ${reg.as_token}`
},
body: "{}",
signal: AbortSignal.timeout(15e3)
body: "{}"
})
const root = await res.json()
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.
*
* 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
* Given an mxc:// URL, and an optional height for thumbnailing, get the file from the content repository. Returns res.
* @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>}>}
*/
async function getMedia(mxc, init = {}) {
init = {...init}
const mediaParts = mxc?.match(/^mxc:\/\/([^/]+)\/([a-zA-Z0-9_-]+)$/)
const mediaParts = mxc?.match(/^mxc:\/\/([^/]+)\/(\w+)$/)
assert(mediaParts)
let route = "download"
let query = ""
if (init.thumbnail) {
route = "thumbnail"
query = "?" + new URLSearchParams(Object.keys(init.thumbnail).map(k => [k, String(init.thumbnail?.[k])]))
}
let url = `${mreq.baseUrl}/client/v1/media/${route}/${mediaParts[1]}/${mediaParts[2]}${query}`
const downloadOrThumbnail = init.height ? "thumbnail" : "download"
let url = `${mreq.baseUrl}/client/v1/media/${downloadOrThumbnail}/${mediaParts[1]}/${mediaParts[2]}`
if (init.height) url += "?" + new URLSearchParams({height: String(init.height), width: String(init.height)})
const res = await fetch(url, {
headers: {
Authorization: `Bearer ${reg.as_token}`

View file

@ -15,15 +15,10 @@ const IMAGE_SIZE = 1024
const inflight = new Map()
/**
* @param {string} urlString
* @param {string} url
*/
function _removeExpiryParams(urlString) {
const url = new URL(urlString)
url.searchParams.delete("ex")
url.searchParams.delete("is")
url.searchParams.delete("sg")
url.searchParams.delete("hm")
return url.toString()
function _removeExpiryParams(url) {
return url.replace(/\?(?:(?:ex|is|sg|hm)=[a-f0-9]+&?)*$/, "")
}
/**
@ -90,7 +85,6 @@ async function _actuallyUploadDiscordFileToMxc(url) {
writeRegistration(reg)
return root
}
e.uploadURL = url
throw e
}
}

View file

@ -20,9 +20,3 @@ test("removeExpiryParams: rearranged params are removed", t => {
const result = file._removeExpiryParams(url)
t.equal(result, "https://cdn.discordapp.com/attachments/112760669178241024/1157363960518029322/image.png")
})
test("removeExpiryParams: works on media proxy and keeps quality", t => {
const url = "https://media.discordapp.net/attachments/1160894080998461480/1548665030898094240/855064e9-bdae-487a-a497-c662bd510487.png?ex=6aa7e234&is=6aa690b4&hm=7c153efcdd1feb1d36d0bb99955a2bfe12f26abea377fed02980521766f51620&format=jpeg"
const result = file._removeExpiryParams(url)
t.equal(result, "https://media.discordapp.net/attachments/1160894080998461480/1548665030898094240/855064e9-bdae-487a-a497-c662bd510487.png?format=jpeg")
})

View file

@ -1,146 +0,0 @@
// @ts-check
const assert = require("assert").strict
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.checkingWhileOnline = true
/** @private */
this.lastCheck = 0
/** @private */
this.sm = new StateMachine("online")
.defineState("online")
.defineState("checking", {
onEnter: [async (event) => {
this.checkingWhileOnline = event === "check while online"
const pingResult = await api.ping().catch(e => ({ok: false}))
this.lastCheck = Date.now()
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}))
this.lastCheck = Date.now()
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 assume online", "checking")
.defineTransition("online", "check assume offline", "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" || (this.sm.currentStateName === "checking" && this.checkingWhileOnline)
}
/** @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 assume offline")
} else if (this.lastCheck < Date.now() - 15e3) {
this.sm.doTransition("check assume online")
} 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

View file

@ -1,11 +1,9 @@
// @ts-check
const assert = require("assert").strict
const DiscordTypes = require("discord-api-types/v10")
const Ty = require("../types")
const {pipeline} = require("stream").promises
const sharp = require("sharp")
const tw = require("@cloudrac3r/tell-me-when")
const {tag} = require("@cloudrac3r/html-template-tag")
const {discord, sync, db, select} = require("../passthrough")
/** @type {import("./api")}) */
@ -14,9 +12,8 @@ const api = sync.require("./api")
const mxUtils = sync.require("./utils")
/** @type {import("../discord/utils")} */
const dUtils = sync.require("../discord/utils")
/** @type {import("../d2m/actions/retrigger")} */
const retrigger = sync.require("../d2m/actions/retrigger")
/** @type {import("./kstate")} */
const ks = sync.require("./kstate")
const {reg} = require("./read-registration")
const PREFIXES = ["//", "/"]
@ -35,17 +32,7 @@ function getSlotCount(tier) {
return TIER_EMOJI_SLOTS.get(tier) || 50
}
/**
* @typedef Button
* @property {string} roomID
* @property {string} eventID
* @property {string} mxid
* @property {string} key
* @property {(any) => any} resolve
* @property {number} created
*/
const buttonStore = sync.remember(() => /** @type {{buttons: Button[]}} */ ({buttons: []}))
let buttons = []
/**
* @param {string} roomID where to add the button
@ -63,23 +50,22 @@ async function addButton(roomID, eventID, key, mxid) {
}
})
return new Promise(resolve => {
buttonStore.buttons.push({roomID, eventID, mxid, key, resolve, created: Date.now()})
buttons.push({roomID, eventID, mxid, key, resolve, created: Date.now()})
})
}
// Clear out old buttons every so often to free memory
setInterval(() => {
const now = Date.now()
buttonStore.buttons = buttonStore.buttons.filter(b => now - b.created < 2*60*60*1000)
buttons = buttons.filter(b => now - b.created < 2*60*60*1000)
}, 10*60*1000).unref()
/** @param {Ty.Event.Outer<Ty.Event.M_Reaction>} event */
function onReactionAdd(event) {
const button = buttonStore.buttons.find(b => b.roomID === event.room_id && b.mxid === event.sender && b.eventID === event.content["m.relates_to"]?.event_id && b.key === event.content["m.relates_to"]?.key)
const button = buttons.find(b => b.roomID === event.room_id && b.mxid === event.sender && b.eventID === event.content["m.relates_to"]?.event_id && b.key === event.content["m.relates_to"]?.key)
if (button) {
buttonStore.buttons = buttonStore.buttons.filter(b => b !== button) // remove button data so it can't be clicked again
buttons = buttons.filter(b => b !== button) // remove button data so it can't be clicked again
button.resolve(event)
return true
}
}
@ -118,8 +104,7 @@ const commands = [{
// 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"]
const guildID = discord.channels.get(channelID)?.["guild_id"]
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."
// Check if we can/should upload to Discord, for various causes
@ -129,7 +114,7 @@ const commands = [{
const guild = discord.guilds.get(guildID)
assert(guild)
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) {
matrixOnlyReason = "CAPACITY"
} else if (!(permissions & 0x40000000n)) { // MANAGE_GUILD_EXPRESSIONS (apparently CREATE_GUILD_EXPRESSIONS isn't good enough...)
@ -254,8 +239,7 @@ const commands = [{
// 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"]
const guildID = discord.channels.get(channelID)?.["guild_id"]
if (!guildID) {
return api.sendEvent(event.room_id, "m.room.message", {
...ctx,
@ -266,7 +250,7 @@ const commands = [{
const guild = discord.guilds.get(guildID)
assert(guild)
const permissions = dUtils.getDefaultPermissions(guild, channel["permission_overwrites"])
const permissions = dUtils.getPermissions(guild.id, [], guild.roles)
if (!(permissions & 0x800000000n)) { // CREATE_PUBLIC_THREADS
return api.sendEvent(event.room_id, "m.room.message", {
...ctx,
@ -278,167 +262,6 @@ const commands = [{
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 === "Missing Permissions") {
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}.`
})
}
)
}, {
aliases: ["time"],
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."
})
}
// Get user timezone
const profile = await api.getProfile(event.sender)
const senderTimeZone = profile?.["m.tz"]
if (!senderTimeZone) {
return api.sendEvent(event.room_id, "m.room.message", {
...ctx,
msgtype: "m.text",
body: "This command converts a time in your local time zone for everybody else. But I don't know your time zone. Please set your time zone (hopefully possible in your client's settings/account menu) and try again."
})
}
try {
var now = Temporal.Now.zonedDateTimeISO(senderTimeZone)
} catch (e) {
return api.sendEvent(event.room_id, "m.room.message", {
...ctx,
...new mxUtils.MatrixStringBuilder().add(
`The time zone on your account, "${senderTimeZone}", is not valid. It needs to be an IANA time zone value, with a slash. See yours here: https://time.is/your_time_zone`,
tag`The time zone on your account, <code>${senderTimeZone}</code>, is not valid. It needs to be an IANA time zone value with a slash. <a href="https://time.is/your_time_zone">See yours here.</a>`
).get()
})
}
const input = words.slice(1).join(" ").trim()
if (!input) {
return api.sendEvent(event.room_id, "m.room.message", {
...ctx,
msgtype: "m.text",
body: "This command converts a time in your local time zone for everybody else. Use this command with a time or date (English natural language)."
})
}
try {
var parsed = tw.parse(input)
var when = tw.tellMeWhen(input, {now})
} catch (e) {
return api.sendEvent(event.room_id, "m.room.message", {
...ctx,
msgtype: "m.text",
body: "I didn't understand your input. If you think your input was reasonable, please report this as a bug."
})
}
const messageID = select("event_message", "message_id", {event_id: event.event_id}).pluck().get()
const hasDate = parsed.some(x => Array.isArray(x) && x[0].match(/year|month|week|date|day/i))
const hasTime = parsed.some(x => Array.isArray(x) && x[0].match(/hour|minute|second/i))
const colonStyle =
( hasDate && hasTime ? ":F"
: hasDate ? ":D"
: ":t")
/** @param {Temporal.ZonedDateTime} z */
function convertOne(z) {
return "<t:" + Math.floor(z.toInstant().epochMilliseconds / 1000) + colonStyle + ">"
}
if (Array.isArray(when)) { // convert range
var content = `From ${convertOne(when[0])} to ${convertOne(when[1])}`
} else { // convert single time
var content = `${convertOne(when)}`
}
/** @type {DiscordTypes.RESTPostAPIChannelMessageJSONBody} */
const message = {
flags: DiscordTypes.MessageFlags.IsComponentsV2,
components: [{
type: DiscordTypes.ComponentType.Container,
components: [{
type: DiscordTypes.ComponentType.TextDisplay,
content: `## ${content}\n-# Wrong? 🗑️ to delete`
}]
}]
}
if (messageID) {
Object.assign(message, {
message_reference: {
type: DiscordTypes.MessageReferenceType.Default,
message_id: messageID,
}
})
}
const sent = await discord.snow.channel.createMessage(channelID, message)
if (!await retrigger.waitForMessage(sent.id)) return
const sentEventID = select("event_message", "event_id", {message_id: sent.id}, "ORDER BY reaction_part").pluck().get()
assert(sentEventID)
addButton(event.room_id, sentEventID, "🗑️", event.sender).then(() => {
discord.snow.channel.deleteMessage(channelID, sent.id).catch(() => {})
})
}
)
}]

View file

@ -9,14 +9,9 @@ const {reg} = require("./read-registration.js")
const baseUrl = `${reg.ooye.server_origin}/_matrix`
class MatrixServerError extends Error {
/**
* @param {number} httpStatus}
*/
constructor(data, httpStatus, opts = {}) {
constructor(data, opts) {
super(data.error || data.errcode)
this.data = data
/** @type {number} */
this.httpStatus = httpStatus
/** @type {string} */
this.errcode = data.errcode
this.opts = opts
@ -49,11 +44,11 @@ async function _convertBody(body) {
async function makeMatrixServerError(res, opts = {}) {
delete opts.headers?.["Authorization"]
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/")) {
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 {
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)
} catch (e) {
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) {
delete opts.headers?.["Authorization"]
throw new MatrixServerError(root, res.status, {baseUrl, url, ...opts})
throw new MatrixServerError(root, {baseUrl, url, ...opts})
}
return root
}

View file

@ -17,10 +17,6 @@ function checkRegistration(reg) {
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.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 */
@ -54,7 +50,6 @@ function getTemplateRegistration(serverName) {
],
sender_localpart: `${namespace_prefix}bot`,
rate_limited: false,
receive_ephemeral: true,
socket: 6693,
ooye: {
namespace_prefix,
@ -83,15 +78,6 @@ function readRegistration() {
/** @type {import("../types").AppServiceRegistrationConfig} */ // @ts-ignore
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.readRegistration = readRegistration
module.exports.getTemplateRegistration = getTemplateRegistration

View file

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

View file

@ -54,7 +54,6 @@ async function onBotMembership(event, api, createRoom) {
assert.equal(event.type, "m.room.member")
assert.equal(event.state_key, utils.bot)
return await roomUpgradeSema.request(async () => {
// Check if an upgrade is pending for this room
const newRoomID = event.room_id
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
if (event.content.membership !== "invite" && event.content.membership !== "join") return false
return await roomUpgradeSema.request(async () => {
// If invited, join
if (event.content.membership === "invite") {
await api.joinRoom(newRoomID)

View file

@ -225,6 +225,19 @@ async function getViaServersQuery(roomID, api) {
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
* 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) {
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
const serverAndMediaID = `${mediaParts[1]}/${mediaParts[2]}`

View file

@ -7,7 +7,6 @@ const {addbot} = require("../addbot")
const passthrough = require("./passthrough")
const {discord, sync, db, select, from, as} = passthrough
const reg = require("./matrix/read-registration")
const data = sync.require("../test/data")
const createSpace = sync.require("./d2m/actions/create-space")
const createRoom = sync.require("./d2m/actions/create-room")
@ -16,37 +15,18 @@ const mreq = sync.require("./matrix/mreq")
const api = sync.require("./matrix/api")
const file = sync.require("./matrix/file")
const sendEvent = sync.require("./m2d/actions/send-event")
const redact = sync.require("./m2d/actions/redact")
const eventDispatcher = sync.require("./d2m/event-dispatcher")
const updatePins = sync.require("./d2m/actions/update-pins")
const speedbump = sync.require("./d2m/actions/speedbump")
const ks = sync.require("./matrix/kstate")
const setPresence = sync.require("./d2m/actions/set-presence")
const channelWebhook = sync.require("./m2d/actions/channel-webhook")
const dUtils = sync.require("./discord/utils")
const mxUtils = sync.require("./matrix/utils")
const guildID = "112760669178241024"
async function ping() {
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) {
setImmediate(() => {
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)
passthrough.repl = cli
}

43
src/types.d.ts vendored
View file

@ -18,7 +18,6 @@ export type AppServiceRegistrationConfig = {
}
protocols: [string]
rate_limited: boolean
receive_ephemeral: boolean
socket?: string | number,
ooye: {
namespace_prefix: string
@ -36,8 +35,6 @@ export type AppServiceRegistrationConfig = {
web_password: string
time_zone?: string
receive_presences: boolean
plu_ral_api_key?: string
bridge_admin_contact?: string
}
old_bridge?: {
as_token: string
@ -62,7 +59,6 @@ export type InitialAppServiceRegistrationConfig = {
}
protocols: [string]
rate_limited: boolean
receive_ephemeral: boolean
socket?: string | number,
ooye: {
namespace_prefix: string
@ -126,28 +122,6 @@ export type PkMessage = {
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 type Outer<T> = {
type: string
@ -183,7 +157,7 @@ export namespace Event {
type: string
state_key: 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 = {
@ -416,20 +390,6 @@ export namespace Event {
body: 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 {
@ -477,7 +437,6 @@ export namespace R {
num_joined_members: number
room_id: string
room_type?: string
encryption?: string
}
export type ResolvedRoom = {

View file

@ -23,7 +23,7 @@ async function getManagedGuilds(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) {
return h3.useSession(event, {password: reg.as_token, maxAge: 365 * 24 * 60 * 60})

View file

@ -2,24 +2,20 @@
const assert = require("assert/strict")
const fs = require("fs")
const util = require("util")
const {join} = require("path")
const getRelativePath = require("get-relative-path")
const h3 = require("h3")
const {defineEventHandler, defaultContentType, setResponseStatus, getQuery, getRequestURL} = h3
const {defineEventHandler, defaultContentType, setResponseStatus, getQuery} = h3
const {compileFile} = require("@cloudrac3r/pug")
const walkAST = require("pug-walk")
const pretty = process.argv.join(" ").includes("test")
const {sync} = require("../passthrough")
/** @type {import("./auth")} */
const auth = sync.require("./auth")
/** @type {import("../js/errors")} */
const errors = sync.require("../js/errors")
// Pug
let globals = sync.remember(() => ({}))
let globals = {}
/** @type {Map<string, (event: import("h3").H3Event, locals: Record<string, any>) => Promise<string>>} */
const pugCache = new Map()
@ -34,60 +30,21 @@ function addGlobals(obj) {
* @param {Record<string, any>} locals
*/
function render(event, filename, locals) {
return renderFragments(event, filename, null, locals)
}
/**
* @param {import("h3").H3Event} event
* @param {string} filename
* @param {string[] | null} fragments
* @param {Record<string, any>} locals
*/
function renderFragments(event, filename, fragments, locals) {
const path = join(__dirname, "pug", filename)
return renderPath(event, path, fragments, locals)
}
/**
* @param {string[] | null} fragments
* @param {string[]} requiresOut
*/
function fragmentSelectorPlugins(fragments, requiresOut) {
if (!fragments) return []
return [{
parse: true,
postParse(ast, options) {
const found = []
walkAST(ast, (node, replace) => {
if (node.type === "NamedBlock") {
const [name, requires] = node.name.split("requires").map(x => x.trim())
// console.log(name, "|", requires)
if (fragments?.includes(name)) {
if (requires) requiresOut.push(...requires.split(",").map(x => x.trim()))
found.push(node)
}
}
})
// console.log(util.inspect(found, false, 9, true))
return {type: "Block", nodes: found, line: 0, filename: found[0].filename}
}
}]
return renderPath(event, path, locals)
}
/**
* @param {import("h3").H3Event} event
* @param {string} path
* @param {string[] | null} fragments
* @param {Record<string, any>} locals
*/
function renderPath(event, path, fragments, locals) {
const cacheKey = path + (fragments ? fragments.map(f => `#${f}`).join("") : "")
function renderPath(event, path, locals) {
function compile() {
try {
const requiredVars = []
const template = compileFile(path, {pretty, plugins: fragmentSelectorPlugins(fragments, requiredVars)})
pugCache.set(cacheKey, async (event, locals) => {
const template = compileFile(path, {pretty})
pugCache.set(path, async (event, locals) => {
defaultContentType(event, "text/html; charset=utf-8")
const session = await auth.useSession(event)
const managed = await auth.getManagedGuilds(event)
const rel = (to, paramsObject) => {
@ -98,36 +55,16 @@ function renderPath(event, path, fragments, locals) {
}
return result
}
const templateVars = Object.assign({},
return template(Object.assign({},
getQuery(event), // Query parameters can be easily accessed on the top level but don't allow them to overwrite anything
globals, // Globals
locals, // Explicit locals overwrite globals in case we need to DI something
{session, event, rel, managed} // These are assigned last so they overwrite everything else. It would be catastrophically bad if they can't be trusted.
)
// Verify all fragment block required variables are present
for (const key of requiredVars) {
if (templateVars[key] === undefined) {
setResponseStatus(event, 500, "Internal Template Variables Error")
defaultContentType(event, "text/plain")
return `requested fragments ${JSON.stringify(fragments)}\nrequire variables ${JSON.stringify(requiredVars)}\nbut "${key}" is ${templateVars[key]} (present: ${key in templateVars})`
}
}
try {
const content = template(templateVars)
defaultContentType(event, "text/html; charset=utf-8")
return content
} catch (e) {
console.error(e)
setResponseStatus(event, 500, "Internal Template Rendering Error")
defaultContentType(event, "text/plain")
e.url = getRequestURL(event).toString()
return errors.stringifyErrorStack(e)
}
))
})
/* c8 ignore start */
} catch (e) {
pugCache.set(cacheKey, async (event) => {
console.error(e)
pugCache.set(path, async (event) => {
setResponseStatus(event, 500, "Internal Template Error")
defaultContentType(event, "text/plain")
return e.toString()
@ -136,16 +73,13 @@ function renderPath(event, path, fragments, locals) {
/* c8 ignore stop */
}
if (!pugCache.has(cacheKey)) {
if (!pugCache.has(path)) {
compile()
const ac = new AbortController()
const {signal} = ac
fs.watch(path, {persistent: false, signal}, compile)
fs.watch(join(__dirname, "pug", "includes"), {persistent: false, signal}, compile)
sync.events.once(__filename, () => ac.abort())
fs.watch(path, {persistent: false}, compile)
fs.watch(join(__dirname, "pug", "includes"), {persistent: false}, compile)
}
const cb = pugCache.get(cacheKey)
const cb = pugCache.get(path)
assert(cb)
return cb(event, locals)
}
@ -163,6 +97,5 @@ function createRoute(router, url, filename) {
module.exports.addGlobals = addGlobals
module.exports.render = render
module.exports.renderFragments = renderFragments
module.exports.renderPath = renderPath
module.exports.createRoute = createRoute

View file

@ -76,7 +76,7 @@ block body
if space_id
h2.mt48.fs-headline1 Server settings
h3.mt32.fs-category How Matrix users join
h3.mt32.fs-category Privacy level
span#privacy-level-loading
.s-card
form(hx-post=rel("/api/privacy-level") hx-trigger="change" hx-indicator="#privacy-level-loading" hx-disabled-elt="input")
@ -105,42 +105,6 @@ block body
p.s-description.m0 Shareable invite links, like Discord
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
block default-roles-list requires 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
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.box-border.lh-sm
.s-popover--arrow.s-popover--arrow__tc
block add-roles-menu requires 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
p.fc-light.mb0.mt8 Matrix users will start with these roles. If your main channels are gated by a role, use this to let Matrix users skip the gate.
h3.mt32.fs-category Features
.s-card.d-grid.px0.g16
form.d-flex.ai-center.g16
@ -188,72 +152,6 @@ block body
td(colspan="3")
.s-empty-state No channels linked between Discord and Matrix yet...
if space_id && linkedChannelsWithDetails.length
- const customMentionRooms = linkedChannelsWithDetails.filter(row => row.room_mention_role)
- const defaultMentionRooms = linkedChannelsWithDetails.filter(row => !row.room_mention_role)
- const mentionRoles = [...guild.roles].filter(role => role.id !== guild_id).sort((a, b) => b.position - a.position)
h3.mt32.fs-category#room-notifications Room notifications
.s-card.p0
p.s-description.m12 Choose which Discord role receives Matrix @room notifications
if customMentionRooms.length
.s-table-container(style="container-type: inline-size")
table.s-table.s-table__bx-simple(style="table-layout: fixed")
thead
tr
th(scope="col" style="width: calc(50cqw - 40px)") Channel
th.pr12(scope="col") Notify role
tbody
each row in customMentionRooms
tr
td.va-middle
+discord(row.channel)
td.pr12
.d-flex.ai-start.g8
form.fl-grow1.wmn0.d-flex.ai-stretch.g8(method="post" action=rel("/api/room-mention-role"))
input(type="hidden" name="guild_id" value=guild_id)
input(type="hidden" name="channel_id" value=row.channel.id)
.s-select.fl-grow1.wmn0
select.w100(name="room_mention_role" id="mention-role-" + row.channel.id aria-label="Notify role for #" + row.channel.name onchange="this.form.requestSubmit()" style="height: 32px")
if !mentionRoles.some(role => role.id === row.room_mention_role)
option(value=row.room_mention_role selected disabled) Deleted role (choose a replacement)
each role in mentionRoles
option(value=role.id selected=row.room_mention_role === role.id)= "@" + role.name
button.s-btn.s-btn__outlined.py0.box-border(type="submit" data-room-mention-save style="height: 32px; min-height: 0") Save
form.d-flex.ai-stretch(method="post" action=rel("/api/room-mention-role"))
input(type="hidden" name="guild_id" value=guild_id)
input(type="hidden" name="channel_id" value=row.channel.id)
input(type="hidden" name="room_mention_role" value="")
button.s-btn.s-btn__danger.s-btn__outlined.py0(type="submit" aria-label="Remove custom role for #" + row.channel.name style="height: 32px") Remove
script.
document.querySelectorAll('[data-room-mention-save]').forEach(button => button.remove())
if defaultMentionRooms.length && mentionRoles.length
details.mt16.mx12.mb12
summary.s-label.cursor-pointer Add custom role
form.mt16(method="post" action=rel("/api/room-mention-role"))
input(type="hidden" name="guild_id" value=guild_id)
.d-flex.fw-wrap.ai-end.g16
.fl-grow1.wmn0(style="flex-basis: 0; min-width: 180px")
label.s-label(for="mention-role-channel") Channel
.s-select
select.w100.box-border#mention-role-channel(name="channel_id" required style="height: 32px")
option(value="" selected disabled) Choose a channel
each row in defaultMentionRooms
- const parent = discord.channels.get(row.channel.parent_id)
option(value=row.channel.id)= "#" + row.channel.name + (parent ? " - " + parent.name : "")
.fl-grow1.wmn0(style="flex-basis: 0; min-width: 180px")
label.s-label(for="mention-role-new") Notify role
.s-select
select.w100.box-border#mention-role-new(name="room_mention_role" required style="height: 32px")
option(value="" selected disabled) Choose a role
each role in mentionRoles
option(value=role.id)= "@" + role.name
.d-flex.ai-stretch(style="height: 32px")
button.s-btn.s-btn__icon.s-btn__filled.d-inline-flex.ai-center.g4.py0.box-border(type="submit" style="min-height: 0")
!= icons.Icons.IconPlusSm
span Add
h3.fs-category.mt32 Auto-create
.s-card.d-grid.px0
form.d-flex.ai-center.g16
@ -274,14 +172,14 @@ block body
label.s-btn.s-btn__muted.ta-left.truncate(for=channel.id)
+discord(channel, true, "Announcement")
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
each room in unlinkedRooms
input.s-btn--radio(type="radio" name="matrix" required id=room.room_id value=room.room_id)
label.s-btn.s-btn__muted.ta-left.truncate(for=room.room_id)
+matrix(room, true)
else
.s-empty-state.p8 No Matrix rooms available.
.s-empty-state.p8 All Matrix rooms are linked.
input(type="hidden" name="guild_id" value=guild_id)
div
button.s-btn.s-btn__icon.s-btn__filled#link-button
@ -332,11 +230,6 @@ block body
ul.my8.ml24
each row in removedLinkedRooms
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
.s-card.p0
ul.my8.ml24

Some files were not shown because too many files have changed in this diff Show more