Compare commits

...
Sign in to create a new pull request.

80 commits

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

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

Co-authored-by: Cadence Ember <cadence@disroot.org>
Reviewed-on: cadence/out-of-your-element#85
2026-06-01 04:54:38 +00:00
af6ea072f3 Add stats
Just adding this early version for now so I can iterate.
2026-05-30 15:28:26 +12:00
24c2dee7d3 Fix m->d custom emoji reactions on some clients 2026-05-30 15:16:54 +12:00
16867d57fb Rework how getMedia does thumbnails 2026-05-29 20:10:32 +12:00
aecfde54c8 Resize avatars before sending to Discord 2026-05-29 20:10:01 +12:00
ee406caf24 Update CloudStorm 2026-05-28 13:20:35 +12:00
9b37705a73 Indicate that errors may be retried 2026-05-28 13:18:18 +12:00
7f7a366cd5 Fix tests for command emoji change 2026-05-22 14:34:59 +12:00
99eacd8c47 Generate letter avatars if no avatar 2026-05-22 14:34:32 +12:00
e0eb7deb2f Change arrow to chevron for commands 2026-05-21 23:19:03 +12:00
e435b78e28 Do not revoke newer webhooks 2026-05-21 19:13:03 +12:00
d76936b157 Change emoji for forwards/crossposts 2026-05-21 19:09:02 +12:00
dec216c0c2 Update dependencies 2026-05-21 19:04:42 +12:00
7781d1e34d Increase d->m catch-up limit to 100 2026-05-21 18:44:48 +12:00
93bbc5ea0f Revoke webhooks that might have been compromised 2026-05-21 18:28:11 +12:00
43b8b02b40 Remove webhook tokens from error messages 2026-05-21 17:59:52 +12:00
eb676256e4 Fix Discord mentions with extra HTML attributes 2026-05-14 18:20:32 +12:00
4815d28aa4 Code blocks uploaded as attachments when too long 2026-05-13 14:38:14 +12:00
191a98e1dc Fix watching registration file before creation 2026-05-12 14:11:06 +12:00
678a1b77bb Cap length of channels report 2026-05-12 14:08:58 +12:00
2aff1fbd06 Code block attachments use Discord supported types 2026-05-12 14:07:14 +12:00
92d6ada71b Merge tag 'v3.5.1'
Remove AI joke
2026-05-10 20:41:21 +12:00
d8fb4be509 d->m: Fix reply to user join message 2026-04-24 21:23:14 +12:00
4698835549 v3.5.1 2026-03-29 15:43:43 +13:00
e7cbfb9fc9 Remove AI joke
This reverts commit 201814e9f4.
2026-03-29 15:43:23 +13:00
91bce76fc8 Use HTML to strip per-message profile fallback 2026-03-29 15:41:23 +13:00
nemesio65
12f4103870 d2m: Create voice channels as call rooms 2026-03-28 11:46:08 +13:00
e28eac6bfa Update domino 2026-03-28 11:45:51 +13:00
857fb7583b v3.5 2026-03-27 19:20:04 +13:00
59012d9613 Fix pinning random messages 2026-03-27 19:13:03 +13:00
953b3e7741 Attach message to error
Apparently this was causing detached logs, so just stop those
complaints if the error isn't being bubbled
2026-03-26 00:16:30 +13:00
8c023cc936 Add ping() function to REPL 2026-03-25 16:24:07 +13:00
e9fe820666 Registration changes should be instant now 2026-03-25 16:22:37 +13:00
f742d8572a MSC4144 minor changes for merge 2026-03-25 03:10:54 +00:00
Bea
8224ed5341 feat(discord): show per-message profile info in matrix info command 2026-03-25 03:10:54 +00:00
Bea
0b513b7ee0 fix(m2d): implement MSC4144 avatar clearing algorithm
- Empty string "" -> undefined (Discord uses default avatar)
- Valid MXC URI -> convert to public URL
- Omitted/null -> keep member avatar
2026-03-25 03:10:54 +00:00
Bea
07ec9832b2 fix(m2d): only use unstable com.beeper.per_message_profile prefix 2026-03-25 03:10:54 +00:00
Bea
a8b7d64e91 feat(m2d): strip per-message profile fallbacks from message content
Remove data-mx-profile-fallback elements from formatted_body and
displayname prefix from plain body when per-message profile is used.
2026-03-25 03:10:54 +00:00
Bea
41692b11ff feat(m2d): support MSC4144 per-message profiles
Override webhook username and avatar_url from m.per_message_profile
(and unstable com.beeper.per_message_profile) when present.
The stable key takes priority over the unstable prefix.
2026-03-25 03:10:54 +00:00
d8c0a947f2 Automatically reload registration 2026-03-25 15:39:26 +13:00
5c9e569a2a Support channel follow messages 2026-03-25 15:29:18 +13:00
201814e9f4 Update dependencies 2026-03-23 21:22:33 +13:00
7367fb3b65 Fix weird background clipping on icons 2026-03-20 01:37:22 +13:00
c75e87f403 Stream files in serveStatic for lower memory use 2026-03-20 01:27:34 +13:00
8b9d8ec0cc Widen newline tag detection 2026-03-20 00:59:52 +13:00
0dac3d2898 Internal language adjusted 2026-03-20 00:53:09 +13:00
9dbd871e0b Defuse mentions in m->d reply if client says so 2026-03-20 00:42:51 +13:00
8c87d93011 Remove member repetition bugfixes 2026-03-20 00:17:40 +13:00
e8d9a5e4ae Script to remove uncached bridged users 2026-03-19 14:30:19 +13:00
876d91fbf4 Remove sims when the Discord user leaves 2026-03-19 14:30:10 +13:00
d2557f73bb Let sims rejoin after being unbanned
The sim_member cache was getting stuck, so OOYE thought it was already
in the room when it actually wasn't.
2026-03-19 13:35:53 +13:00
f8896dce7f Type fixes in set-presence.js 2026-03-19 13:34:19 +13:00
5b04b5d712 Reformat /plu/ral emulated replies 2026-03-19 13:33:50 +13:00
711e024caa Update dependencies 2026-03-17 14:02:11 +13:00
f1b111a8a4 Refuse to operate on encrypted rooms
- Refuse to link to encrypted rooms
- Do not show encrypted rooms as link candidates (if server supports)
- Reject invites to encrypted rooms with message
- Unbridge and leave room if it becomes encrypted
2026-03-17 12:35:42 +13:00
d3afa728ed Fix m->d posting embeds even when setting is off 2026-03-15 20:53:41 +13:00
6716b432ba Wait for response before next click (don't queue) 2026-03-15 01:33:29 +13:00
3365023fe3 Sync default roles changes immediately 2026-03-15 01:21:38 +13:00
e6c3013993 Make default permission setting functional 2026-03-14 20:23:43 +13:00
cb4e8df91e Fix package-lock 2026-03-14 14:34:59 +13:00
f90cdfdbb5 Update dependencies, make stream-type independent 2026-03-14 14:25:48 +13:00
ff022e8793 Combine additional embed images into same event 2026-03-13 11:12:44 +13:00
99f4c52beb Fix attempting to follow an upgrade path twice 2026-03-13 10:17:04 +13:00
5f768fee01 d->m: Don't guess mentions in code blocks 2026-03-12 16:23:22 +13:00
6ca1b836e1 Add more debugging information 2026-03-11 12:38:05 +13:00
Bea
ada3933d9c Backfill: Create new rooms when needed
This updates the backfill script to attempt to create rooms for unbridged rooms, rather than bombing out that the room isn't already bridged.

Co-authored-by: Cadence Ember <cadence@disroot.org>
Reviewed-on: cadence/out-of-your-element#75
Co-authored-by: Bea <beanie@theargo.space>
Co-committed-by: Bea <beanie@theargo.space>
2026-03-09 00:22:41 +00:00
Bea
f5ee130463 Handle expired invites & fix test registration (#73)
This PR addresses a bridge crash discovered while backfilling old channels, alongside a wee QoL fix for the test suite.

* **Expired Events (`d2m`):** Wraps Discord scheduled event/invite link lookups in a try-catch block. If a link is expired (404 or Discord error 10006), the bridge now posts a fallback `m.notice` rather than throwing an error and halting message conversion.
* **Test Suite Setup:** Updates `test.js` to initialize the mock registration object using `getTemplateRegistration()` preventing test runner crashes when running without a local `registration.yaml` file.

Co-authored-by: Cadence Ember <cadence@disroot.org>
Reviewed-on: cadence/out-of-your-element#73
Co-authored-by: Bea <beanie@theargo.space>
Co-committed-by: Bea <beanie@theargo.space>
2026-03-08 22:11:28 +00:00
cd8549da38 Fix sticker tests and coverage 2026-03-08 23:32:36 +13:00
f7a5b2d74c Update tryToCatch dependency and usages 2026-03-08 22:36:05 +13:00
6a2606cbdb Add UI for defining default roles 2026-03-08 22:35:10 +13:00
9eaa85c072 Add /invite Matrix command to get Discord invite 2026-03-08 22:34:51 +13:00
74c0c28cf4 Update dependencies 2026-03-08 22:34:04 +13:00
84 changed files with 3706 additions and 1264 deletions

View file

@ -89,15 +89,14 @@ Whether you read those or not, I'm more than happy to help you 1-on-1 with codin
# Dependency justification # Dependency justification
Total transitive production dependencies: 134 Total transitive production dependencies: 144
### <font size="+2">🦕</font> ### <font size="+2">🦕</font>
* (31) better-sqlite3: SQLite is the best database, and this is the best library for it. * (35) 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.) * (29) sharp: Image resizing and compositing. OOYE needs this for the emoji sprite sheets. It has libvips prebuilts for each platform.
* (16) stream-mime-type@1: This seems like the best option. Version 1 is used because version 2 is ESM-only. * (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 appservice listener, authmedia proxy, self-service, and more. * (9) h3: Web server. OOYE needs this for the web UI, appservice listener, authmedia proxy, and more.
* (11) sharp: Image resizing and compositing. OOYE needs this for the emoji sprite sheets.
### <font size="-1">🪱</font> ### <font size="-1">🪱</font>
@ -108,6 +107,7 @@ Total transitive production dependencies: 134
* (0) @cloudrac3r/in-your-element: This is my Matrix Appservice API library. It depends on h3 and zod, which are already pulled in by OOYE. * (0) @cloudrac3r/in-your-element: This is my Matrix Appservice API library. It depends on h3 and zod, which are already pulled in by OOYE.
* (0) @cloudrac3r/mixin-deep: This is my fork. (It fixes a bug in regular mixin-deep.) * (0) @cloudrac3r/mixin-deep: This is my fork. (It fixes a bug in regular mixin-deep.)
* (0) @cloudrac3r/pngjs: Lottie stickers are converted to bitmaps with the vendored Rlottie WASM build, then the bitmaps are converted to PNG with pngjs. * (0) @cloudrac3r/pngjs: Lottie stickers are converted to bitmaps with the vendored Rlottie WASM build, then the bitmaps are converted to PNG with pngjs.
* (0) @cloudrac3r/stream-type: Determine type of Matrix files that don't specify it in info. Switched from stream-mime-type to this.
* (0) @cloudrac3r/turndown: This HTML-to-Markdown converter looked the most suitable. I forked it to change the escaping logic to match the way Discord works. * (0) @cloudrac3r/turndown: This HTML-to-Markdown converter looked the most suitable. I forked it to change the escaping logic to match the way Discord works.
* (3) @stackoverflow/stacks: Stack Overflow design language and icons. * (3) @stackoverflow/stacks: Stack Overflow design language and icons.
* (0) ansi-colors: Helps with interactive prompting for the initial setup, and it's already pulled in by enquirer. * (0) ansi-colors: Helps with interactive prompting for the initial setup, and it's already pulled in by enquirer.
@ -115,12 +115,12 @@ Total transitive production dependencies: 134
* (0) cloudstorm: Discord gateway library with bring-your-own-caching that I trust. * (0) cloudstorm: Discord gateway library with bring-your-own-caching that I trust.
* (0) discord-api-types: Bitfields needed at runtime and types needed for development. * (0) discord-api-types: Bitfields needed at runtime and types needed for development.
* (0) domino: DOM implementation that's already pulled in by turndown. * (0) domino: DOM implementation that's already pulled in by turndown.
* (1) enquirer: Interactive prompting for the initial setup rather than forcing users to edit YAML non-interactively. * (2) enquirer: Interactive prompting for the initial setup rather than forcing users to edit YAML non-interactively.
* (0) entities: Looks fine. No dependencies. * (0) entities: Looks fine. No dependencies.
* (0) get-relative-path: Looks fine. No dependencies. * (0) get-relative-path: Looks fine. No dependencies.
* (1) heatsync: Module hot-reloader that I trust. * (1) heatsync: Module hot-reloader that I trust.
* (0) lru-cache: For holding unused nonce in memory and letting them be overwritten later if never used. * (0) lru-cache: For holding unused nonce in memory and letting them be overwritten later if never used.
* (0) mime-type: File extension to mime type mapping that's already pulled in by stream-mime-type. * (1) mime-types: List of mime type mappings. Needed to serve static files.
* (0) prettier-bytes: It does what I want and has no dependencies. * (0) prettier-bytes: It does what I want and has no dependencies.
* (0) snowtransfer: Discord API library with bring-your-own-caching that I trust. * (0) snowtransfer: Discord API library with bring-your-own-caching that I trust.
* (0) try-to-catch: Not strictly necessary, but it's already pulled in by supertape, so I may as well. * (0) try-to-catch: Not strictly necessary, but it's already pulled in by supertape, so I may as well.

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. If the bridge software was restarted, it will attempt to catch up on messages missed while it was offline.
From Discord, for any given channel, if fewer than 50 messages were missed in that given channel, the bridge will catch up and transfer all of them to Matrix. If more than 50 messages were missed in that given channel, the bridge will only bridge the latest message. Happenings that aren't messages, such as edits and reactions to prior messages, might be missed during catch-up. From Discord, for any given channel, if fewer than 100 messages were missed in that given channel, the bridge will catch up and transfer all of them to Matrix. If more than 100 messages were missed in that given channel, the bridge will only bridge the latest message. Happenings that aren't messages, such as edits and reactions to prior messages, might be missed during catch-up.
From Matrix, all events should be bridged to Discord. From Matrix, all events should be bridged to Discord.

1139
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", "name": "out-of-your-element",
"version": "3.4.0", "version": "3.6.0",
"description": "A bridge between Matrix and Discord", "description": "A bridge between Matrix and Discord",
"main": "index.js", "main": "index.js",
"repository": { "repository": {
@ -19,35 +19,35 @@
}, },
"dependencies": { "dependencies": {
"@chriscdn/promise-semaphore": "^3.0.1", "@chriscdn/promise-semaphore": "^3.0.1",
"@cloudrac3r/discord-markdown": "^2.6.10", "@cloudrac3r/discord-markdown": "^2.7.0",
"@cloudrac3r/giframe": "^0.4.3", "@cloudrac3r/giframe": "^0.4.3",
"@cloudrac3r/html-template-tag": "^5.0.1", "@cloudrac3r/html-template-tag": "^5.0.1",
"@cloudrac3r/in-your-element": "^1.1.1", "@cloudrac3r/in-your-element": "^1.1.1",
"@cloudrac3r/mixin-deep": "^3.0.1", "@cloudrac3r/mixin-deep": "^3.0.1",
"@cloudrac3r/pngjs": "^7.0.3", "@cloudrac3r/pngjs": "^7.0.3",
"@cloudrac3r/pug": "^4.0.4", "@cloudrac3r/pug": "^4.0.4",
"@cloudrac3r/stream-type": "^1.0.0",
"@cloudrac3r/turndown": "^7.1.4", "@cloudrac3r/turndown": "^7.1.4",
"@stackoverflow/stacks": "^2.5.4", "@stackoverflow/stacks": "^2.5.4",
"@stackoverflow/stacks-icons": "^6.0.2", "@stackoverflow/stacks-icons": "^6.0.2",
"ansi-colors": "^4.1.3", "ansi-colors": "^4.1.3",
"better-sqlite3": "^12.2.0", "better-sqlite3": "^12.2.0",
"chunk-text": "^2.0.1", "chunk-text": "^2.0.1",
"cloudstorm": "^0.15.2", "cloudstorm": "^0.17.1",
"discord-api-types": "^0.38.38", "discord-api-types": "^0.38.38",
"domino": "^2.1.6", "domino": "^2.1.6",
"enquirer": "^2.4.1", "enquirer": "^2.4.1",
"entities": "^5.0.0", "entities": "^5.0.0",
"get-relative-path": "^1.0.2", "get-relative-path": "^1.0.2",
"h3": "^1.15.1", "h3": "^1.15.10",
"heatsync": "^2.7.2", "heatsync": "^2.7.2",
"htmx.org": "^2.0.4", "htmx.org": "^2.0.4",
"lru-cache": "^11.0.2", "lru-cache": "^11.0.2",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
"prettier-bytes": "^1.0.4", "prettier-bytes": "^1.0.4",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"snowtransfer": "^0.17.1", "snowtransfer": "^0.17.5",
"stream-mime-type": "^1.0.2", "try-to-catch": "^4.0.5",
"try-to-catch": "^3.0.1",
"uqr": "^0.1.2", "uqr": "^0.1.2",
"xxhash-wasm": "^1.0.2", "xxhash-wasm": "^1.0.2",
"zod": "^4.0.17" "zod": "^4.0.17"
@ -58,9 +58,9 @@
"devDependencies": { "devDependencies": {
"@cloudrac3r/tap-dot": "^2.0.3", "@cloudrac3r/tap-dot": "^2.0.3",
"@types/node": "^22.17.1", "@types/node": "^22.17.1",
"c8": "^10.1.2", "c8": "^11.0.0",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"supertape": "^12.0.12" "supertape": "^13.2.0"
}, },
"scripts": { "scripts": {
"start": "node --enable-source-maps start.js", "start": "node --enable-source-maps start.js",

View file

@ -10,7 +10,6 @@ if (!channelID) {
process.exit(1) process.exit(1)
} }
const assert = require("assert/strict")
const sqlite = require("better-sqlite3") const sqlite = require("better-sqlite3")
const backfill = new sqlite("scripts/backfill.db") const backfill = new sqlite("scripts/backfill.db")
backfill.prepare("CREATE TABLE IF NOT EXISTS backfill (channel_id TEXT NOT NULL, message_id INTEGER NOT NULL, PRIMARY KEY (channel_id, message_id))").run() backfill.prepare("CREATE TABLE IF NOT EXISTS backfill (channel_id TEXT NOT NULL, message_id INTEGER NOT NULL, PRIMARY KEY (channel_id, message_id))").run()
@ -38,12 +37,8 @@ passthrough.select = orm.select
/** @type {import("../src/d2m/event-dispatcher")}*/ /** @type {import("../src/d2m/event-dispatcher")}*/
const eventDispatcher = sync.require("../src/d2m/event-dispatcher") const eventDispatcher = sync.require("../src/d2m/event-dispatcher")
/** @type {import("../src/d2m/actions/create-room")} */
const roomID = passthrough.select("channel_room", "room_id", {channel_id: channelID}).pluck().get() const createRoom = sync.require("../src/d2m/actions/create-room")
if (!roomID) {
console.error("Please choose a channel that's already bridged.")
process.exit(1)
}
;(async () => { ;(async () => {
await discord.cloud.connect() await discord.cloud.connect()
@ -60,23 +55,29 @@ async function event(event) {
if (!channel) return if (!channel) return
const guild_id = event.d.id const guild_id = event.d.id
let last = backfill.prepare("SELECT cast(max(message_id) as TEXT) FROM backfill WHERE channel_id = ?").pluck().get(channelID) || "0" try {
console.log(`OK, processing messages for #${channel.name}, continuing from ${last}`) 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}`)
while (last) { while (last) {
const messages = await discord.snow.channel.getChannelMessages(channelID, {limit: 50, after: String(last)}) const messages = await discord.snow.channel.getChannelMessages(channelID, {limit: 50, after: String(last)})
messages.reverse() // More recent messages come first -> More recent messages come last messages.reverse() // More recent messages come first -> More recent messages come last
for (const message of messages) { for (const message of messages) {
const simulatedGatewayDispatchData = { const simulatedGatewayDispatchData = {
guild_id, guild_id,
backfill: true, backfill: true,
...message ...message
}
await eventDispatcher.MESSAGE_CREATE(discord, simulatedGatewayDispatchData)
preparedInsert.run(channelID, message.id)
} }
await eventDispatcher.MESSAGE_CREATE(discord, simulatedGatewayDispatchData) last = messages.at(-1)?.id
preparedInsert.run(channelID, message.id)
} }
last = messages.at(-1)?.id
}
process.exit() process.exit()
} catch (e) {
console.error(e)
process.exit(1) // won't exit automatically on thrown error due to living discord connection, so manual exit is necessary
}
} }

View file

@ -0,0 +1,36 @@
// @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 reg.ooye.web_password = passwordResponse.web_password
writeRegistration(reg) writeRegistration(reg)
console.log("Saved. Restart Out Of Your Element to apply this change.") console.log("Saved. This change should be applied instantly.")
})() })()

View file

@ -122,7 +122,7 @@ async function channelToKState(channel, guild, di) {
join_rules = {join_rule: PRIVACY_ENUMS.ROOM_JOIN_RULES[privacyLevel]} join_rules = {join_rule: PRIVACY_ENUMS.ROOM_JOIN_RULES[privacyLevel]}
} }
const everyonePermissions = dUtils.getPermissions(guild.id, [], guild.roles, undefined, channel.permission_overwrites) const everyonePermissions = dUtils.getDefaultPermissions(guild, channel.permission_overwrites)
const everyoneCanSend = dUtils.hasPermission(everyonePermissions, DiscordTypes.PermissionFlagsBits.SendMessages) const everyoneCanSend = dUtils.hasPermission(everyonePermissions, DiscordTypes.PermissionFlagsBits.SendMessages)
const everyoneCanMentionEveryone = dUtils.hasPermission(everyonePermissions, DiscordTypes.PermissionFlagsBits.MentionEveryone) const everyoneCanMentionEveryone = dUtils.hasPermission(everyonePermissions, DiscordTypes.PermissionFlagsBits.MentionEveryone)
@ -193,6 +193,16 @@ async function channelToKState(channel, guild, di) {
// Don't overwrite room topic if the topic has been customised // Don't overwrite room topic if the topic has been customised
if (hasCustomTopic) delete channelKState["m.room.topic/"] if (hasCustomTopic) delete channelKState["m.room.topic/"]
// Make voice channels be a Matrix voice room (MSC3417)
if (channel.type === DiscordTypes.ChannelType.GuildVoice) {
creationContent.type = "org.matrix.msc3417.call"
channelKState["org.matrix.msc3401.call/"] = {
"m.intent": "m.room",
"m.type": "m.voice",
"m.name": customName || channel.name
}
}
// Don't add a space parent if it's self service // Don't add a space parent if it's self service
// (The person setting up self-service has already put it in their preferred space to be able to get this far.) // (The person setting up self-service has already put it in their preferred space to be able to get this far.)
const autocreate = select("guild_active", "autocreate", {guild_id: guild.id}).pluck().get() const autocreate = select("guild_active", "autocreate", {guild_id: guild.id}).pluck().get()
@ -256,7 +266,7 @@ async function createRoom(channel, guild, spaceID, kstate, privacyLevel) {
/** /**
* Handling power levels separately. The spec doesn't specify what happens, Dendrite differs, * Handling power levels separately. The spec doesn't specify what happens, Dendrite differs,
* and Synapse does an absolutely insane *shallow merge* of what I provide on top of what it creates. * and Synapse does a very poorly thought out *shallow merge* of what I provide on top of what it creates.
* We don't want the `events` key to be overridden completely. * We don't want the `events` key to be overridden completely.
* https://github.com/matrix-org/synapse/blob/develop/synapse/handlers/room.py#L1170-L1210 * https://github.com/matrix-org/synapse/blob/develop/synapse/handlers/room.py#L1170-L1210
* https://github.com/matrix-org/matrix-spec/issues/492 * https://github.com/matrix-org/matrix-spec/issues/492
@ -442,8 +452,9 @@ function syncRoom(channelID) {
/** /**
* @param {{id: string, topic?: string?}} channel channel-ish (just needs an id, topic is optional) * @param {{id: string, topic?: string?}} channel channel-ish (just needs an id, topic is optional)
* @param {string} guildID * @param {string} guildID
* @param {string} messageBeforeLeave
*/ */
async function unbridgeChannel(channel, guildID) { async function unbridgeChannel(channel, guildID, messageBeforeLeave = "This room was removed from the bridge.") {
const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get() const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get()
assert.ok(roomID) assert.ok(roomID)
const row = from("guild_space").join("guild_active", "guild_id").select("space_id", "autocreate").where({guild_id: guildID}).get() const row = from("guild_space").join("guild_active", "guild_id").select("space_id", "autocreate").where({guild_id: guildID}).get()
@ -493,7 +504,7 @@ async function unbridgeChannel(channel, guildID) {
// send a notification in the room // send a notification in the room
await api.sendEvent(roomID, "m.room.message", { await api.sendEvent(roomID, "m.room.message", {
msgtype: "m.notice", msgtype: "m.notice",
body: "⚠️ This room was removed from the bridge." body: `⚠️ ${messageBeforeLeave}`
}) })
// if it is an easy mode room, clean up the room from the managed space and make it clear it's not being bridged // if it is an easy mode room, clean up the room from the managed space and make it clear it's not being bridged

View file

@ -190,6 +190,17 @@ test("channel2room: read-only discord channel", async t => {
t.equal(api.getCalled(), 2) t.equal(api.getCalled(), 2)
}) })
test("channel2room: voice channel", async t => {
const api = mockAPI(t)
const state = kstateStripConditionals(await channelToKState(testData.channel.voice, testData.guild.general, {api}).then(x => x.channelKState))
t.equal(state["m.room.create/"].type, "org.matrix.msc3417.call")
t.deepEqual(state["org.matrix.msc3401.call/"], {
"m.intent": "m.room",
"m.name": "🍞丨[8user] Piece",
"m.type": "m.voice"
})
})
test("convertNameAndTopic: custom name and topic", t => { test("convertNameAndTopic: custom name and topic", t => {
t.deepEqual( t.deepEqual(
_convertNameAndTopic({id: "123", name: "the-twilight-zone", topic: "Spooky stuff here. :ghost:", type: 0}, {id: "456"}, "hauntings"), _convertNameAndTopic({id: "123", name: "the-twilight-zone", topic: "Spooky stuff here. :ghost:", type: 0}, {id: "456"}, "hauntings"),

View file

@ -34,7 +34,10 @@ async function emojisToState(emojis, guild) {
if (e.data?.errcode === "M_TOO_LARGE") { // Very unlikely to happen. Only possible for 3x-series emojis uploaded shortly after animated emojis were introduced, when there was no 256 KB size limit. if (e.data?.errcode === "M_TOO_LARGE") { // Very unlikely to happen. Only possible for 3x-series emojis uploaded shortly after animated emojis were introduced, when there was no 256 KB size limit.
return return
} }
console.error(`Trying to handle emoji ${emoji.name} (${emoji.id}), but...`) e["emoji"] = {
name: emoji.name,
id: emoji.id
}
throw e throw e
}) })
)) ))

View file

@ -154,7 +154,7 @@ function memberToPowerLevel(user, member, guild, channel) {
if (!member) return 0 if (!member) return 0
const permissions = dUtils.getPermissions(guild.id, member.roles, guild.roles, user.id, channel.permission_overwrites) const permissions = dUtils.getPermissions(guild.id, member.roles, guild.roles, user.id, channel.permission_overwrites)
const everyonePermissions = dUtils.getPermissions(guild.id, [], guild.roles, undefined, channel.permission_overwrites) const everyonePermissions = dUtils.getDefaultPermissions(guild, channel.permission_overwrites)
/* /*
* PL 100 = Administrator = People who can brick the room. RATIONALE: * PL 100 = Administrator = People who can brick the room. RATIONALE:
* - Administrator. * - Administrator.
@ -206,14 +206,16 @@ function _hashProfileContent(content, powerLevel) {
* 3. Calculate the power level the user should get based on their Discord permissions * 3. Calculate the power level the user should get based on their Discord permissions
* 4. Compare against the previously known state content, which is helpfully stored in the database * 4. Compare against the previously known state content, which is helpfully stored in the database
* 5. If the state content or power level have changed, send them to Matrix and update them in the database for next time * 5. If the state content or power level have changed, send them to Matrix and update them in the database for next time
* 6. If the sim is for a user-installed app, check which user it was added by
* @param {DiscordTypes.APIUser} user * @param {DiscordTypes.APIUser} user
* @param {Omit<DiscordTypes.APIGuildMember, "user"> | undefined} member * @param {Omit<DiscordTypes.APIGuildMember, "user"> | undefined} member
* @param {DiscordTypes.APIGuildChannel} channel * @param {DiscordTypes.APIGuildChannel} channel
* @param {DiscordTypes.APIGuild} guild * @param {DiscordTypes.APIGuild} guild
* @param {string} roomID * @param {string} roomID
* @param {DiscordTypes.APIMessageInteractionMetadata} [interactionMetadata]
* @returns {Promise<string>} mxid of the updated sim * @returns {Promise<string>} mxid of the updated sim
*/ */
async function syncUser(user, member, channel, guild, roomID) { async function syncUser(user, member, channel, guild, roomID, interactionMetadata) {
const mxid = await ensureSimJoined(user, roomID) const mxid = await ensureSimJoined(user, roomID)
const content = await memberToStateContent(user, member, guild.id) const content = await memberToStateContent(user, member, guild.id)
const powerLevel = memberToPowerLevel(user, member, guild, channel) const powerLevel = memberToPowerLevel(user, member, guild, channel)
@ -222,6 +224,12 @@ async function syncUser(user, member, channel, guild, roomID) {
allowOverwrite: !!member, allowOverwrite: !!member,
globalProfile: await userToGlobalProfile(user) globalProfile: await userToGlobalProfile(user)
}) })
const appInstalledByUser = user.bot && interactionMetadata?.authorizing_integration_owners?.[DiscordTypes.ApplicationIntegrationType.UserInstall]
if (appInstalledByUser) {
db.prepare("INSERT OR IGNORE INTO app_user_install (app_bot_id, user_id, guild_id) VALUES (?, ?, ?)").run(user.id, appInstalledByUser, guild.id)
}
return mxid return mxid
} }

View file

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

View file

@ -51,7 +51,7 @@ async function sendMessage(message, channel, guild, row) {
if (message.author.id === discord.application.id) { if (message.author.id === discord.application.id) {
// no need to sync the bot's own user // no need to sync the bot's own user
} else { } else {
senderMxid = await registerUser.syncUser(message.author, message.member, channel, guild, roomID) senderMxid = await registerUser.syncUser(message.author, message.member, channel, guild, roomID, message.interaction_metadata)
} }
} }
@ -60,8 +60,7 @@ async function sendMessage(message, channel, guild, row) {
const detailedResultsMessage = await pollEnd.endPoll(message) const detailedResultsMessage = await pollEnd.endPoll(message)
if (detailedResultsMessage) { if (detailedResultsMessage) {
const threadParent = select("channel_room", "thread_parent", {channel_id: message.channel_id}).pluck().get() const threadParent = select("channel_room", "thread_parent", {channel_id: message.channel_id}).pluck().get()
const channelID = threadParent ? threadParent : message.channel_id const {channelID, threadID} = dUtils.swapThreadID(message.channel_id, threadParent)
const threadID = threadParent ? message.channel_id : undefined
sentResultsMessage = await channelWebhook.sendMessageWithWebhook(channelID, detailedResultsMessage, threadID) sentResultsMessage = await channelWebhook.sendMessageWithWebhook(channelID, detailedResultsMessage, threadID)
} }
} }

View file

@ -1,5 +1,7 @@
// @ts-check // @ts-check
const assert = require("assert").strict
const passthrough = require("../../passthrough") const passthrough = require("../../passthrough")
const {sync, select} = passthrough const {sync, select} = passthrough
/** @type {import("../../matrix/api")} */ /** @type {import("../../matrix/api")} */
@ -26,7 +28,7 @@ const presenceLoopInterval = 28e3
// Cache the list of enabled guilds rather than accessing it like multiple times per second when any user changes presence // Cache the list of enabled guilds rather than accessing it like multiple times per second when any user changes presence
const guildPresenceSetting = new class { const guildPresenceSetting = new class {
/** @private @type {Set<string>} */ guilds /** @private @type {Set<string>} */ guilds = new Set()
constructor() { constructor() {
this.update() this.update()
} }
@ -40,7 +42,7 @@ const guildPresenceSetting = new class {
class Presence extends sync.reloadClassMethods(() => Presence) { class Presence extends sync.reloadClassMethods(() => Presence) {
/** @type {string} */ userID /** @type {string} */ userID
/** @type {{presence: "online" | "offline" | "unavailable", status_msg?: string}} */ data /** @type {{presence: "online" | "offline" | "unavailable", status_msg?: string} | undefined} */ data
/** @private @type {?string | undefined} */ mxid /** @private @type {?string | undefined} */ mxid
/** @private @type {number} */ delay = Math.random() /** @private @type {number} */ delay = Math.random()
@ -66,6 +68,7 @@ class Presence extends sync.reloadClassMethods(() => Presence) {
// I haven't tried, but I assume Synapse explodes if you try to update too many presences at the same time. // I haven't tried, but I assume Synapse explodes if you try to update too many presences at the same time.
// This random delay will space them out over the whole 28 second cycle. // This random delay will space them out over the whole 28 second cycle.
setTimeout(() => { setTimeout(() => {
assert(this.data)
api.setPresence(this.data, mxid).catch(() => {}) api.setPresence(this.data, mxid).catch(() => {})
}, this.delay * presenceLoopInterval).unref() }, this.delay * presenceLoopInterval).unref()
} }

View file

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

View file

@ -151,9 +151,11 @@ async function editToChanges(message, guild, api) {
const messageReallyOld = message.timestamp && new Date(message.timestamp).getTime() < Date.now() - 2 * 60 * 1000 // older than 2 minutes ago const messageReallyOld = message.timestamp && new Date(message.timestamp).getTime() < Date.now() - 2 * 60 * 1000 // older than 2 minutes ago
// Don't post new generated embeds for messages if the setting was disabled. // Don't post new generated embeds for messages if the setting was disabled.
const embedsEnabled = select("guild_space", "url_preview", {guild_id: guild?.id}).pluck().get() ?? 1 const embedsEnabled = select("guild_space", "url_preview", {guild_id: guild?.id}).pluck().get() ?? 1
// Bots may rely on embeds to send new content, so the rules may be more lax for them.
const botEmbedsApproved = message.author?.bot && !originallyFromMatrix
if (messageReallyOld) { if (messageReallyOld) {
eventsToSend = [] // Only allow edits to change and delete, but not send new. eventsToSend = [] // Only allow edits to change and delete, but not send new.
} else if ((messageQuiteOld || !embedsEnabled) && !message.author?.bot) { } else if ((messageQuiteOld || !embedsEnabled) && !botEmbedsApproved) {
eventsToSend = eventsToSend.filter(e => e.msgtype !== "m.notice") // Only send events that aren't embeds. eventsToSend = eventsToSend.filter(e => e.msgtype !== "m.notice") // Only send events that aren't embeds.
} }

View file

@ -78,7 +78,7 @@ test("edit2changes: bot response", async t => {
newContent: { newContent: {
$type: "m.room.message", $type: "m.room.message",
msgtype: "m.text", msgtype: "m.text",
body: "* :ae_botrac4r: [@cadence](https://matrix.to/#/@cadence:cadence.moe) asked ``­``, I respond: Stop drinking paint. (No)\n\nHit :bn_re: to reroll.", body: "* :ae_botrac4r: @cadence asked ``­``, I respond: Stop drinking paint. (No)\n\nHit :bn_re: to reroll.",
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: '* <img data-mx-emoticon height="32" src="mxc://cadence.moe/skqfuItqxNmBYekzmVKyoLzs" title=":ae_botrac4r:" alt=":ae_botrac4r:"> <a href="https://matrix.to/#/@cadence:cadence.moe">@cadence</a> asked <code>­</code>, I respond: Stop drinking paint. (No)<br><br>Hit <img data-mx-emoticon height="32" src="mxc://cadence.moe/OIpqpfxTnHKokcsYqDusxkBT" title=":bn_re:" alt=":bn_re:"> to reroll.', formatted_body: '* <img data-mx-emoticon height="32" src="mxc://cadence.moe/skqfuItqxNmBYekzmVKyoLzs" title=":ae_botrac4r:" alt=":ae_botrac4r:"> <a href="https://matrix.to/#/@cadence:cadence.moe">@cadence</a> asked <code>­</code>, I respond: Stop drinking paint. (No)<br><br>Hit <img data-mx-emoticon height="32" src="mxc://cadence.moe/OIpqpfxTnHKokcsYqDusxkBT" title=":bn_re:" alt=":bn_re:"> to reroll.',
"m.mentions": { "m.mentions": {
@ -87,7 +87,7 @@ test("edit2changes: bot response", async t => {
// *** Replaced With: *** // *** Replaced With: ***
"m.new_content": { "m.new_content": {
msgtype: "m.text", msgtype: "m.text",
body: ":ae_botrac4r: [@cadence](https://matrix.to/#/@cadence:cadence.moe) asked ``­``, I respond: Stop drinking paint. (No)\n\nHit :bn_re: to reroll.", body: ":ae_botrac4r: @cadence asked ``­``, I respond: Stop drinking paint. (No)\n\nHit :bn_re: to reroll.",
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: '<img data-mx-emoticon height="32" src="mxc://cadence.moe/skqfuItqxNmBYekzmVKyoLzs" title=":ae_botrac4r:" alt=":ae_botrac4r:"> <a href="https://matrix.to/#/@cadence:cadence.moe">@cadence</a> asked <code>­</code>, I respond: Stop drinking paint. (No)<br><br>Hit <img data-mx-emoticon height="32" src="mxc://cadence.moe/OIpqpfxTnHKokcsYqDusxkBT" title=":bn_re:" alt=":bn_re:"> to reroll.', formatted_body: '<img data-mx-emoticon height="32" src="mxc://cadence.moe/skqfuItqxNmBYekzmVKyoLzs" title=":ae_botrac4r:" alt=":ae_botrac4r:"> <a href="https://matrix.to/#/@cadence:cadence.moe">@cadence</a> asked <code>­</code>, I respond: Stop drinking paint. (No)<br><br>Hit <img data-mx-emoticon height="32" src="mxc://cadence.moe/OIpqpfxTnHKokcsYqDusxkBT" title=":bn_re:" alt=":bn_re:"> to reroll.',
"m.mentions": { "m.mentions": {

View file

@ -146,10 +146,18 @@ function findMention(pjr, maximumWrittenSection, baseOffset, prefix, content) {
// Highlight the relevant part of the message // Highlight the relevant part of the message
const start = baseOffset + best.scored.matchedInputTokens[0].index const start = baseOffset + best.scored.matchedInputTokens[0].index
const end = baseOffset + prefix.length + best.scored.matchedInputTokens.slice(-1)[0].end const end = baseOffset + prefix.length + best.scored.matchedInputTokens.slice(-1)[0].end
const newContent = content.slice(0, start) + "[" + content.slice(start, end) + "](https://matrix.to/#/" + best.mxid + ")" + content.slice(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)
}]
return { return {
mxid: best.mxid, mxid: best.mxid,
newContent newNodes
} }
} }
} }

View file

@ -35,10 +35,10 @@ function getDiscordParseCallbacks(message, guild, useHTML, spoilers = []) {
/** @param {{id: string, type: "discordUser"}} node */ /** @param {{id: string, type: "discordUser"}} node */
user: node => { user: node => {
const mxid = select("sim", "mxid", {user_id: node.id}).pluck().get() const mxid = select("sim", "mxid", {user_id: node.id}).pluck().get()
const interaction = message.interaction_metadata || message.interaction const interactionMetadata = message.interaction_metadata
const username = message.mentions?.find(ment => ment.id === node.id)?.username const username = message.mentions?.find(ment => ment.id === node.id)?.username
|| message.referenced_message?.mentions?.find(ment => ment.id === node.id)?.username || message.referenced_message?.mentions?.find(ment => ment.id === node.id)?.username
|| (interaction?.user.id === node.id ? interaction.user.username : null) || (interactionMetadata?.user.id === node.id ? interactionMetadata.user.username : null)
|| (message.author?.id === node.id ? message.author.username : null) || (message.author?.id === node.id ? message.author.username : null)
|| "unknown-user" || "unknown-user"
if (mxid && useHTML) { if (mxid && useHTML) {
@ -109,7 +109,7 @@ const embedTitleParser = markdown.markdownEngine.parserFor({
/** /**
* @param {{room?: boolean, user_ids?: string[]}} mentions * @param {{room?: boolean, user_ids?: string[]}} mentions
* @param {Omit<DiscordTypes.APIAttachment, "id" | "proxy_url">} attachment * @param {Omit<DiscordTypes.APIAttachment, "id" | "proxy_url" | "flags">} attachment
* @param {boolean} [alwaysLink] * @param {boolean} [alwaysLink]
*/ */
async function attachmentToEvent(mentions, attachment, alwaysLink) { async function attachmentToEvent(mentions, attachment, alwaysLink) {
@ -256,8 +256,34 @@ function getFormattedInteraction(interaction, isThinkingInteraction) {
const username = interaction.member?.nick || interaction.user.global_name || interaction.user.username const username = interaction.member?.nick || interaction.user.global_name || interaction.user.username
const thinkingText = isThinkingInteraction ? " — interaction loading..." : "" const thinkingText = isThinkingInteraction ? " — interaction loading..." : ""
return { return {
body: `↪️ ${username} used \`/${interaction.name}\`${thinkingText}`, body: `${username} used \`/${interaction.name}\`${thinkingText}`,
html: `<blockquote><sub>↪️ ${mxid ? tag`<a href="https://matrix.to/#/${mxid}">${username}</a>` : username} used <code>/${interaction.name}</code>${thinkingText}</sub></blockquote>` html: `<blockquote><sub>❭ ${mxid ? tag`<a href="https://matrix.to/#/${mxid}">${username}</a>` : username} used <code>/${interaction.name}</code>${thinkingText}</sub></blockquote>`
}
}
/**
* @param {any} newEvents merge into events
* @param {any} events will be modified
* @param {boolean} forceSameMsgtype whether m.text may only be combined with m.text, etc
* @param {boolean} [forceMerge] if true, must merge event, will error if it had to append
*/
function mergeTextEvents(newEvents, events, forceSameMsgtype, forceMerge = false) {
let prev = events.at(-1)
for (const ne of newEvents) {
const isAllText = prev?.body && prev?.formatted_body && ["m.text", "m.notice"].includes(ne.msgtype) && ["m.text", "m.notice"].includes(prev?.msgtype)
const typesPermitted = !forceSameMsgtype || ne?.msgtype === prev?.msgtype
if (isAllText && typesPermitted) {
const rep = new mxUtils.MatrixStringBuilder()
rep.body = prev.body
rep.formattedBody = prev.formatted_body
rep.addLine(ne.body, ne.formatted_body)
prev.body = rep.body
prev.formatted_body = rep.formattedBody
} else if (forceMerge) {
throw new Error("Unable to merge events")
} else {
events.push(ne)
}
} }
} }
@ -334,9 +360,19 @@ async function messageToEvent(message, guild, options = {}, di) {
}] }]
} }
const interaction = message.interaction_metadata || message.interaction if (message.type === DiscordTypes.MessageType.ChannelFollowAdd) {
const isInteraction = message.type === DiscordTypes.MessageType.ChatInputCommand && !!interaction && "name" in interaction return [{
const isThinkingInteraction = isInteraction && !!((message.flags || 0) & DiscordTypes.MessageFlags.Loading) $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)
/** /**
@type {{room?: boolean, user_ids?: string[]}} @type {{room?: boolean, user_ids?: string[]}}
@ -377,6 +413,16 @@ async function messageToEvent(message, guild, options = {}, di) {
} else if (message.referenced_message) { } else if (message.referenced_message) {
repliedToUnknownEvent = true repliedToUnknownEvent = true
} }
} else if (message.type === DiscordTypes.MessageType.ContextMenuCommand && message.interaction && message.message_reference?.message_id) {
// It could be a /plu/ral emulated reply
if (message.interaction.name.startsWith("Reply ") && message.content.startsWith("-# [↪](")) {
const row = await getHistoricalEventRow(message.message_reference?.message_id)
if (row && "event_id" in row) {
repliedToEventRow = Object.assign(row, {channel_id: row.reference_channel_id})
message.content = message.content.replace(/^.*\n/, "")
isInteraction = false // declutter
}
}
} else if (dUtils.isWebhookMessage(message) && message.embeds[0]?.author?.name?.endsWith("↩️")) { } else if (dUtils.isWebhookMessage(message) && message.embeds[0]?.author?.name?.endsWith("↩️")) {
// It could be a PluralKit emulated reply, let's see if it has a message link // It could be a PluralKit emulated reply, let's see if it has a message link
const isEmulatedReplyToText = message.embeds[0].description?.startsWith("**[Reply to:]") const isEmulatedReplyToText = message.embeds[0].description?.startsWith("**[Reply to:]")
@ -519,29 +565,60 @@ async function messageToEvent(message, guild, options = {}, di) {
return emojiToKey.emojiToKey({id, name, animated}, message.id) // Register the custom emoji if needed return emojiToKey.emojiToKey({id, name, animated}, message.id) // Register the custom emoji if needed
})) }))
async function transformParsedVia(parsed) { async function transformParsedVia(parsed, scanTextForMentions) {
for (const node of parsed) { for (let n = 0; n < parsed.length; n++) {
const node = parsed[n]
if (node.type === "discordChannel" || node.type === "discordChannelLink") { if (node.type === "discordChannel" || node.type === "discordChannelLink") {
node.row = select("channel_room", ["room_id", "name", "nick"], {channel_id: node.id}).get() node.row = select("channel_room", ["room_id", "name", "nick"], {channel_id: node.id}).get()
if (node.row?.room_id) { if (node.row?.room_id) {
node.via = await getViaServersMemo(node.row.room_id) node.via = await getViaServersMemo(node.row.room_id)
} }
} }
else if (node.type === "text" && typeof node.content === "string") {
// Merge adjacent text nodes into this one
while (parsed[n+1]?.type === "text" && typeof parsed[n+1].content === "string") {
node.content += parsed[n+1].content
parsed.splice(n+1, 1)
}
// Mentions scenario 3: scan the message content for written @mentions of matrix users. Allows for up to one space between @ and mention.
if (scanTextForMentions) {
let content = node.content
const matches = [...content.matchAll(/(@ ?)([a-z0-9_.#$][^@\n]+)/gi)]
for (let i = matches.length; i--;) {
const m = matches[i]
const prefix = m[1]
const maximumWrittenSection = m[2].toLowerCase()
if (m.index > 0 && !content[m.index-1].match(/ |\(|\n/)) continue // must have space before it
if (maximumWrittenSection.match(/^everyone\b/) || maximumWrittenSection.match(/^here\b/)) continue // ignore @everyone/@here
var roomID = roomID ?? select("channel_room", "room_id", {channel_id: message.channel_id}).pluck().get()
assert(roomID)
var pjr = pjr ?? findMentions.processJoined(Object.entries((await di.api.getJoinedMembers(roomID)).joined).map(([mxid, ev]) => ({mxid, displayname: ev.display_name})))
const found = findMentions.findMention(pjr, maximumWrittenSection, m.index, prefix, content)
if (found) {
addMention(found.mxid)
parsed.splice(n, 1, ...found.newNodes)
content = found.newNodes[0].content
}
}
}
}
for (const maybeChildNodesArray of [node, node.content, node.items]) { for (const maybeChildNodesArray of [node, node.content, node.items]) {
if (Array.isArray(maybeChildNodesArray)) { if (Array.isArray(maybeChildNodesArray)) {
await transformParsedVia(maybeChildNodesArray) await transformParsedVia(maybeChildNodesArray, scanTextForMentions && ["blockQuote", "list", "paragraph", "em", "strong", "u", "del", "text"].includes(node.type))
} }
} }
} }
return parsed return parsed
} }
let html = await markdown.toHtmlWithPostParser(content, transformParsedVia, { let html = await markdown.toHtmlWithPostParser(content, parsed => transformParsedVia(parsed, customOptions.isTheMessageContent && options.scanTextForMentions !== false), {
discordCallback: getDiscordParseCallbacks(message, guild, true, spoilers), discordCallback: getDiscordParseCallbacks(message, guild, true, spoilers),
...customOptions ...customOptions
}, customParser, customHtmlOutput) }, customParser, customHtmlOutput)
let body = await markdown.toHtmlWithPostParser(content, transformParsedVia, { 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
discordCallback: getDiscordParseCallbacks(message, guild, false), discordCallback: getDiscordParseCallbacks(message, guild, false),
discordOnly: true, discordOnly: true,
escapeHTML: false, escapeHTML: false,
@ -566,8 +643,8 @@ async function messageToEvent(message, guild, options = {}, di) {
const flags = message.flags || 0 const flags = message.flags || 0
if (flags & DiscordTypes.MessageFlags.IsCrosspost) { if (flags & DiscordTypes.MessageFlags.IsCrosspost) {
body = `[🔀 ${message.author.username}]\n` + body body = `[ ${message.author.username}]\n` + body
html = `🔀 <strong>${message.author.username}</strong><br>` + html html = ` <strong>${message.author.username}</strong><br>` + html
} }
// Fallback body/formatted_body for replies // Fallback body/formatted_body for replies
@ -582,7 +659,8 @@ async function messageToEvent(message, guild, options = {}, di) {
// check that condition 1 or 2 is met // check that condition 1 or 2 is met
if (repliedToEventInDifferentRoom || repliedToUnknownEvent) { if (repliedToEventInDifferentRoom || repliedToUnknownEvent) {
let referenced = message.referenced_message let referenced = message.referenced_message
if (!referenced) { // backend couldn't be bothered to dereference the message, have to do it ourselves /* c8 ignore next 4 - backend couldn't be bothered to dereference the message, have to do it ourselves */
if (!referenced) {
assert(message.message_reference?.message_id) assert(message.message_reference?.message_id)
referenced = await discord.snow.channel.getChannelMessage(message.message_reference.channel_id, message.message_reference.message_id) referenced = await discord.snow.channel.getChannelMessage(message.message_reference.channel_id, message.message_reference.message_id)
} }
@ -594,7 +672,7 @@ async function messageToEvent(message, guild, options = {}, di) {
const match = repliedToEventSenderMxid.match(/^@([^:]*)/) const match = repliedToEventSenderMxid.match(/^@([^:]*)/)
assert(match) assert(match)
repliedToDisplayName = referenced.author.username || match[1] || "a Matrix user" // grab the localpart as the display name, whatever repliedToDisplayName = referenced.author.username || match[1] || "a Matrix user" // grab the localpart as the display name, whatever
repliedToUserHtml = `<a href="https://matrix.to/#/${repliedToEventSenderMxid}">${repliedToDisplayName}</a>` repliedToUserHtml = tag`<a href="https://matrix.to/#/${repliedToEventSenderMxid}">${repliedToDisplayName}</a>`
} else { } else {
repliedToDisplayName = referenced.author.global_name || referenced.author.username || "a Discord user" repliedToDisplayName = referenced.author.global_name || referenced.author.username || "a Discord user"
repliedToUserHtml = repliedToDisplayName repliedToUserHtml = repliedToDisplayName
@ -619,6 +697,12 @@ async function messageToEvent(message, guild, options = {}, di) {
+ html + html
body = `${repliedToDisplayName}: ${repliedToBody}`.split("\n").map(line => "> " + line).join("\n") // scenario 1 part B for mentions body = `${repliedToDisplayName}: ${repliedToBody}`.split("\n").map(line => "> " + line).join("\n") // scenario 1 part B for mentions
+ "\n\n" + body + "\n\n" + body
} else if (referenced.type === DiscordTypes.MessageType.UserJoin) {
// Discord user join messages are bridged as joins, not text events. Generate substitute text for reply.
const joinerMxid = select("sim", "mxid", {user_id: referenced.author.id}).pluck().get()
const joinerHtml = joinerMxid ? tag`<a href="https://matrix.to/#/${joinerMxid}">${repliedToDisplayName}</a>` : tag`<strong>${repliedToDisplayName}</strong>`
html = `<blockquote>${joinerHtml} joined the room</blockquote>` + html
body = `> ${repliedToDisplayName} joined the room\n\n` + body
} else { // repliedToUnknownEvent } else { // repliedToUnknownEvent
const dateDisplay = dUtils.howOldUnbridgedMessage(referenced.timestamp, message.timestamp) const dateDisplay = dUtils.howOldUnbridgedMessage(referenced.timestamp, message.timestamp)
html = `<blockquote>In reply to ${dateDisplay} from ${repliedToDisplayName}:` html = `<blockquote>In reply to ${dateDisplay} from ${repliedToDisplayName}:`
@ -630,8 +714,8 @@ async function messageToEvent(message, guild, options = {}, di) {
} }
} }
if (isInteraction && !isThinkingInteraction && events.length === 0) { if (isInteraction && !isThinkingInteraction && message.interaction && events.length === 0) {
const formattedInteraction = getFormattedInteraction(interaction, false) const formattedInteraction = getFormattedInteraction(message.interaction, false)
body = `${formattedInteraction.body}\n${body}` body = `${formattedInteraction.body}\n${body}`
html = `${formattedInteraction.html}${html}` html = `${formattedInteraction.html}${html}`
} }
@ -687,20 +771,20 @@ async function messageToEvent(message, guild, options = {}, di) {
if (row && "event_id" in row) { if (row && "event_id" in row) {
const via = await getViaServersMemo(row.room_id) const via = await getViaServersMemo(row.room_id)
forwardedNotice.addLine( forwardedNotice.addLine(
`[🔀 Forwarded from #${roomName}]`, `[ Forwarded from #${roomName}]`,
tag`🔀 <em>Forwarded from ${roomName} <a href="https://matrix.to/#/${room.room_id}/${row.event_id}?${via}">[jump to event]</a></em>` tag` <em>Forwarded from ${roomName} <a href="https://matrix.to/#/${room.room_id}/${row.event_id}?${via}">[jump to event]</a></em>`
) )
} else { } else {
const via = await getViaServersMemo(room.room_id) const via = await getViaServersMemo(room.room_id)
forwardedNotice.addLine( forwardedNotice.addLine(
`[🔀 Forwarded from #${roomName}]`, `[ Forwarded from #${roomName}]`,
tag`🔀 <em>Forwarded from ${roomName} <a href="https://matrix.to/#/${room.room_id}?${via}">[jump to room]</a></em>` tag` <em>Forwarded from ${roomName} <a href="https://matrix.to/#/${room.room_id}?${via}">[jump to room]</a></em>`
) )
} }
} else { } else {
forwardedNotice.addLine( forwardedNotice.addLine(
`[🔀 Forwarded message]`, `[ Forwarded message]`,
tag`🔀 <em>Forwarded message</em>` tag` <em>Forwarded message</em>`
) )
} }
@ -727,49 +811,37 @@ async function messageToEvent(message, guild, options = {}, di) {
events.push(...forwardedEvents) events.push(...forwardedEvents)
} }
if (isThinkingInteraction) { if (isInteraction && isThinkingInteraction && message.interaction) {
const formattedInteraction = getFormattedInteraction(interaction, true) const formattedInteraction = getFormattedInteraction(message.interaction, true)
await addTextEvent(formattedInteraction.body, formattedInteraction.html, "m.notice") await addTextEvent(formattedInteraction.body, formattedInteraction.html, "m.notice")
} }
// Then text content // Then text content
if (message.content && !isOnlyKlipyGIF && !isThinkingInteraction) { if (message.content && !isOnlyKlipyGIF && !isThinkingInteraction) {
// Mentions scenario 3: scan the message content for written @mentions of matrix users. Allows for up to one space between @ and mention.
let content = message.content
if (options.scanTextForMentions !== false) {
const matches = [...content.matchAll(/(@ ?)([a-z0-9_.#$][^@\n]+)/gi)]
for (let i = matches.length; i--;) {
const m = matches[i]
const prefix = m[1]
const maximumWrittenSection = m[2].toLowerCase()
if (m.index > 0 && !content[m.index-1].match(/ |\(|\n/)) continue // must have space before it
if (maximumWrittenSection.match(/^everyone\b/) || maximumWrittenSection.match(/^here\b/)) continue // ignore @everyone/@here
var roomID = roomID ?? select("channel_room", "room_id", {channel_id: message.channel_id}).pluck().get()
assert(roomID)
var pjr = pjr ?? findMentions.processJoined(Object.entries((await di.api.getJoinedMembers(roomID)).joined).map(([mxid, ev]) => ({mxid, displayname: ev.display_name})))
const found = findMentions.findMention(pjr, maximumWrittenSection, m.index, prefix, content)
if (found) {
addMention(found.mxid)
content = found.newContent
}
}
}
// Scan the content for emojihax and replace them with real emojis // Scan the content for emojihax and replace them with real emojis
content = content.replaceAll(/\[([a-zA-Z0-9_-]{2,32})(?:~[0-9]+)?\]\(https:\/\/cdn\.discordapp\.com\/emojis\/([0-9]+)\.[^ \n)`]+\)/g, (_, name, id) => { let content = message.content.replaceAll(/\[([a-zA-Z0-9_-]{2,32})(?:~[0-9]+)?\]\(https:\/\/cdn\.discordapp\.com\/emojis\/([0-9]+)\.[^ \n)`]+\)/g, (_, name, id) => {
return `<:${name}:${id}>` return `<:${name}:${id}>`
}) })
const {body, html} = await transformContent(content) const {body, html} = await transformContent(content, {isTheMessageContent: true})
await addTextEvent(body, html, msgtype) await addTextEvent(body, html, msgtype)
} }
// Then scheduled events // Then scheduled events
if (message.content && di?.snow) { if (message.content && di?.snow) {
for (const match of [...message.content.matchAll(/discord\.gg\/([A-Za-z0-9]+)\?event=([0-9]{18,})/g)]) { // snowflake has minimum 18 because the events feature is at least that old for (const match of [...message.content.matchAll(/discord\.gg\/([A-Za-z0-9]+)\?event=([0-9]{18,})/g)]) { // snowflake has minimum 18 because the events feature is at least that old
const invite = await di.snow.invite.getInvite(match[1], {guild_scheduled_event_id: match[2]}) let invite
try {
invite = await di.snow.invite.getInvite(match[1], {guild_scheduled_event_id: match[2]})
} catch (e) {
// Skip expired/invalid invites and events
if (e.message === `{"message": "Unknown Invite", "code": 10006}`) {
break
} else {
throw e
}
}
const event = invite.guild_scheduled_event const event = invite.guild_scheduled_event
if (!event) continue // the event ID provided was not valid if (!event) continue // the event ID provided was not valid
@ -815,15 +887,7 @@ async function messageToEvent(message, guild, options = {}, di) {
// Try to merge attachment events with the previous event // Try to merge attachment events with the previous event
// This means that if the attachments ended up as a text link, and especially if there were many of them, the events will be joined together. // This means that if the attachments ended up as a text link, and especially if there were many of them, the events will be joined together.
let prev = events.at(-1) mergeTextEvents(attachmentEvents, events, false)
for (const atch of attachmentEvents) {
if (atch.msgtype === "m.text" && prev?.body && prev?.formatted_body && ["m.text", "m.notice"].includes(prev?.msgtype)) {
prev.body = prev.body + "\n" + atch.body
prev.formatted_body = prev.formatted_body + "<br>" + atch.formatted_body
} else {
events.push(atch)
}
}
} }
// Then components // Then components
@ -905,11 +969,9 @@ async function messageToEvent(message, guild, options = {}, di) {
else if (component.type === DiscordTypes.ComponentType.Button) { else if (component.type === DiscordTypes.ComponentType.Button) {
// May only be a section accessory or in an action row (up to 5) // May only be a section accessory or in an action row (up to 5)
if (component.style === DiscordTypes.ButtonStyle.Link) { if (component.style === DiscordTypes.ButtonStyle.Link) {
if (component.label) { assert(component.label) // required for Discord to validate link buttons
stack.msb.add(`[${component.label} ${component.url}] `, tag`<a href="${component.url}">${component.label}</a> `) const link = await transformContentMessageLinks(component.url)
} else { stack.msb.add(`[${component.label} ${link}] `, tag`<a href="${link}">${component.label}</a> `)
stack.msb.add(component.url)
}
} }
} }
@ -922,7 +984,19 @@ async function messageToEvent(message, guild, options = {}, di) {
const {body, formatted_body} = stack.msb.get() const {body, formatted_body} = stack.msb.get()
if (body.trim().length) { if (body.trim().length) {
await addTextEvent(body, formatted_body, "m.text") // Create new message if Components V2 (cannot have regular content)
if ((message.flags ?? 0) & DiscordTypes.MessageFlags.IsComponentsV2) {
await addTextEvent(body, formatted_body, "m.text")
}
// Add to existing message if legacy components https://docs.discord.com/developers/components/reference#legacy-message-component-behavior
else {
mergeTextEvents([{
msgtype: "m.text",
body,
format: "org.matrix.custom.html",
formatted_body
}], events, false, true)
}
} }
} }
@ -964,6 +1038,7 @@ async function messageToEvent(message, guild, options = {}, di) {
// Start building up a replica ("rep") of the embed in Discord-markdown format, which we will convert into both plaintext and formatted body at once // Start building up a replica ("rep") of the embed in Discord-markdown format, which we will convert into both plaintext and formatted body at once
const rep = new mxUtils.MatrixStringBuilder() const rep = new mxUtils.MatrixStringBuilder()
let isAdditionalImage = false
if (isKlipyGIF) { if (isKlipyGIF) {
assert(embed.video?.url) assert(embed.video?.url)
@ -1030,7 +1105,11 @@ async function messageToEvent(message, guild, options = {}, di) {
let chosenImage = embed.image?.url let chosenImage = embed.image?.url
// the thumbnail seems to be used for "article" type but displayed big at the bottom by discord // the thumbnail seems to be used for "article" type but displayed big at the bottom by discord
if (embed.type === "article" && embed.thumbnail?.url && !chosenImage) chosenImage = embed.thumbnail.url if (embed.type === "article" && embed.thumbnail?.url && !chosenImage) chosenImage = embed.thumbnail.url
if (chosenImage) rep.addParagraph(`📸 ${dUtils.getPublicUrlForCdn(chosenImage)}`)
if (chosenImage) {
isAdditionalImage = !rep.body && !!events.length
rep.addParagraph(`📸 ${dUtils.getPublicUrlForCdn(chosenImage)}`)
}
if (embed.video?.url) rep.addParagraph(`🎞️ ${dUtils.getPublicUrlForCdn(embed.video.url)}`) if (embed.video?.url) rep.addParagraph(`🎞️ ${dUtils.getPublicUrlForCdn(embed.video.url)}`)
@ -1039,6 +1118,11 @@ async function messageToEvent(message, guild, options = {}, di) {
body = body.split("\n").map(l => "| " + l).join("\n") body = body.split("\n").map(l => "| " + l).join("\n")
html = `<blockquote>${html}</blockquote>` html = `<blockquote>${html}</blockquote>`
if (isAdditionalImage) {
mergeTextEvents([{...rep.get(), body, html, msgtype: "m.notice"}], events, true)
continue
}
// Send as m.notice to apply the usual automated/subtle appearance, showing this wasn't actually typed by the person // Send as m.notice to apply the usual automated/subtle appearance, showing this wasn't actually typed by the person
await addTextEvent(body, html, "m.notice") await addTextEvent(body, html, "m.notice")
} }
@ -1059,7 +1143,7 @@ async function messageToEvent(message, guild, options = {}, di) {
} }
} else { } else {
let body = stickerItem.name let body = stickerItem.name
const sticker = guild.stickers.find(sticker => sticker.id === stickerItem.id) const sticker = guild.stickers?.find(sticker => sticker.id === stickerItem.id)
if (sticker && sticker.description) body += ` - ${sticker.description}` if (sticker && sticker.description) body += ` - ${sticker.description}`
return { return {
$type: "m.sticker", $type: "m.sticker",

View file

@ -1,6 +1,7 @@
const {test} = require("supertape") const {test} = require("supertape")
const {messageToEvent} = require("./message-to-event") const {messageToEvent} = require("./message-to-event")
const data = require("../../../test/data") const data = require("../../../test/data")
const {mockGetEffectivePower} = require("../../matrix/utils.test")
test("message2event components: pk question mark output", async t => { test("message2event components: pk question mark output", async t => {
const events = await messageToEvent(data.message_with_components.pk_question_mark_response, data.guild.general, {}) const events = await messageToEvent(data.message_with_components.pk_question_mark_response, data.guild.general, {})
@ -65,7 +66,7 @@ test("message2event components: pk question mark output", async t => {
+ "<hr>" + "<hr>"
+ "<blockquote><p><strong>System:</strong> INX (<code>xffgnx</code>)" + "<blockquote><p><strong>System:</strong> INX (<code>xffgnx</code>)"
+ "<br><strong>Member:</strong> Lillith (<code>pphhoh</code>)" + "<br><strong>Member:</strong> Lillith (<code>pphhoh</code>)"
+ "<br><strong>Sent by:</strong> infinidoge1337 (@unknown-user:)" + "<br><strong>Sent by:</strong> infinidoge1337 (<a href=\"https://matrix.to/#/@_ooye_infinidoge1337:cadence.moe\">@unknown-user</a>)"
+ "<br><br><strong>Account Roles (7)</strong>" + "<br><br><strong>Account Roles (7)</strong>"
+ "<br>§b, !, ‼, Ears Port Ping, Ears Update Ping, Yttr Ping, unsup Ping</p>" + "<br>§b, !, ‼, Ears Port Ping, Ears Update Ping, Yttr Ping, unsup Ping</p>"
+ `🖼️ <a href="https://files.inx.moe/p/cdn/lillith.webp">https://files.inx.moe/p/cdn/lillith.webp</a>` + `🖼️ <a href="https://files.inx.moe/p/cdn/lillith.webp">https://files.inx.moe/p/cdn/lillith.webp</a>`
@ -77,3 +78,24 @@ test("message2event components: pk question mark output", async t => {
msgtype: "m.text", msgtype: "m.text",
}]) }])
}) })
test("message2event components: pk ping message legacy components", async t => {
const events = await messageToEvent(data.message_with_components.pk_ping_components_v1, data.guild.general, {}, {
api: {
async getJoinedMembers() {
return {joined: {}}
},
getEffectivePower: mockGetEffectivePower()
}
})
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.text",
body: "❭ cadence used `/🔔 Ping author`"
+ "\nPsst, **Red** (@cadence.worm:), you have been pinged by @cadence.worm:."
+ "\n[Jump https://matrix.to/#/!TqlyQmifxGUggEmdBN:cadence.moe/$l9FMmsEbh9K0NUReeEpWOMZYGRlUOE8yLcm6P-TYHSM?via=cadence.moe] ",
format: "org.matrix.custom.html",
formatted_body: "<blockquote><sub>❭ <a href=\"https://matrix.to/#/@_ooye_cadence:cadence.moe\">cadence</a> used <code>/🔔 Ping author</code></sub></blockquote>Psst, <strong>Red</strong> (<a href=\"https://matrix.to/#/@_ooye_cadence:cadence.moe\">@cadence.worm</a>), you have been pinged by <a href=\"https://matrix.to/#/@_ooye_cadence:cadence.moe\">@cadence.worm</a>.<br><a href=\"https://matrix.to/#/!TqlyQmifxGUggEmdBN:cadence.moe/$l9FMmsEbh9K0NUReeEpWOMZYGRlUOE8yLcm6P-TYHSM?via=cadence.moe\">Jump</a> ",
"m.mentions": {}
}])
})

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, {}) const events = await messageToEvent(data.interaction_message.thinking_interaction, data.guild.general, {})
t.deepEqual(events, [{ t.deepEqual(events, [{
$type: "m.room.message", $type: "m.room.message",
body: "↪️ Brad used `/stats` — interaction loading...", body: " Brad used `/stats` — interaction loading...",
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: "<blockquote><sub>↪️ <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">Brad</a> used <code>/stats</code> — interaction loading...</sub></blockquote>", formatted_body: "<blockquote><sub> <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">Brad</a> used <code>/stats</code> — interaction loading...</sub></blockquote>",
"m.mentions": {}, "m.mentions": {},
msgtype: "m.notice", msgtype: "m.notice",
}]) }])
@ -22,12 +22,12 @@ test("message2event embeds: nothing but a field", async t => {
$type: "m.room.message", $type: "m.room.message",
"m.mentions": {}, "m.mentions": {},
msgtype: "m.notice", msgtype: "m.notice",
body: "↪️ PapiOphidian used `/stats`" body: " PapiOphidian used `/stats`"
+ "\n| ### Amanda 🎵#2192 :online:" + "\n| ### Amanda 🎵#2192 :online:"
+ "\n| willow tree, branch 0" + "\n| willow tree, branch 0"
+ "\n| ** Uptime:**\n| 3m 55s\n| ** Memory:**\n| 64.45MB", + "\n| ** Uptime:**\n| 3m 55s\n| ** Memory:**\n| 64.45MB",
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: '<blockquote><sub>↪️ <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">PapiOphidian</a> used <code>/stats</code></sub></blockquote>' formatted_body: '<blockquote><sub> <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">PapiOphidian</a> used <code>/stats</code></sub></blockquote>'
+ '<blockquote><p><strong>Amanda 🎵#2192 <img data-mx-emoticon height=\"32\" src=\"mxc://cadence.moe/LCEqjStXCxvRQccEkuslXEyZ\" title=\":online:\" alt=\":online:\">' + '<blockquote><p><strong>Amanda 🎵#2192 <img data-mx-emoticon height=\"32\" src=\"mxc://cadence.moe/LCEqjStXCxvRQccEkuslXEyZ\" title=\":online:\" alt=\":online:\">'
+ '<br>willow tree, branch 0</strong>' + '<br>willow tree, branch 0</strong>'
+ '<br><strong> Uptime:</strong><br>3m 55s' + '<br><strong> Uptime:</strong><br>3m 55s'
@ -125,8 +125,8 @@ test("message2event embeds: blockquote in embed", async t => {
t.equal(called, 1, "should call getJoinedMembers once") t.equal(called, 1, "should call getJoinedMembers once")
}) })
test("message2event embeds: crazy html is all escaped", async t => { test("message2event embeds: extreme html is all escaped", async t => {
const events = await messageToEvent(data.message_with_embeds.escaping_crazy_html_tags, data.guild.general) const events = await messageToEvent(data.message_with_embeds.extreme_html_escaping, data.guild.general)
t.deepEqual(events, [{ t.deepEqual(events, [{
$type: "m.room.message", $type: "m.room.message",
msgtype: "m.notice", msgtype: "m.notice",
@ -153,10 +153,10 @@ test("message2event embeds: title without url", async t => {
t.deepEqual(events, [{ t.deepEqual(events, [{
$type: "m.room.message", $type: "m.room.message",
msgtype: "m.notice", msgtype: "m.notice",
body: "↪️ PapiOphidian used `/stats`" body: " PapiOphidian used `/stats`"
+ "\n| ## Hi, I'm Amanda!\n| \n| I condone pirating music!", + "\n| ## Hi, I'm Amanda!\n| \n| I condone pirating music!",
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: '<blockquote><sub>↪️ <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">PapiOphidian</a> used <code>/stats</code></sub></blockquote>' formatted_body: '<blockquote><sub> <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">PapiOphidian</a> used <code>/stats</code></sub></blockquote>'
+ `<blockquote><p><strong>Hi, I'm Amanda!</strong></p><p>I condone pirating music!</p></blockquote>`, + `<blockquote><p><strong>Hi, I'm Amanda!</strong></p><p>I condone pirating music!</p></blockquote>`,
"m.mentions": {} "m.mentions": {}
}]) }])
@ -167,10 +167,10 @@ test("message2event embeds: url without title", async t => {
t.deepEqual(events, [{ t.deepEqual(events, [{
$type: "m.room.message", $type: "m.room.message",
msgtype: "m.notice", msgtype: "m.notice",
body: "↪️ PapiOphidian used `/stats`" body: " PapiOphidian used `/stats`"
+ "\n| I condone pirating music!", + "\n| I condone pirating music!",
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: '<blockquote><sub>↪️ <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">PapiOphidian</a> used <code>/stats</code></sub></blockquote>' formatted_body: '<blockquote><sub> <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">PapiOphidian</a> used <code>/stats</code></sub></blockquote>'
+ `<blockquote><p>I condone pirating music!</p></blockquote>`, + `<blockquote><p>I condone pirating music!</p></blockquote>`,
"m.mentions": {} "m.mentions": {}
}]) }])
@ -181,10 +181,10 @@ test("message2event embeds: author without url", async t => {
t.deepEqual(events, [{ t.deepEqual(events, [{
$type: "m.room.message", $type: "m.room.message",
msgtype: "m.notice", msgtype: "m.notice",
body: "↪️ PapiOphidian used `/stats`" body: " PapiOphidian used `/stats`"
+ "\n| ## Amanda\n| \n| I condone pirating music!", + "\n| ## Amanda\n| \n| I condone pirating music!",
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: '<blockquote><sub>↪️ <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">PapiOphidian</a> used <code>/stats</code></sub></blockquote>' formatted_body: '<blockquote><sub> <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">PapiOphidian</a> used <code>/stats</code></sub></blockquote>'
+ `<blockquote><p><strong>Amanda</strong></p><p>I condone pirating music!</p></blockquote>`, + `<blockquote><p><strong>Amanda</strong></p><p>I condone pirating music!</p></blockquote>`,
"m.mentions": {} "m.mentions": {}
}]) }])
@ -195,15 +195,53 @@ test("message2event embeds: author url without name", async t => {
t.deepEqual(events, [{ t.deepEqual(events, [{
$type: "m.room.message", $type: "m.room.message",
msgtype: "m.notice", msgtype: "m.notice",
body: "↪️ PapiOphidian used `/stats`" body: " PapiOphidian used `/stats`"
+ "\n| I condone pirating music!", + "\n| I condone pirating music!",
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: '<blockquote><sub>↪️ <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">PapiOphidian</a> used <code>/stats</code></sub></blockquote>' formatted_body: '<blockquote><sub> <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">PapiOphidian</a> used <code>/stats</code></sub></blockquote>'
+ `<blockquote><p>I condone pirating music!</p></blockquote>`, + `<blockquote><p>I condone pirating music!</p></blockquote>`,
"m.mentions": {} "m.mentions": {}
}]) }])
}) })
test("message2event embeds: 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 “humanitys 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 “humanitys last non-AI made social network”"
+ "<br><br><a href=\"https://automaton-media.com/en/news/4chan-owner-hiroyuki-evangelion-director-hideaki-anno-and-gackt-to-participate-in-humanitys-last-non-ai-made-social-network/\">automaton-media.com/en/news/4chan-owner-hiroyuki-evangelion-director-hideaki-anno-and-gackt-to-participate-in-humanitys-last-non-ai-made-social-network/</a>"
+ "<br><br><strong><a href=\"https://x.com/intent/tweet?in_reply_to=2032003668787020046\">💬</a> 36<a href=\"https://x.com/intent/retweet?tweet_id=2032003668787020046\">🔁</a> 212<a href=\"https://x.com/intent/like?tweet_id=2032003668787020046\">❤</a> 3.0K 👁 131.7K</strong></p>"
+ "<p>📸 https://pbs.twimg.com/media/HDMUyf6bQAM3yts.jpg?name=orig</p>— FixupX</blockquote>"
+ "<p>📸 https://pbs.twimg.com/media/HDMUgxybQAE4FtJ.jpg?name=orig</p>"
+ "<p>📸 https://pbs.twimg.com/media/HDMUrPobgAAeb90.jpg?name=orig</p>"
+ "<p>📸 https://pbs.twimg.com/media/HDMUuy5bgAAInj5.jpg?name=orig</p></blockquote>",
"m.mentions": {}
}])
})
test("message2event embeds: vx image", async t => { test("message2event embeds: vx image", async t => {
const events = await messageToEvent(data.message_with_embeds.vx_image, data.guild.general) const events = await messageToEvent(data.message_with_embeds.vx_image, data.guild.general)
t.deepEqual(events, [{ t.deepEqual(events, [{

View file

@ -4,6 +4,7 @@ const {MatrixServerError} = require("../../matrix/mreq")
const data = require("../../../test/data") const data = require("../../../test/data")
const {mockGetEffectivePower} = require("../../matrix/utils.test") const {mockGetEffectivePower} = require("../../matrix/utils.test")
const Ty = require("../../types") const Ty = require("../../types")
const {db} = require("../../passthrough")
/** /**
* @param {string} roomID * @param {string} roomID
@ -733,6 +734,31 @@ test("message2event: reply to a Discord message that wasn't bridged", async t =>
}]) }])
}) })
test("message2event: reply to a Discord member join (who didn't join on Matrix)", async t => {
const events = await messageToEvent(data.message.reply_to_member_join, data.guild.general)
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.text",
body: "> PEASANT!! joined the room\n\nwhen the broke friend who we pay to bring food shows up at the medieval lord party",
format: "org.matrix.custom.html",
formatted_body: "<blockquote><strong>PEASANT!!</strong> joined the room</blockquote>when the broke friend who we pay to bring food shows up at the medieval lord party",
"m.mentions": {}
}])
})
test("message2event: reply to a Discord member join (who did join on Matrix)", async t => {
db.prepare("INSERT INTO sim (user_id, username, sim_name, mxid) VALUES ('1461677775554478161', 'peasant321_76775', 'peasant321_76775', '@_ooye_peasant321_76775:cadence.moe')").run()
const events = await messageToEvent(data.message.reply_to_member_join, data.guild.general)
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.text",
body: "> PEASANT!! joined the room\n\nwhen the broke friend who we pay to bring food shows up at the medieval lord party",
format: "org.matrix.custom.html",
formatted_body: `<blockquote><a href="https://matrix.to/#/@_ooye_peasant321_76775:cadence.moe">PEASANT!!</a> joined the room</blockquote>when the broke friend who we pay to bring food shows up at the medieval lord party`,
"m.mentions": {}
}])
})
test("message2event: simple written @mention for matrix user", async t => { test("message2event: simple written @mention for matrix user", async t => {
const events = await messageToEvent(data.message.simple_written_at_mention_for_matrix, data.guild.general, {}, { const events = await messageToEvent(data.message.simple_written_at_mention_for_matrix, data.guild.general, {}, {
api: { api: {
@ -789,7 +815,7 @@ test("message2event: simple written @mention for matrix user", async t => {
] ]
}, },
msgtype: "m.text", msgtype: "m.text",
body: "[@ash](https://matrix.to/#/@she_who_brings_destruction:cadence.moe) do you need anything from the store btw as I'm heading there after gym", body: "@ash do you need anything from the store btw as I'm heading there after gym",
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: `<a href="https://matrix.to/#/@she_who_brings_destruction:cadence.moe">@ash</a> do you need anything from the store btw as I'm heading there after gym` formatted_body: `<a href="https://matrix.to/#/@she_who_brings_destruction:cadence.moe">@ash</a> do you need anything from the store btw as I'm heading there after gym`
}]) }])
@ -838,7 +864,7 @@ test("message2event: many written @mentions for matrix users", async t => {
] ]
}, },
msgtype: "m.text", msgtype: "m.text",
body: "[@Cadence](https://matrix.to/#/@cadence:cadence.moe), tell me about @Phil, the creator of the Chin Trick, who has become ever more powerful under the mentorship of @botrac4r and [@huck](https://matrix.to/#/@huckleton:cadence.moe)", 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",
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: `<a href="https://matrix.to/#/@cadence:cadence.moe">@Cadence</a>, tell me about @Phil, the creator of the Chin Trick, who has become ever more powerful under the mentorship of @botrac4r and <a href="https://matrix.to/#/@huckleton:cadence.moe">@huck</a>` formatted_body: `<a href="https://matrix.to/#/@cadence:cadence.moe">@Cadence</a>, tell me about @Phil, the creator of the Chin Trick, who has become ever more powerful under the mentorship of @botrac4r and <a href="https://matrix.to/#/@huckleton:cadence.moe">@huck</a>`
}]) }])
@ -890,7 +916,7 @@ test("message2event: written @mentions may match part of the name", async t => {
] ]
}, },
msgtype: "m.text", msgtype: "m.text",
body: "I wonder if [@cadence](https://matrix.to/#/@secret:cadence.moe) saw this?", body: "I wonder if @cadence saw this?",
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: `I wonder if <a href="https://matrix.to/#/@secret:cadence.moe">@cadence</a> saw this?` formatted_body: `I wonder if <a href="https://matrix.to/#/@secret:cadence.moe">@cadence</a> saw this?`
}]) }])
@ -941,7 +967,7 @@ test("message2event: written @mentions may match part of the mxid", async t => {
] ]
}, },
msgtype: "m.text", msgtype: "m.text",
body: "I wonder if [@huck](https://matrix.to/#/@huckleton:cadence.moe) saw this?", body: "I wonder if @huck saw this?",
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: `I wonder if <a href="https://matrix.to/#/@huckleton:cadence.moe">@huck</a> saw this?` formatted_body: `I wonder if <a href="https://matrix.to/#/@huckleton:cadence.moe">@huck</a> saw this?`
}]) }])
@ -962,6 +988,36 @@ 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 => { test("message2event: entire message may match elaborate display name", async t => {
let called = 0 let called = 0
const events = await messageToEvent({ const events = await messageToEvent({
@ -1007,7 +1063,7 @@ test("message2event: entire message may match elaborate display name", async t =
] ]
}, },
msgtype: "m.text", msgtype: "m.text",
body: "[@Cadence, Maid of Creation, Eye of Clarity, Empress of Hope ☆](https://matrix.to/#/@wa:cadence.moe)", body: "@Cadence, Maid of Creation, Eye of Clarity, Empress of Hope ☆",
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: `<a href="https://matrix.to/#/@wa:cadence.moe">@Cadence, Maid of Creation, Eye of Clarity, Empress of Hope ☆</a>` formatted_body: `<a href="https://matrix.to/#/@wa:cadence.moe">@Cadence, Maid of Creation, Eye of Clarity, Empress of Hope ☆</a>`
}]) }])
@ -1084,7 +1140,7 @@ test("message2event: multiple attachments are combined into the same event where
formatted_body: "hey" formatted_body: "hey"
+ `<br>📄 Uploaded file: <a href="https://bridge.example.org/download/discordcdn/123/456/789.mega">hey.jpg</a> (100 MB)` + `<br>📄 Uploaded file: <a href="https://bridge.example.org/download/discordcdn/123/456/789.mega">hey.jpg</a> (100 MB)`
+ `<br><blockquote>📸 Uploaded SPOILER file: <a href="https://bridge.example.org/download/discordcdn/123/456/SPOILER_secret.jpg">https://bridge.example.org/download/discordcdn/123/456/SPOILER_secret.jpg</a> (38 KB)</blockquote>` + `<br><blockquote>📸 Uploaded SPOILER file: <a href="https://bridge.example.org/download/discordcdn/123/456/SPOILER_secret.jpg">https://bridge.example.org/download/discordcdn/123/456/SPOILER_secret.jpg</a> (38 KB)</blockquote>`
+ `<br>📄 Uploaded file: <a href="https://bridge.example.org/download/discordcdn/123/456/789.mega">hey.jpg</a> (100 MB)` + `📄 Uploaded file: <a href="https://bridge.example.org/download/discordcdn/123/456/789.mega">hey.jpg</a> (100 MB)`
}, { }, {
$type: "m.room.message", $type: "m.room.message",
"m.mentions": {}, "m.mentions": {},
@ -1112,6 +1168,19 @@ test("message2event: type 4 channel name change", async t => {
}]) }])
}) })
test("message2event: type 12 channel follow add", async t => {
const events = await messageToEvent(data.special_message.channel_follow_add, data.guild.general)
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.emote",
body: "set this room to receive announcements from PluralKit #downtime",
format: "org.matrix.custom.html",
formatted_body: "set this room to receive announcements from <strong>PluralKit #downtime</strong>",
"m.mentions": {}
}])
})
test("message2event: thread start message reference", async t => { test("message2event: thread start message reference", async t => {
const events = await messageToEvent(data.special_message.thread_start_context, data.guild.general, {}, { const events = await messageToEvent(data.special_message.thread_start_context, data.guild.general, {}, {
api: { api: {
@ -1204,9 +1273,9 @@ test("message2event: crossposted announcements say where they are crossposted fr
$type: "m.room.message", $type: "m.room.message",
"m.mentions": {}, "m.mentions": {},
msgtype: "m.text", msgtype: "m.text",
body: "[🔀 Chewey Bot Official Server #announcements]\nAll text based commands are now inactive on Chewey Bot\nTo continue using commands you'll need to use them as slash commands", body: "[ Chewey Bot Official Server #announcements]\nAll text based commands are now inactive on Chewey Bot\nTo continue using commands you'll need to use them as slash commands",
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: "🔀 <strong>Chewey Bot Official Server #announcements</strong><br>All text based commands are now inactive on Chewey Bot<br>To continue using commands you'll need to use them as slash commands" formatted_body: " <strong>Chewey Bot Official Server #announcements</strong><br>All text based commands are now inactive on Chewey Bot<br>To continue using commands you'll need to use them as slash commands"
}]) }])
}) })
@ -1275,9 +1344,9 @@ test("message2event: forwarded image", async t => {
t.deepEqual(events, [ t.deepEqual(events, [
{ {
$type: "m.room.message", $type: "m.room.message",
body: "[🔀 Forwarded message]", body: "[ Forwarded message]",
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: "🔀 <em>Forwarded message</em>", formatted_body: " <em>Forwarded message</em>",
"m.mentions": {}, "m.mentions": {},
msgtype: "m.notice", msgtype: "m.notice",
}, },
@ -1316,10 +1385,10 @@ test("message2event: constructed forwarded message", async t => {
t.deepEqual(events, [ t.deepEqual(events, [
{ {
$type: "m.room.message", $type: "m.room.message",
body: "[🔀 Forwarded from #wonderland]" body: "[ Forwarded from #wonderland]"
+ "\n» What's cooking, good looking? :hipposcope:", + "\n» What's cooking, good looking? :hipposcope:",
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: `🔀 <em>Forwarded from wonderland <a href="https://matrix.to/#/!qzDBLKlildpzrrOnFZ:cadence.moe/$tBIT8mO7XTTCgIINyiAIy6M2MSoPAdJenRl_RLyYuaE?via=cadence.moe&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>`, + `<br><blockquote>What's cooking, good looking? <img data-mx-emoticon height="32" src="mxc://cadence.moe/WbYqNlACRuicynBfdnPYtmvc" title=":hipposcope:" alt=":hipposcope:"></blockquote>`,
"m.mentions": {}, "m.mentions": {},
msgtype: "m.text", msgtype: "m.text",
@ -1375,10 +1444,10 @@ test("message2event: constructed forwarded text", async t => {
t.deepEqual(events, [ t.deepEqual(events, [
{ {
$type: "m.room.message", $type: "m.room.message",
body: "[🔀 Forwarded from #amanda-spam]" body: "[ Forwarded from #amanda-spam]"
+ "\n» What's cooking, good looking?", + "\n» What's cooking, good looking?",
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: `🔀 <em>Forwarded from amanda-spam <a href="https://matrix.to/#/!CzvdIdUQXgUjDVKxeU:cadence.moe?via=cadence.moe&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>`, + `<br><blockquote>What's cooking, good looking?</blockquote>`,
"m.mentions": {}, "m.mentions": {},
msgtype: "m.text", msgtype: "m.text",
@ -1398,10 +1467,10 @@ test("message2event: don't scan forwarded messages for mentions", async t => {
t.deepEqual(events, [ t.deepEqual(events, [
{ {
$type: "m.room.message", $type: "m.room.message",
body: "[🔀 Forwarded message]" body: "[ Forwarded message]"
+ "\n» If some folks have spare bandwidth then helping out ArchiveTeam with archiving soon to be deleted research and government data might be worthwhile https://social.luca.run/@luca/113950834185678114", + "\n» If some folks have spare bandwidth then helping out ArchiveTeam with archiving soon to be deleted research and government data might be worthwhile https://social.luca.run/@luca/113950834185678114",
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: `🔀 <em>Forwarded message</em>` formatted_body: ` <em>Forwarded message</em>`
+ `<br><blockquote>If some folks have spare bandwidth then helping out ArchiveTeam with archiving soon to be deleted research and government data might be worthwhile <a href="https://social.luca.run/@luca/113950834185678114">https://social.luca.run/@luca/113950834185678114</a></blockquote>`, + `<br><blockquote>If some folks have spare bandwidth then helping out ArchiveTeam with archiving soon to be deleted research and government data might be worthwhile <a href="https://social.luca.run/@luca/113950834185678114">https://social.luca.run/@luca/113950834185678114</a></blockquote>`,
"m.mentions": {}, "m.mentions": {},
msgtype: "m.text" msgtype: "m.text"
@ -1538,6 +1607,28 @@ test("message2event: vc invite event renders embed with room link", async t => {
]) ])
}) })
test("message2event: expired/invalid invites are sent as-is", async t => {
const events = await messageToEvent({content: "https://discord.gg/placeholder?event=1381190945646710824"}, {}, {}, {
snow: {
invite: {
async getInvite() {
throw new Error(`{"message": "Unknown Invite", "code": 10006}`)
}
}
}
})
t.deepEqual(events, [
{
$type: "m.room.message",
body: "https://discord.gg/placeholder?event=1381190945646710824",
format: "org.matrix.custom.html",
formatted_body: "<a href=\"https://discord.gg/placeholder?event=1381190945646710824\">https://discord.gg/placeholder?event=1381190945646710824</a>",
"m.mentions": {},
msgtype: "m.text",
}
])
})
test("message2event: channel links are converted even inside lists (parser post-processer descends into list items)", async t => { test("message2event: channel links are converted even inside lists (parser post-processer descends into list items)", async t => {
let called = 0 let called = 0
const events = await messageToEvent({ const events = await messageToEvent({
@ -1729,9 +1820,9 @@ test("message2event: forwarded message with unreferenced mention", async t => {
t.deepEqual(events, [{ t.deepEqual(events, [{
$type: "m.room.message", $type: "m.room.message",
msgtype: "m.text", msgtype: "m.text",
body: "[🔀 Forwarded message]\n» @unknown-user:\n» 🎞️ Uploaded file: https://bridge.example.org/download/discordcdn/893634327722721290/1463174815119704114/2022-10-18_16-49-46.mp4 (51 MB)", body: "[ Forwarded message]\n» @unknown-user:\n» 🎞️ Uploaded file: https://bridge.example.org/download/discordcdn/893634327722721290/1463174815119704114/2022-10-18_16-49-46.mp4 (51 MB)",
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: "🔀 <em>Forwarded message</em><br><blockquote>@unknown-user:<br>🎞️ Uploaded file: <a href=\"https://bridge.example.org/download/discordcdn/893634327722721290/1463174815119704114/2022-10-18_16-49-46.mp4\">2022-10-18_16-49-46.mp4</a> (51 MB)</blockquote>", formatted_body: " <em>Forwarded message</em><br><blockquote>@unknown-user:<br>🎞️ Uploaded file: <a href=\"https://bridge.example.org/download/discordcdn/893634327722721290/1463174815119704114/2022-10-18_16-49-46.mp4\">2022-10-18_16-49-46.mp4</a> (51 MB)</blockquote>",
"m.mentions": {} "m.mentions": {}
}]) }])
}) })

View file

@ -22,7 +22,7 @@ function pinsToList(pins, kstate) {
/** @type {string[]} */ /** @type {string[]} */
const result = [] const result = []
for (const pin of pins.items) { for (const pin of pins.items) {
const eventID = select("event_message", "event_id", {message_id: pin.message.id, part: 0}).pluck().get() const eventID = select("event_message", "event_id", {message_id: pin.message.id}, "ORDER BY part ASC").pluck().get()
if (eventID && !alreadyPinned.includes(eventID)) result.push(eventID) if (eventID && !alreadyPinned.includes(eventID)) result.push(eventID)
} }
result.reverse() result.reverse()

View file

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

@ -0,0 +1,43 @@
// @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 // Even though the bridge bot only reacted once on Discord-side, multiple Matrix users may have
// reacted on Matrix-side. Semantically, we want to remove the reaction from EVERY Matrix user. // reacted on Matrix-side. Semantically, we want to remove the reaction from EVERY Matrix user.
// Also need to clean up the database. // Also need to clean up the database.
const hash = utils.getEventIDHash(event.event_id) const hash = utils.getEventIDHash(eventID)
removals.push({eventID, mxid: null, hash}) removals.push({eventID, mxid: null, hash})
} }
if (!lookingAtMatrixReaction && !wantToRemoveMatrixReaction) { if (!lookingAtMatrixReaction && !wantToRemoveMatrixReaction) {

View file

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

View file

@ -52,7 +52,11 @@ class DiscordClient {
/** @type {Map<string, Array<string>>} */ /** @type {Map<string, Array<string>>} */
this.guildChannelMap = new Map() this.guildChannelMap = new Map()
if (listen !== "no") { if (listen !== "no") {
this.cloud.on("event", message => discordPackets.onPacket(this, message, listen)) this.cloud.on("event", message => {
process.nextTick(() => {
discordPackets.onPacket(this, message, listen)
})
})
} }
const addEventLogger = (eventName, logName) => { const addEventLogger = (eventName, logName) => {

View file

@ -26,6 +26,7 @@ const utils = {
client.user = message.d.user client.user = message.d.user
client.application = message.d.application client.application = message.d.application
console.log(`Discord logged in as ${client.user.username}#${client.user.discriminator} (${client.user.id})`) console.log(`Discord logged in as ${client.user.username}#${client.user.discriminator} (${client.user.id})`)
interactions.registerInteractions()
} else if (message.t === "GUILD_CREATE") { } else if (message.t === "GUILD_CREATE") {
message.d.members = message.d.members.filter(m => m.user.id === client.user.id) // only keep the bot's own member - it's needed to determine private channels on web message.d.members = message.d.members.filter(m => m.user.id === client.user.id) // only keep the bot's own member - it's needed to determine private channels on web
@ -47,10 +48,10 @@ const utils = {
if (listen === "full") { if (listen === "full") {
try { try {
interactions.registerInteractions()
await eventDispatcher.checkMissedExpressions(message.d) await eventDispatcher.checkMissedExpressions(message.d)
await eventDispatcher.checkMissedPins(client, message.d)
await eventDispatcher.checkMissedMessages(client, message.d) await eventDispatcher.checkMissedMessages(client, message.d)
await eventDispatcher.checkMissedPins(client, message.d)
await eventDispatcher.checkMissedLeaves(client, message.d)
} catch (e) { } catch (e) {
console.error("Failed to sync missed events. To retry, please fix this error and restart OOYE:") console.error("Failed to sync missed events. To retry, please fix this error and restart OOYE:")
console.error(e) console.error(e)

View file

@ -2,6 +2,7 @@
const assert = require("assert").strict const assert = require("assert").strict
const DiscordTypes = require("discord-api-types/v10") const DiscordTypes = require("discord-api-types/v10")
const {id: botID} = require("../../addbot")
const {sync, db, select, from} = require("../passthrough") const {sync, db, select, from} = require("../passthrough")
/** @type {import("./actions/send-message")}) */ /** @type {import("./actions/send-message")}) */
@ -32,10 +33,14 @@ const speedbump = sync.require("./actions/speedbump")
const retrigger = sync.require("./actions/retrigger") const retrigger = sync.require("./actions/retrigger")
/** @type {import("./actions/set-presence")} */ /** @type {import("./actions/set-presence")} */
const setPresence = sync.require("./actions/set-presence") const setPresence = sync.require("./actions/set-presence")
/** @type {import("./actions/remove-member")} */
const removeMember = sync.require("./actions/remove-member")
/** @type {import("./actions/poll-vote")} */ /** @type {import("./actions/poll-vote")} */
const vote = sync.require("./actions/poll-vote") const vote = sync.require("./actions/poll-vote")
/** @type {import("../m2d/event-dispatcher")} */ /** @type {import("../m2d/event-dispatcher")} */
const matrixEventDispatcher = sync.require("../m2d/event-dispatcher") const matrixEventDispatcher = sync.require("../m2d/event-dispatcher")
/** @type {import("../m2d/actions/redact.js")} */
const redact = sync.require("../m2d/actions/redact.js")
/** @type {import("../discord/interactions/matrix-info")} */ /** @type {import("../discord/interactions/matrix-info")} */
const matrixInfoInteraction = sync.require("../discord/interactions/matrix-info") const matrixInfoInteraction = sync.require("../discord/interactions/matrix-info")
@ -100,7 +105,7 @@ module.exports = {
// console.log(`[check missed messages] in ${channel.id} (${guild.name} / ${channel.name}) because its last message ${channel.last_message_id} is not in the database`) // console.log(`[check missed messages] in ${channel.id} (${guild.name} / ${channel.name}) because its last message ${channel.last_message_id} is not in the database`)
let messages let messages
try { try {
messages = await client.snow.channel.getChannelMessages(channel.id, {limit: 50}) messages = await client.snow.channel.getChannelMessages(channel.id, {limit: 100})
} catch (e) { } catch (e) {
if (e.message === `{"message": "Missing Access", "code": 50001}`) { // pathetic error handling from SnowTransfer if (e.message === `{"message": "Missing Access", "code": 50001}`) { // pathetic error handling from SnowTransfer
console.log(`[check missed messages] no permissions to look back in channel ${channel.name} (${channel.id})`) console.log(`[check missed messages] no permissions to look back in channel ${channel.name} (${channel.id})`)
@ -123,6 +128,7 @@ module.exports = {
// Send in order // Send in order
for (let i = Math.min(messages.length, latestBridgedMessageIndex)-1; i >= 0; i--) { for (let i = Math.min(messages.length, latestBridgedMessageIndex)-1; i >= 0; i--) {
const message = messages[i] const message = messages[i]
if (message.type === DiscordTypes.MessageType.UserJoin) continue // since join announcements don't become events, it would be a repetition to act on them during backfill
if (!members.has(message.author.id)) members.set(message.author.id, await client.snow.guild.getGuildMember(guild.id, message.author.id).catch(() => undefined)) if (!members.has(message.author.id)) members.set(message.author.id, await client.snow.guild.getGuildMember(guild.id, message.author.id).catch(() => undefined))
await module.exports.MESSAGE_CREATE(client, { await module.exports.MESSAGE_CREATE(client, {
@ -172,6 +178,31 @@ module.exports = {
await createSpace.syncSpaceExpressions(data, true) await createSpace.syncSpaceExpressions(data, true)
}, },
/**
* When logging back in, check if any members left while we were gone.
* Do this by getting the member list from Discord and seeing who we still have locally that isn't there in the response.
* @param {import("./discord-client")} client
* @param {DiscordTypes.GatewayGuildCreateDispatchData} guild
*/
async checkMissedLeaves(client, guild) {
const maxLimit = 1000
if (guild.member_count >= maxLimit) return // too large to want to scan
const discordMembers = await client.snow.guild.getGuildMembers(guild.id, {limit: maxLimit})
if (discordMembers.length >= maxLimit) return // response was maxed out, there are guild members that weren't listed, can't act safely
const discordMembersSet = new Set(discordMembers.map(m => m.user.id))
// no indexes on this one but I'll cope
const membersAddedOnMatrix = new Set(from("sim").join("sim_member", "mxid").join("channel_room", "room_id")
.pluck("user_id").selectUnsafe("DISTINCT user_id").where({guild_id: guild.id}).and("AND user_id not like '%-%' and user_id not like '%\\_%' escape '\\'").all())
const userInstalledAppIDs = new Set(from("app_user_install").pluck("app_bot_id").selectUnsafe("DISTINCT app_bot_id").where({guild_id: guild.id}).all())
// loop over members added on matrix and if the member does not exist on discord-side then they should be removed
for (const userID of membersAddedOnMatrix) {
if (userInstalledAppIDs.has(userID)) continue // skip user installed apps here since they're never true members - they'll be removed by removeMember when the associated user is removed
if (!discordMembersSet.has(userID)) {
await removeMember.removeMember(userID, guild.id)
}
}
},
/** /**
* Announces to the parent room that the thread room has been created. * Announces to the parent room that the thread room has been created.
* See notes.md, "Ignore MESSAGE_UPDATE and bridge THREAD_CREATE as the announcement" * See notes.md, "Ignore MESSAGE_UPDATE and bridge THREAD_CREATE as the announcement"
@ -211,6 +242,14 @@ module.exports = {
} }
}, },
/**
* @param {import("./discord-client")} client
* @param {DiscordTypes.GatewayGuildMemberRemoveDispatchData} data
*/
async GUILD_MEMBER_REMOVE(client, data) {
await removeMember.removeMember(data.user.id, data.guild_id)
},
/** /**
* @param {import("./discord-client")} client * @param {import("./discord-client")} client
* @param {DiscordTypes.GatewayChannelUpdateDispatchData} channelOrThread * @param {DiscordTypes.GatewayChannelUpdateDispatchData} channelOrThread
@ -274,13 +313,13 @@ module.exports = {
if (!createRoom.existsOrAutocreatable(channel, guild.id)) return // Check that the sending-to room exists or is autocreatable if (!createRoom.existsOrAutocreatable(channel, guild.id)) return // Check that the sending-to room exists or is autocreatable
const {affected, row} = await speedbump.maybeDoSpeedbump(message.channel_id, message.id) const {affected, row} = await speedbump.maybeDoSpeedbump(message.channel_id, message.id, message.author.id)
if (affected) return if (affected) return
// @ts-ignore // @ts-ignore
await sendMessage.sendMessage(message, channel, guild, row) await sendMessage.sendMessage(message, channel, guild, row)
retrigger.messageFinishedBridging(message.id) retrigger.finishedBridging(message.id)
}, },
/** /**
@ -296,12 +335,12 @@ module.exports = {
if (dUtils.isEphemeralMessage(data)) return // Ephemeral messages are for the eyes of the receiver only! if (dUtils.isEphemeralMessage(data)) return // Ephemeral messages are for the eyes of the receiver only!
// Edits need to go through the speedbump as well. If the message is delayed but the edit isn't, we don't have anything to edit from. // Edits need to go through the speedbump as well. If the message is delayed but the edit isn't, we don't have anything to edit from.
const {affected, row} = await speedbump.maybeDoSpeedbump(data.channel_id, data.id) const {affected, row} = await speedbump.maybeDoSpeedbump(data.channel_id, data.id, data.author.id)
if (affected) return if (affected) return
if (!row) { if (!row) {
// Check that the sending-to room exists, and deal with Eventual Consistency(TM) // Check that the sending-to room exists, and deal with Eventual Consistency(TM)
if (retrigger.eventNotFoundThenRetrigger(data.id, module.exports.MESSAGE_UPDATE, client, data)) return if (!await retrigger.waitForMessage(data.id)) return
} }
/** @type {DiscordTypes.GatewayMessageCreateDispatchData} */ /** @type {DiscordTypes.GatewayMessageCreateDispatchData} */
@ -339,6 +378,16 @@ module.exports = {
* @param {DiscordTypes.GatewayMessageReactionRemoveDispatchData | DiscordTypes.GatewayMessageReactionRemoveEmojiDispatchData | DiscordTypes.GatewayMessageReactionRemoveAllDispatchData} data * @param {DiscordTypes.GatewayMessageReactionRemoveDispatchData | DiscordTypes.GatewayMessageReactionRemoveEmojiDispatchData | DiscordTypes.GatewayMessageReactionRemoveAllDispatchData} data
*/ */
async onSomeReactionsRemoved(client, data) { async onSomeReactionsRemoved(client, data) {
// Don't attempt to double-bridge our own m2d deleted reactions back to Matrix
if ("user_id" in data && data.user_id === botID) {
const emojiIdOrName = data.emoji.id || data.emoji.name
const i = redact.m2dDeletedReactions.findIndex(x => data.message_id === x.messageID && emojiIdOrName === x.emojiIdOrName)
if (i !== -1) {
redact.m2dDeletedReactions.splice(i, 1)
return
}
}
await removeReaction.removeSomeReactions(data) await removeReaction.removeSomeReactions(data)
}, },
@ -348,7 +397,7 @@ module.exports = {
*/ */
async MESSAGE_DELETE(client, data) { async MESSAGE_DELETE(client, data) {
speedbump.onMessageDelete(data.id) speedbump.onMessageDelete(data.id)
if (retrigger.eventNotFoundThenRetrigger(data.id, module.exports.MESSAGE_DELETE, client, data)) return if (!await retrigger.waitForMessage(data.id)) return
await deleteMessage.deleteMessage(data) await deleteMessage.deleteMessage(data)
}, },
@ -396,12 +445,12 @@ module.exports = {
* @param {DiscordTypes.GatewayMessagePollVoteDispatchData} data * @param {DiscordTypes.GatewayMessagePollVoteDispatchData} data
*/ */
async MESSAGE_POLL_VOTE_ADD(client, data) { async MESSAGE_POLL_VOTE_ADD(client, data) {
if (retrigger.eventNotFoundThenRetrigger(data.message_id, module.exports.MESSAGE_POLL_VOTE_ADD, client, data)) return if (!await retrigger.waitForMessage(data.message_id)) return
await vote.addVote(data) await vote.addVote(data)
}, },
async MESSAGE_POLL_VOTE_REMOVE(client, data) { async MESSAGE_POLL_VOTE_REMOVE(client, data) {
if (retrigger.eventNotFoundThenRetrigger(data.message_id, module.exports.MESSAGE_POLL_VOTE_REMOVE, client, data)) return if (!await retrigger.waitForMessage(data.message_id)) return
await vote.removeVote(data) await vote.removeVote(data)
}, },

View file

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

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

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

View file

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

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

@ -1,4 +1,10 @@
export type Models = { export type Models = {
app_user_install: {
guild_id: string
app_bot_id: string
user_id: string
}
auto_emoji: { auto_emoji: {
name: string name: string
emoji_id: string emoji_id: string
@ -104,6 +110,11 @@ export type Models = {
historical_room_index: number historical_room_index: number
} }
role_default: {
guild_id: string
role_id: string
}
room_upgrade_pending: { room_upgrade_pending: {
new_room_id: string new_room_id: string
old_room_id: string old_room_id: string

View file

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

View file

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

View file

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

View file

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

View file

@ -91,40 +91,32 @@ function registerInteractions() {
async function dispatchInteraction(interaction) { async function dispatchInteraction(interaction) {
const interactionId = interaction.data?.["custom_id"] || interaction.data?.["name"] const interactionId = interaction.data?.["custom_id"] || interaction.data?.["name"]
try { try {
if (interaction.type === DiscordTypes.InteractionType.MessageComponent || interaction.type === DiscordTypes.InteractionType.ModalSubmit) { if (interactionId === "Matrix info") {
// All we get is custom_id, don't know which context the button was clicked in. await matrixInfo.interact(interaction)
// So we namespace these ourselves in the custom_id. Currently the only existing namespace is POLL_. } else if (interactionId === "invite") {
if (interaction.data.custom_id.startsWith("POLL_")) { await invite.interact(interaction)
await poll.interact(interaction) } else if (interactionId === "invite_channel") {
await invite.interactButton(interaction)
} else if (interactionId === "Permissions") {
await permissions.interact(interaction)
} else if (interactionId === "permissions_edit") {
await permissions.interactEdit(interaction)
} else if (interactionId === "Responses") {
/** @type {DiscordTypes.APIMessageApplicationCommandGuildInteraction} */ // @ts-ignore
const messageInteraction = interaction
if (select("poll", "message_id", {message_id: messageInteraction.data.target_id}).get()) {
await pollResponses.interact(messageInteraction)
} else { } else {
throw new Error(`Unknown message component ${interaction.data.custom_id}`) await reactions.interact(messageInteraction)
} }
} else if (interactionId === "ping") {
await ping.interact(interaction)
} else if (interactionId === "privacy") {
await privacy.interact(interaction)
} else if (interactionId.startsWith("POLL_")) {
await poll.interact(interaction)
} else { } else {
if (interactionId === "Matrix info") { throw new Error(`Unknown interaction ${interactionId}`)
await matrixInfo.interact(interaction)
} else if (interactionId === "invite") {
await invite.interact(interaction)
} else if (interactionId === "invite_channel") {
await invite.interactButton(interaction)
} else if (interactionId === "Permissions") {
await permissions.interact(interaction)
} else if (interactionId === "permissions_edit") {
await permissions.interactEdit(interaction)
} else if (interactionId === "Responses") {
/** @type {DiscordTypes.APIMessageApplicationCommandGuildInteraction} */ // @ts-ignore
const messageInteraction = interaction
if (select("poll", "message_id", {message_id: messageInteraction.data.target_id}).get()) {
await pollResponses.interact(messageInteraction)
} else {
await reactions.interact(messageInteraction)
}
} else if (interactionId === "ping") {
await ping.interact(interaction)
} else if (interactionId === "privacy") {
await privacy.interact(interaction)
} else {
throw new Error(`Unknown interaction ${interactionId}`)
}
} }
} catch (e) { } catch (e) {
let stackLines = null let stackLines = null

View file

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

View file

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

View file

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

View file

@ -9,7 +9,7 @@ const sharp = require("sharp")
const api = sync.require("../../matrix/api") const api = sync.require("../../matrix/api")
/** @type {import("../../matrix/mreq")} */ /** @type {import("../../matrix/mreq")} */
const mreq = sync.require("../../matrix/mreq") const mreq = sync.require("../../matrix/mreq")
const streamMimeType = require("stream-mime-type") const {streamType} = require("@cloudrac3r/stream-type")
const WIDTH = 160 const WIDTH = 160
const HEIGHT = 160 const HEIGHT = 160
@ -26,13 +26,13 @@ async function getAndResizeSticker(mxc) {
} }
const streamIn = Readable.fromWeb(res.body) const streamIn = Readable.fromWeb(res.body)
const { stream, mime } = await streamMimeType.getMimeType(streamIn) const {streamThrough, type} = await streamType(streamIn)
const animated = ["image/gif", "image/webp"].includes(mime) const animated = ["image/gif", "image/webp"].includes(type)
const transformer = sharp({animated: animated}) const transformer = sharp({animated: animated})
.resize(WIDTH, HEIGHT, {fit: "inside", background: {r: 0, g: 0, b: 0, alpha: 0}}) .resize(WIDTH, HEIGHT, {fit: "inside", background: {r: 0, g: 0, b: 0, alpha: 0}})
.webp() .webp()
stream.pipe(transformer) streamThrough.pipe(transformer)
return Readable.toWeb(transformer) return Readable.toWeb(transformer)
} }

View file

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

View file

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

View file

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

View file

@ -29,6 +29,8 @@ const pollComponents = sync.require("./poll-components")
const setupEmojis = sync.require("../actions/setup-emojis") const setupEmojis = sync.require("../actions/setup-emojis")
/** @type {import("../../d2m/converters/user-to-mxid")} */ /** @type {import("../../d2m/converters/user-to-mxid")} */
const userToMxid = sync.require("../../d2m/converters/user-to-mxid") const userToMxid = sync.require("../../d2m/converters/user-to-mxid")
/** @type {import("../../web/routes/letter-avatar")} */
const letterAvatar = sync.require("../../web/routes/letter-avatar")
/** @type {[RegExp, string][]} */ /** @type {[RegExp, string][]} */
const markdownEscapes = [ const markdownEscapes = [
@ -471,7 +473,8 @@ async function checkWrittenMentions(content, senderMxid, roomID, guild, di) {
// @ts-ignore - typescript doesn't know about indices yet // @ts-ignore - typescript doesn't know about indices yet
content: content.slice(0, writtenMentionMatch.indices[1][0]-1) + `@everyone` + content.slice(writtenMentionMatch.indices[1][1]), content: content.slice(0, writtenMentionMatch.indices[1][0]-1) + `@everyone` + content.slice(writtenMentionMatch.indices[1][1]),
ensureJoined: [], ensureJoined: [],
allowedMentionsParse: ["everyone"] allowedMentionsParse: ["everyone"],
allowedMentionsUsers: []
} }
} }
} else if (writtenMentionMatch[1].length < 40) { // the API supports up to 100 characters, but really if you're searching more than 40, something messed up } else if (writtenMentionMatch[1].length < 40) { // the API supports up to 100 characters, but really if you're searching more than 40, something messed up
@ -482,7 +485,8 @@ async function checkWrittenMentions(content, senderMxid, roomID, guild, di) {
// @ts-ignore - typescript doesn't know about indices yet // @ts-ignore - typescript doesn't know about indices yet
content: content.slice(0, writtenMentionMatch.indices[1][0]-1) + `<@${results[0].user.id}>` + content.slice(writtenMentionMatch.indices[1][1]), content: content.slice(0, writtenMentionMatch.indices[1][0]-1) + `<@${results[0].user.id}>` + content.slice(writtenMentionMatch.indices[1][1]),
ensureJoined: [results[0].user], ensureJoined: [results[0].user],
allowedMentionsParse: [] allowedMentionsParse: [],
allowedMentionsUsers: [results[0].user.id]
} }
} }
} }
@ -544,16 +548,34 @@ async function eventToMessage(event, guild, channel, di) {
let displayName = event.sender let displayName = event.sender
let avatarURL = undefined let avatarURL = undefined
const allowedMentionsParse = ["users", "roles"] const allowedMentionsParse = ["users", "roles"]
const allowedMentionsUsers = []
/** @type {string[]} */ /** @type {string[]} */
let messageIDsToEdit = [] let messageIDsToEdit = []
let replyLine = "" let replyLine = ""
// Extract a basic display name from the sender // Extract a basic display name from the sender
const match = event.sender.match(/^@(.*?):/) const match = event.sender.match(/^@(.*?):/)
if (match) displayName = match[1] if (match) displayName = match[1]
// Try to extract an accurate display name and avatar URL from the member event // Try to extract an accurate display name and avatar URL from the member event
const member = await getMemberFromCacheOrHomeserver(event.room_id, event.sender, di?.api) const member = await getMemberFromCacheOrHomeserver(event.room_id, event.sender, di?.api)
if (member.displayname) displayName = member.displayname if (member.displayname) displayName = member.displayname
if (member.avatar_url) avatarURL = mxUtils.getPublicUrlForMxc(member.avatar_url) if (member.avatar_url) avatarURL = mxUtils.getPublicUrlForMxc(member.avatar_url)
// MSC4144: Override display name and avatar from per-message profile if present
const perMessageProfile = event.content["com.beeper.per_message_profile"]
if (perMessageProfile?.displayname) displayName = perMessageProfile.displayname
if (perMessageProfile && "avatar_url" in perMessageProfile) {
if (perMessageProfile.avatar_url) {
// use provided avatar_url
avatarURL = mxUtils.getPublicUrlForMxc(perMessageProfile.avatar_url)
} else if (perMessageProfile.avatar_url === "") {
// empty string avatar_url clears the avatar
avatarURL = undefined
}
// else, omitted/null falls back to member avatar
}
// If the display name is too long to be put into the webhook (80 characters is the maximum), // If the display name is too long to be put into the webhook (80 characters is the maximum),
// put the excess characters into displayNameRunoff, later to be put at the top of the message // put the excess characters into displayNameRunoff, later to be put at the top of the message
let [displayNameShortened, displayNameRunoff] = splitDisplayName(displayName) let [displayNameShortened, displayNameRunoff] = splitDisplayName(displayName)
@ -562,6 +584,13 @@ async function eventToMessage(event, guild, channel, di) {
displayNameRunoff = "" displayNameRunoff = ""
} }
// Avatar post-processing. Use a thumbnail for media, or generate letter avatar if none present.
if (avatarURL) {
avatarURL = avatarURL + "?preset=avatar"
} else {
avatarURL = letterAvatar.getLetterAvatarURL(event.sender, displayNameShortened)
}
let content = event.content["body"] || "" // ultimate fallback let content = event.content["body"] || "" // ultimate fallback
/** @type {{id: string, filename: string}[]} */ /** @type {{id: string, filename: string}[]} */
const attachments = [] const attachments = []
@ -763,7 +792,7 @@ async function eventToMessage(event, guild, channel, di) {
// Generate a reply preview for a standard message // Generate a reply preview for a standard message
repliedToContent = repliedToContent.replace(/.*<\/mx-reply>/s, "") // Remove everything before replies, so just use the actual message body repliedToContent = repliedToContent.replace(/.*<\/mx-reply>/s, "") // Remove everything before replies, so just use the actual message body
repliedToContent = repliedToContent.replace(/^\s*<blockquote>.*?<\/blockquote>(.....)/s, "$1") // If the message starts with a blockquote, don't count it and use the message body afterwards repliedToContent = repliedToContent.replace(/^\s*<blockquote>.*?<\/blockquote>(.....)/s, "$1") // If the message starts with a blockquote, don't count it and use the message body afterwards
repliedToContent = repliedToContent.replace(/(?:\n|<br>)+/g, " ") // Should all be on one line repliedToContent = repliedToContent.replace(/(?:\n|<br ?\/?>)+/g, " ") // Should all be on one line
repliedToContent = repliedToContent.replace(/<span [^>]*data-mx-spoiler\b[^>]*>.*?<\/span>/g, "[spoiler]") // Good enough method of removing spoiler content. (I don't want to break out the HTML parser unless I have to.) repliedToContent = repliedToContent.replace(/<span [^>]*data-mx-spoiler\b[^>]*>.*?<\/span>/g, "[spoiler]") // Good enough method of removing spoiler content. (I don't want to break out the HTML parser unless I have to.)
repliedToContent = repliedToContent.replace(/<img([^>]*)>/g, (_, att) => { // Convert Matrix emoji images into Discord emoji markdown repliedToContent = repliedToContent.replace(/<img([^>]*)>/g, (_, att) => { // Convert Matrix emoji images into Discord emoji markdown
const mxcUrlMatch = att.match(/\bsrc="(mxc:\/\/[^"]+)"/) const mxcUrlMatch = att.match(/\bsrc="(mxc:\/\/[^"]+)"/)
@ -801,7 +830,7 @@ async function eventToMessage(event, guild, channel, di) {
} }
// Handling mentions of Discord users // Handling mentions of Discord users
input = input.replace(/("https:\/\/matrix.to\/#\/((?:@|%40)[^"]+)")>/g, (whole, attributeValue, mxid) => { input = input.replace(/("https:\/\/matrix.to\/#\/((?:@|%40)[^"]+)")/g, (whole, attributeValue, mxid) => {
mxid = decodeURIComponent(mxid) mxid = decodeURIComponent(mxid)
if (mxUtils.eventSenderIsFromDiscord(mxid)) { if (mxUtils.eventSenderIsFromDiscord(mxid)) {
// Handle mention of an OOYE sim user by their mxid // Handle mention of an OOYE sim user by their mxid
@ -856,8 +885,9 @@ async function eventToMessage(event, guild, channel, di) {
const doc = domino.createDocument( const doc = domino.createDocument(
// DOM parsers arrange elements in the <head> and <body>. Wrapping in a custom element ensures elements are reliably arranged in a single element. // DOM parsers arrange elements in the <head> and <body>. Wrapping in a custom element ensures elements are reliably arranged in a single element.
'<x-turndown id="turndown-root">' + input + '</x-turndown>' '<x-turndown id="turndown-root">' + input + '</x-turndown>'
); )
const root = doc.getElementById("turndown-root"); const root = doc.getElementById("turndown-root")
assert(root)
async function forEachNode(event, node) { async function forEachNode(event, node) {
for (; node; node = node.nextSibling) { for (; node; node = node.nextSibling) {
// Check written mentions // Check written mentions
@ -871,9 +901,12 @@ async function eventToMessage(event, guild, channel, di) {
} }
// Check for incompatible backticks in code blocks // Check for incompatible backticks in code blocks
let preNode let preNode
if (node.nodeType === 3 && node.nodeValue.includes("```") && (preNode = nodeIsChildOf(node, ["PRE"]))) { let isBackticksTextInPre = node.nodeType === 3 && node.nodeValue.includes("```") && (preNode = nodeIsChildOf(node, ["PRE"]))
let isLongPre = node.tagName === "PRE" && node.textContent.length > 1800 && (preNode = node)
if (isBackticksTextInPre || isLongPre) {
if (preNode.firstChild?.nodeName === "CODE") { if (preNode.firstChild?.nodeName === "CODE") {
const ext = preNode.firstChild.className.match(/language-(\S+)/)?.[1] || "txt" let ext = preNode.firstChild.className.match(/language-(\S+)/)?.[1]
if (!dUtils.supportedPlaintextPreviewExtensions.has(ext)) ext = "txt"
const filename = `inline_code.${ext}` const filename = `inline_code.${ext}`
// Build the replacement <code> node // Build the replacement <code> node
const replacementCode = doc.createElement("code") const replacementCode = doc.createElement("code")
@ -898,7 +931,7 @@ async function eventToMessage(event, guild, channel, di) {
let shouldSuppress = inBody !== -1 && event.content.body[inBody-1] === "<" let shouldSuppress = inBody !== -1 && event.content.body[inBody-1] === "<"
if (!shouldSuppress && guild?.roles) { if (!shouldSuppress && guild?.roles) {
// Suppress if regular users don't have permission // Suppress if regular users don't have permission
const permissions = dUtils.getPermissions(guild.id, [], guild.roles) const permissions = dUtils.getDefaultPermissions(guild, channel?.permission_overwrites)
const canEmbedLinks = dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.EmbedLinks) const canEmbedLinks = dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.EmbedLinks)
shouldSuppress = !canEmbedLinks shouldSuppress = !canEmbedLinks
} }
@ -910,6 +943,7 @@ async function eventToMessage(event, guild, channel, di) {
} }
} }
await forEachNode(event, root) await forEachNode(event, root)
if (perMessageProfile?.has_fallback) root.querySelectorAll("[data-mx-profile-fallback]").forEach(x => x.remove())
// SPRITE SHEET EMOJIS FEATURE: Emojis at the end of the message that we don't know about will be reuploaded as a sprite sheet. // SPRITE SHEET EMOJIS FEATURE: Emojis at the end of the message that we don't know about will be reuploaded as a sprite sheet.
// First we need to determine which emojis are at the end. // First we need to determine which emojis are at the end.
@ -941,6 +975,10 @@ async function eventToMessage(event, guild, channel, di) {
} else { } else {
// Looks like we're using the plaintext body! // Looks like we're using the plaintext body!
content = event.content.body content = event.content.body
if (perMessageProfile?.has_fallback && perMessageProfile.displayname && content.startsWith(perMessageProfile.displayname + ": ")) {
// Strip the display name prefix fallback added for clients that don't support per-message profiles
content = content.slice(perMessageProfile.displayname.length + 2)
}
if (event.content.msgtype === "m.emote") { if (event.content.msgtype === "m.emote") {
content = `* ${displayName} ${content}` content = `* ${displayName} ${content}`
@ -961,7 +999,7 @@ async function eventToMessage(event, guild, channel, di) {
// Suppress if regular users don't have permission // Suppress if regular users don't have permission
if (!shouldSuppress && guild?.roles) { if (!shouldSuppress && guild?.roles) {
const permissions = dUtils.getPermissions(guild.id, [], guild.roles, undefined, channel.permission_overwrites) const permissions = dUtils.getDefaultPermissions(guild, channel.permission_overwrites)
const canEmbedLinks = dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.EmbedLinks) const canEmbedLinks = dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.EmbedLinks)
shouldSuppress = !canEmbedLinks shouldSuppress = !canEmbedLinks
} }
@ -986,16 +1024,34 @@ async function eventToMessage(event, guild, channel, di) {
} }
} }
// Complete content
content = displayNameRunoff + replyLine + content content = displayNameRunoff + replyLine + content
// Split into 2000 character chunks // Split into 2000 character chunks
const chunks = chunk(content, 2000) const chunks = chunk(content, 2000)
// If m.mentions is specified and valid, overwrite allowedMentionsParse with a converted m.mentions
let allowed_mentions = {parse: allowedMentionsParse}
if (event.content["m.mentions"]) {
// Combine requested mentions with detected written mentions to get the full list
if (Array.isArray(event.content["m.mentions"].user_ids)) {
for (const mxid of event.content["m.mentions"].user_ids) {
const user_id = select("sim", "user_id", {mxid}).pluck().get()
if (!user_id) continue
allowedMentionsUsers.push(
select("sim_proxy", "proxy_owner_id", {user_id}).pluck().get() || user_id
)
}
}
// Specific mentions were requested, so do not parse users
allowed_mentions.parse = allowed_mentions.parse.filter(x => x !== "users")
allowed_mentions.users = allowedMentionsUsers
}
// Assemble chunks into Discord messages content
/** @type {(DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | stream.Readable}[]})[]} */ /** @type {(DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | stream.Readable}[]})[]} */
const messages = chunks.map(content => ({ const messages = chunks.map(content => ({
content, content,
allowed_mentions: { allowed_mentions,
parse: allowedMentionsParse
},
username: displayNameShortened, username: displayNameShortened,
avatar_url: avatarURL avatar_url: avatarURL
})) }))

File diff suppressed because it is too large Load diff

View file

@ -94,6 +94,11 @@ function printError(type, source, e, payload) {
console.dir(payload, {depth: null}) console.dir(payload, {depth: null})
} }
/** @param {string} stack */
function cleanErrorStack(stack) {
return stack.replace(/(\/webhooks\/[0-9]+\/)[a-zA-Z0-9_-]+/g, "$1(redacted)")
}
/** /**
* @param {string} roomID * @param {string} roomID
* @param {"Discord" | "Matrix"} source * @param {"Discord" | "Matrix"} source
@ -134,7 +139,7 @@ async function sendError(roomID, source, type, e, payload) {
builder.addLine(errorIntroLine) builder.addLine(errorIntroLine)
// Where // Where
const stack = stringifyErrorStack(e) const stack = cleanErrorStack(stringifyErrorStack(e))
builder.addLine(`Error trace:\n${stack}`, tag`<details><summary>Error trace</summary><pre>${stack}</pre></details>`) builder.addLine(`Error trace:\n${stack}`, tag`<details><summary>Error trace</summary><pre>${stack}</pre></details>`)
// How // How
@ -143,7 +148,7 @@ async function sendError(roomID, source, type, e, payload) {
// Send // Send
try { try {
await api.sendEvent(roomID, "m.room.message", { const errorEventID = await api.sendEvent(roomID, "m.room.message", {
...builder.get(), ...builder.get(),
"moe.cadence.ooye.error": { "moe.cadence.ooye.error": {
source: source.toLowerCase(), source: source.toLowerCase(),
@ -153,6 +158,14 @@ async function sendError(roomID, source, type, e, payload) {
user_ids: ["@cadence:cadence.moe"] user_ids: ["@cadence:cadence.moe"]
} }
}) })
// Add reaction indicating that errors may be retried
await api.sendEvent(roomID, "m.reaction", {
"m.relates_to": {
rel_type: "m.annotation",
event_id: errorEventID,
key: "🔁"
}
})
} catch (e) {} } catch (e) {}
} }
@ -172,6 +185,7 @@ const errorRetrySema = new Semaphore()
* @param {Ty.Event.Outer<Ty.Event.M_Reaction>} reactionEvent * @param {Ty.Event.Outer<Ty.Event.M_Reaction>} reactionEvent
*/ */
async function onRetryReactionAdd(reactionEvent) { async function onRetryReactionAdd(reactionEvent) {
if (reactionEvent.sender === `@${reg.sender_localpart}:${reg.ooye.server_name}`) return // Don't respond to the bot's own indicative reaction
const roomID = reactionEvent.room_id const roomID = reactionEvent.room_id
await errorRetrySema.request(async () => { await errorRetrySema.request(async () => {
const event = await api.getEvent(roomID, reactionEvent.content["m.relates_to"]?.event_id) const event = await api.getEvent(roomID, reactionEvent.content["m.relates_to"]?.event_id)
@ -211,7 +225,7 @@ async event => {
// @ts-ignore // @ts-ignore
await matrixCommandHandler.execute(event) await matrixCommandHandler.execute(event)
} }
retrigger.messageFinishedBridging(event.event_id) retrigger.finishedBridging(event.event_id)
await api.ackEvent(event) await api.ackEvent(event)
})) }))
@ -222,7 +236,7 @@ sync.addTemporaryListener(as, "type:m.sticker", guard("m.sticker",
async event => { async event => {
if (utils.eventSenderIsFromDiscord(event.sender)) return if (utils.eventSenderIsFromDiscord(event.sender)) return
const messageResponses = await sendEvent.sendEvent(event) const messageResponses = await sendEvent.sendEvent(event)
retrigger.messageFinishedBridging(event.event_id) retrigger.finishedBridging(event.event_id)
await api.ackEvent(event) await api.ackEvent(event)
})) }))
@ -413,6 +427,7 @@ async event => {
console.error(e) console.error(e)
return await api.leaveRoomWithReason(event.room_id, `I wasn't able to find out what this room is. Please report this as a bug. Check console for more details. (${e.toString()})`) return await api.leaveRoomWithReason(event.room_id, `I wasn't able to find out what this room is. Please report this as a bug. Check console for more details. (${e.toString()})`)
} }
if (inviteRoomState?.encryption) return await api.leaveRoomWithReason(event.room_id, "Encrypted rooms are not supported for bridging. Please use an unencrypted room.")
if (!inviteRoomState?.name) return await api.leaveRoomWithReason(event.room_id, `Please only invite me to rooms that have a name/avatar set. Update the room details and reinvite.`) if (!inviteRoomState?.name) return await api.leaveRoomWithReason(event.room_id, `Please only invite me to rooms that have a name/avatar set. Update the room details and reinvite.`)
await api.joinRoom(event.room_id) await api.joinRoom(event.room_id)
db.prepare("REPLACE INTO invite (mxid, room_id, type, name, topic, avatar) VALUES (?, ?, ?, ?, ?, ?)").run(event.sender, event.room_id, inviteRoomState.type, inviteRoomState.name, inviteRoomState.topic, inviteRoomState.avatar) db.prepare("REPLACE INTO invite (mxid, room_id, type, name, topic, avatar) VALUES (?, ?, ?, ?, ?, ?)").run(event.sender, event.room_id, inviteRoomState.type, inviteRoomState.name, inviteRoomState.topic, inviteRoomState.avatar)
@ -422,7 +437,10 @@ async event => {
if (event.content.membership === "leave" || event.content.membership === "ban") { if (event.content.membership === "leave" || event.content.membership === "ban") {
// Member is gone // Member is gone
// if Matrix member, data was cached in member_cache
db.prepare("DELETE FROM member_cache WHERE room_id = ? and mxid = ?").run(event.room_id, event.state_key) db.prepare("DELETE FROM member_cache WHERE room_id = ? and mxid = ?").run(event.room_id, event.state_key)
// if Discord member (so kicked/banned by Matrix user), data was cached in sim_member
db.prepare("DELETE FROM sim_member WHERE room_id = ? and mxid = ?").run(event.room_id, event.state_key)
// Unregister room's use as a direct chat and/or an invite target if the bot itself left // Unregister room's use as a direct chat and/or an invite target if the bot itself left
if (event.state_key === utils.bot) { if (event.state_key === utils.bot) {
@ -483,6 +501,21 @@ async event => {
await roomUpgrade.onTombstone(event, api) await roomUpgrade.onTombstone(event, api)
})) }))
sync.addTemporaryListener(as, "type:m.room.encryption", guard("m.room.encryption",
/**
* @param {Ty.Event.StateOuter<Ty.Event.M_Room_Encryption>} event
*/
async event => {
// Dramatically unbridge rooms if they become encrypted
if (event.state_key !== "") return
const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get()
if (!channelID) return
const channel = discord.channels.get(channelID)
if (!channel) return
await createRoom.unbridgeChannel(channel, channel["guild_id"], "Encrypted rooms are not supported. This room was removed from the bridge.")
}))
module.exports.stringifyErrorStack = stringifyErrorStack module.exports.stringifyErrorStack = stringifyErrorStack
module.exports.cleanErrorStack = cleanErrorStack
module.exports.sendError = sendError module.exports.sendError = sendError
module.exports.printError = printError module.exports.printError = printError

View file

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

View file

@ -172,7 +172,7 @@ function getStateEventOuter(roomID, type, key) {
/** /**
* @param {string} roomID * @param {string} roomID
* @param {{unsigned?: {invite_room_state?: Ty.Event.InviteStrippedState[]}}} [event] * @param {{unsigned?: {invite_room_state?: Ty.Event.InviteStrippedState[]}}} [event]
* @returns {Promise<{name: string?, topic: string?, avatar: string?, type: string?}>} * @returns {Promise<{name: string?, topic: string?, avatar: string?, type: string?, encryption: string?}>}
*/ */
async function getInviteState(roomID, event) { async function getInviteState(roomID, event) {
function getFromInviteRoomState(strippedState, nskey, key) { function getFromInviteRoomState(strippedState, nskey, key) {
@ -191,7 +191,8 @@ async function getInviteState(roomID, event) {
name: getFromInviteRoomState(event.unsigned.invite_room_state, "m.room.name", "name"), name: getFromInviteRoomState(event.unsigned.invite_room_state, "m.room.name", "name"),
topic: getFromInviteRoomState(event.unsigned.invite_room_state, "m.room.topic", "topic"), topic: getFromInviteRoomState(event.unsigned.invite_room_state, "m.room.topic", "topic"),
avatar: getFromInviteRoomState(event.unsigned.invite_room_state, "m.room.avatar", "url"), avatar: getFromInviteRoomState(event.unsigned.invite_room_state, "m.room.avatar", "url"),
type: getFromInviteRoomState(event.unsigned.invite_room_state, "m.room.create", "type") type: getFromInviteRoomState(event.unsigned.invite_room_state, "m.room.create", "type"),
encryption: getFromInviteRoomState(event.unsigned.invite_room_state, "m.room.encryption", "algorithm")
} }
} }
@ -227,7 +228,8 @@ async function getInviteState(roomID, event) {
name: getFromInviteRoomState(strippedState, "m.room.name", "name"), name: getFromInviteRoomState(strippedState, "m.room.name", "name"),
topic: getFromInviteRoomState(strippedState, "m.room.topic", "topic"), topic: getFromInviteRoomState(strippedState, "m.room.topic", "topic"),
avatar: getFromInviteRoomState(strippedState, "m.room.avatar", "url"), avatar: getFromInviteRoomState(strippedState, "m.room.avatar", "url"),
type: getFromInviteRoomState(strippedState, "m.room.create", "type") type: getFromInviteRoomState(strippedState, "m.room.create", "type"),
encryption: getFromInviteRoomState(strippedState, "m.room.encryption", "algorithm")
} }
} }
} catch (e) {} } catch (e) {}
@ -240,7 +242,8 @@ async function getInviteState(roomID, event) {
name: room.name ?? null, name: room.name ?? null,
topic: room.topic ?? null, topic: room.topic ?? null,
avatar: room.avatar_url ?? null, avatar: room.avatar_url ?? null,
type: room.room_type ?? null type: room.room_type ?? null,
encryption: (room.encryption || room["im.nheko.summary.encryption"]) ?? null
} }
} }
@ -460,17 +463,29 @@ async function ping() {
} }
/** /**
* Given an mxc:// URL, and an optional height for thumbnailing, get the file from the content repository. Returns res. * Given an mxc:// URL, and optional parameters for thumbnailing, get the file from the content repository. Returns res.
*
* Note that Synapse currently doesn't support animated thumbnails: https://github.com/element-hq/synapse/pull/18831
* @see https://spec.matrix.org/v1.18/client-server-api/#get_matrixclientv1mediathumbnailservernamemediaid
* @param {string} mxc * @param {string} mxc
* @param {RequestInit & {height?: number | string}} [init] * @param {RequestInit & {thumbnail?: {height?: number | string, width?: number | string, animated?: boolean, method?: "crop" | "scale"}}} [init]
* @return {Promise<Response & {body: streamWeb.ReadableStream<Uint8Array>}>} * @return {Promise<Response & {body: streamWeb.ReadableStream<Uint8Array>}>}
*/ */
async function getMedia(mxc, init = {}) { async function getMedia(mxc, init = {}) {
init = {...init}
const mediaParts = mxc?.match(/^mxc:\/\/([^/]+)\/(\w+)$/) const mediaParts = mxc?.match(/^mxc:\/\/([^/]+)\/(\w+)$/)
assert(mediaParts) assert(mediaParts)
const downloadOrThumbnail = init.height ? "thumbnail" : "download"
let url = `${mreq.baseUrl}/client/v1/media/${downloadOrThumbnail}/${mediaParts[1]}/${mediaParts[2]}` let route = "download"
if (init.height) url += "?" + new URLSearchParams({height: String(init.height), width: String(init.height)}) let query = ""
if (init.thumbnail) {
route = "thumbnail"
query = "?" + new URLSearchParams(Object.keys(init.thumbnail).map(k => [k, String(init.thumbnail?.[k])]))
}
let url = `${mreq.baseUrl}/client/v1/media/${route}/${mediaParts[1]}/${mediaParts[2]}${query}`
const res = await fetch(url, { const res = await fetch(url, {
headers: { headers: {
Authorization: `Bearer ${reg.as_token}` Authorization: `Bearer ${reg.as_token}`

View file

@ -85,6 +85,7 @@ async function _actuallyUploadDiscordFileToMxc(url) {
writeRegistration(reg) writeRegistration(reg)
return root return root
} }
e.uploadURL = url
throw e throw e
} }
} }

View file

@ -1,6 +1,7 @@
// @ts-check // @ts-check
const assert = require("assert").strict const assert = require("assert").strict
const DiscordTypes = require("discord-api-types/v10")
const Ty = require("../types") const Ty = require("../types")
const {pipeline} = require("stream").promises const {pipeline} = require("stream").promises
const sharp = require("sharp") const sharp = require("sharp")
@ -104,7 +105,8 @@ const commands = [{
// Guard // Guard
/** @type {string} */ // @ts-ignore /** @type {string} */ // @ts-ignore
const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get() const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get()
const guildID = discord.channels.get(channelID)?.["guild_id"] const channel = discord.channels.get(channelID)
const guildID = channel?.["guild_id"]
let matrixOnlyReason = null let matrixOnlyReason = null
const matrixOnlyConclusion = "So the emoji will be uploaded on Matrix-side only. It will still be usable over the bridge, but may have degraded functionality." const matrixOnlyConclusion = "So the emoji will be uploaded on Matrix-side only. It will still be usable over the bridge, but may have degraded functionality."
// Check if we can/should upload to Discord, for various causes // Check if we can/should upload to Discord, for various causes
@ -114,7 +116,7 @@ const commands = [{
const guild = discord.guilds.get(guildID) const guild = discord.guilds.get(guildID)
assert(guild) assert(guild)
const slots = getSlotCount(guild.premium_tier) const slots = getSlotCount(guild.premium_tier)
const permissions = dUtils.getPermissions(guild.id, [], guild.roles) const permissions = dUtils.getDefaultPermissions(guild, channel["permission_overwrites"])
if (guild.emojis.length >= slots) { if (guild.emojis.length >= slots) {
matrixOnlyReason = "CAPACITY" matrixOnlyReason = "CAPACITY"
} else if (!(permissions & 0x40000000n)) { // MANAGE_GUILD_EXPRESSIONS (apparently CREATE_GUILD_EXPRESSIONS isn't good enough...) } else if (!(permissions & 0x40000000n)) { // MANAGE_GUILD_EXPRESSIONS (apparently CREATE_GUILD_EXPRESSIONS isn't good enough...)
@ -239,7 +241,8 @@ const commands = [{
// Guard // Guard
/** @type {string} */ // @ts-ignore /** @type {string} */ // @ts-ignore
const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get() const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get()
const guildID = discord.channels.get(channelID)?.["guild_id"] const channel = discord.channels.get(channelID)
const guildID = channel?.["guild_id"]
if (!guildID) { if (!guildID) {
return api.sendEvent(event.room_id, "m.room.message", { return api.sendEvent(event.room_id, "m.room.message", {
...ctx, ...ctx,
@ -250,7 +253,7 @@ const commands = [{
const guild = discord.guilds.get(guildID) const guild = discord.guilds.get(guildID)
assert(guild) assert(guild)
const permissions = dUtils.getPermissions(guild.id, [], guild.roles) const permissions = dUtils.getDefaultPermissions(guild, channel["permission_overwrites"])
if (!(permissions & 0x800000000n)) { // CREATE_PUBLIC_THREADS if (!(permissions & 0x800000000n)) { // CREATE_PUBLIC_THREADS
return api.sendEvent(event.room_id, "m.room.message", { return api.sendEvent(event.room_id, "m.room.message", {
...ctx, ...ctx,
@ -262,6 +265,59 @@ const commands = [{
await discord.snow.channel.createThreadWithoutMessage(channelID, {type: 11, name: words.slice(1).join(" ")}) await discord.snow.channel.createThreadWithoutMessage(channelID, {type: 11, name: words.slice(1).join(" ")})
} }
) )
}, {
aliases: ["invite"],
execute: replyctx(
async (event, realBody, words, ctx) => {
// Guard
/** @type {string} */ // @ts-ignore
const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get()
const channel = discord.channels.get(channelID)
const guildID = channel?.["guild_id"]
if (!guildID) {
return api.sendEvent(event.room_id, "m.room.message", {
...ctx,
msgtype: "m.text",
body: "This room isn't bridged to the other side."
})
}
const guild = discord.guilds.get(guildID)
assert(guild)
const permissions = dUtils.getDefaultPermissions(guild, channel["permission_overwrites"])
if (!dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.CreateInstantInvite)) {
return api.sendEvent(event.room_id, "m.room.message", {
...ctx,
msgtype: "m.text",
body: "This command creates an invite link to the Discord side. But you aren't allowed to do this, because if you were a Discord user, you wouldn't have the Create Invite permission."
})
}
try {
var invite = await discord.snow.channel.createChannelInvite(channelID)
} catch (e) {
if (e.message === `{"message": "Missing Permissions", "code": 50013}`) {
return api.sendEvent(event.room_id, "m.room.message", {
...ctx,
msgtype: "m.text",
body: "I don't have permission to create invites to the Discord channel/server."
})
} else {
throw e
}
}
const validHours = Math.ceil(invite.max_age / (60 * 60))
const validUses =
( invite.max_uses === 0 ? "unlimited uses"
: invite.max_uses === 1 ? "single-use"
: `${invite.max_uses} uses`)
return api.sendEvent(event.room_id, "m.room.message", {
...ctx,
msgtype: "m.text",
body: `https://discord.gg/${invite.code}\nValid for next ${validHours} hours, ${validUses}.`
})
}
)
}] }]

View file

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

View file

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

View file

@ -54,17 +54,17 @@ async function onBotMembership(event, api, createRoom) {
assert.equal(event.type, "m.room.member") assert.equal(event.type, "m.room.member")
assert.equal(event.state_key, utils.bot) assert.equal(event.state_key, utils.bot)
// 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()
if (!oldRoomID) return false
const channelRow = from("channel_room").join("guild_space", "guild_id").where({room_id: oldRoomID}).select("space_id", "guild_id", "channel_id").get()
assert(channelRow) // this could only fail if the channel was unbridged or something between upgrade and joining
// Check if is join/invite
if (event.content.membership !== "invite" && event.content.membership !== "join") return false
return await roomUpgradeSema.request(async () => { 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()
if (!oldRoomID) return false
const channelRow = from("channel_room").join("guild_space", "guild_id").where({room_id: oldRoomID}).select("space_id", "guild_id", "channel_id").get()
assert(channelRow) // this could only fail if the channel was unbridged or something between upgrade and joining
// Check if is join/invite
if (event.content.membership !== "invite" && event.content.membership !== "join") return false
// If invited, join // If invited, join
if (event.content.membership === "invite") { if (event.content.membership === "invite") {
await api.joinRoom(newRoomID) await api.joinRoom(newRoomID)

View file

@ -225,19 +225,6 @@ async function getViaServersQuery(roomID, api) {
return qs return qs
} }
function generatePermittedMediaHash(mxc) {
assert(hasher, "xxhash is not ready yet")
const mediaParts = mxc?.match(/^mxc:\/\/([^/]+)\/(\w+)$/)
if (!mediaParts) return undefined
const serverAndMediaID = `${mediaParts[1]}/${mediaParts[2]}`
const unsignedHash = hasher.h64(serverAndMediaID)
const signedHash = unsignedHash - 0x8000000000000000n // shifting down to signed 64-bit range
db.prepare("INSERT OR IGNORE INTO media_proxy (permitted_hash) VALUES (?)").run(signedHash)
return serverAndMediaID
}
/** /**
* Since the introduction of authenticated media, this can no longer just be the /_matrix/media/r0/download URL * Since the introduction of authenticated media, this can no longer just be the /_matrix/media/r0/download URL
* because Discord and Discord users cannot use those URLs. Media now has to be proxied through the bridge. * because Discord and Discord users cannot use those URLs. Media now has to be proxied through the bridge.

View file

@ -15,18 +15,37 @@ const mreq = sync.require("./matrix/mreq")
const api = sync.require("./matrix/api") const api = sync.require("./matrix/api")
const file = sync.require("./matrix/file") const file = sync.require("./matrix/file")
const sendEvent = sync.require("./m2d/actions/send-event") const sendEvent = sync.require("./m2d/actions/send-event")
const redact = sync.require("./m2d/actions/redact")
const eventDispatcher = sync.require("./d2m/event-dispatcher") const eventDispatcher = sync.require("./d2m/event-dispatcher")
const updatePins = sync.require("./d2m/actions/update-pins") const updatePins = sync.require("./d2m/actions/update-pins")
const speedbump = sync.require("./d2m/actions/speedbump") const speedbump = sync.require("./d2m/actions/speedbump")
const ks = sync.require("./matrix/kstate") const ks = sync.require("./matrix/kstate")
const setPresence = sync.require("./d2m/actions/set-presence") const setPresence = sync.require("./d2m/actions/set-presence")
const channelWebhook = sync.require("./m2d/actions/channel-webhook") const channelWebhook = sync.require("./m2d/actions/channel-webhook")
const dUtils = sync.require("./discord/utils")
const mxUtils = sync.require("./matrix/utils")
const guildID = "112760669178241024" const guildID = "112760669178241024"
async function ping() {
const result = await api.ping().catch(e => ({ok: false, status: "net", root: e.message}))
if (result.ok) {
return "Ping OK. The homeserver and OOYE are talking to each other fine."
} else {
if (typeof result.root === "string") {
var msg = `Cannot reach homeserver: ${result.root}`
} else if (result.root.error) {
var msg = `Homeserver said: [${result.status}] ${result.root.error}`
} else {
var msg = `Homeserver said: [${result.status}] ${JSON.stringify(result.root)}`
}
return msg + "\nMatrix->Discord won't work until you fix this.\nIf your installation has recently changed, consider `npm run setup` again."
}
}
if (process.stdin.isTTY) { if (process.stdin.isTTY) {
setImmediate(() => { setImmediate(() => {
if (!passthrough.repl) { if (!passthrough.repl) {
const cli = repl.start({ prompt: "", eval: customEval, writer: s => s }) const cli = repl.start({prompt: "", eval: customEval, writer: s => s})
Object.assign(cli.context, passthrough) Object.assign(cli.context, passthrough)
passthrough.repl = cli passthrough.repl = cli
} }

9
src/types.d.ts vendored
View file

@ -157,7 +157,7 @@ export namespace Event {
type: string type: string
state_key: string state_key: string
sender: string sender: string
content: Event.M_Room_Create | Event.M_Room_Name | Event.M_Room_Avatar | Event.M_Room_Topic | Event.M_Room_JoinRules | Event.M_Room_CanonicalAlias 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
} }
export type M_Room_Create = { export type M_Room_Create = {
@ -390,6 +390,12 @@ export namespace Event {
body: string body: string
replacement_room: string replacement_room: string
} }
export type M_Room_Encryption = {
algorithm: string
rotation_period_ms?: number
rotation_period_msgs?: number
}
} }
export namespace R { export namespace R {
@ -437,6 +443,7 @@ export namespace R {
num_joined_members: number num_joined_members: number
room_id: string room_id: string
room_type?: string room_type?: string
encryption?: string
} }
export type ResolvedRoom = { export type ResolvedRoom = {

View file

@ -77,6 +77,7 @@ function renderPath(event, path, locals) {
compile() compile()
fs.watch(path, {persistent: false}, compile) fs.watch(path, {persistent: false}, compile)
fs.watch(join(__dirname, "pug", "includes"), {persistent: false}, compile) fs.watch(join(__dirname, "pug", "includes"), {persistent: false}, compile)
fs.watch(join(__dirname, "pug", "fragments"), {persistent: false}, compile)
} }
const cb = pugCache.get(path) const cb = pugCache.get(path)

View file

@ -0,0 +1,5 @@
//- locals: guild, guild_id
include ../includes/default-roles-list.pug
+default-roles-list(guild, guild_id)
+add-roles-menu(guild, guild_id)

View file

@ -1,4 +1,5 @@
extends includes/template.pug extends includes/template.pug
include includes/default-roles-list.pug
mixin badge-readonly mixin badge-readonly
.s-badge.s-badge__xs.s-badge__icon.s-badge__muted .s-badge.s-badge__xs.s-badge__icon.s-badge__muted
@ -76,7 +77,7 @@ block body
if space_id if space_id
h2.mt48.fs-headline1 Server settings h2.mt48.fs-headline1 Server settings
h3.mt32.fs-category Privacy level h3.mt32.fs-category How Matrix users join
span#privacy-level-loading span#privacy-level-loading
.s-card .s-card
form(hx-post=rel("/api/privacy-level") hx-trigger="change" hx-indicator="#privacy-level-loading" hx-disabled-elt="input") form(hx-post=rel("/api/privacy-level") hx-trigger="change" hx-indicator="#privacy-level-loading" hx-disabled-elt="input")
@ -105,6 +106,24 @@ block body
p.s-description.m0 Shareable invite links, like Discord p.s-description.m0 Shareable invite links, like Discord
p.s-description.m0 Publicly listed in directory, like Discord server discovery p.s-description.m0 Publicly listed in directory, like Discord server discovery
h3.mt32.fs-category Default roles
.s-card
form(method="post" action=rel("/api/default-roles") hx-post=rel("/api/default-roles") hx-sync="this:drop" hx-indicator="#add-role-loading" hx-target="#default-roles-list" hx-select="#default-roles-list" hx-swap="outerHTML")#default-roles
input(type="hidden" name="guild_id" value=guild_id)
.d-flex.fw-wrap.g4
.s-tag.s-tag__md.fs-body1.s-tag__required @everyone
+default-roles-list(guild, guild_id)
button(type="button" popovertarget="role-add").s-btn__dropdown.s-tag.s-tag__md.fs-body1.p0
.s-tag--dismiss.m1
!= icons.Icons.IconPlusSm
#role-add.s-popover(popover style="display: revert").ws2.px0.py4.bs-lg.overflow-visible
.s-popover--arrow.s-popover--arrow__tc
+add-roles-menu(guild, guild_id)
p.fc-light.mb0.mt8 Matrix users will start with these roles. If your main channels are gated by a role, use this to let Matrix users skip the gate.
h3.mt32.fs-category Features h3.mt32.fs-category Features
.s-card.d-grid.px0.g16 .s-card.d-grid.px0.g16
form.d-flex.ai-center.g16 form.d-flex.ai-center.g16
@ -172,14 +191,14 @@ block body
label.s-btn.s-btn__muted.ta-left.truncate(for=channel.id) label.s-btn.s-btn__muted.ta-left.truncate(for=channel.id)
+discord(channel, true, "Announcement") +discord(channel, true, "Announcement")
else else
.s-empty-state.p8 All Discord channels are linked. .s-empty-state.p8 No Discord channels available.
.fl-grow1.s-btn-group.fd-column.w30 .fl-grow1.s-btn-group.fd-column.w30
each room in unlinkedRooms each room in unlinkedRooms
input.s-btn--radio(type="radio" name="matrix" required id=room.room_id value=room.room_id) input.s-btn--radio(type="radio" name="matrix" required id=room.room_id value=room.room_id)
label.s-btn.s-btn__muted.ta-left.truncate(for=room.room_id) label.s-btn.s-btn__muted.ta-left.truncate(for=room.room_id)
+matrix(room, true) +matrix(room, true)
else else
.s-empty-state.p8 All Matrix rooms are linked. .s-empty-state.p8 No Matrix rooms available.
input(type="hidden" name="guild_id" value=guild_id) input(type="hidden" name="guild_id" value=guild_id)
div div
button.s-btn.s-btn__icon.s-btn__filled#link-button button.s-btn.s-btn__icon.s-btn__filled#link-button
@ -230,6 +249,11 @@ block body
ul.my8.ml24 ul.my8.ml24
each row in removedLinkedRooms each row in removedLinkedRooms
li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name
h3.mt24 Unavailable rooms: Encryption not supported
.s-card.p0
ul.my8.ml24
each row in removedEncryptedRooms
li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name
h3.mt24 Unavailable rooms: Wrong type h3.mt24 Unavailable rooms: Wrong type
.s-card.p0 .s-card.p0
ul.my8.ml24 ul.my8.ml24

View file

@ -0,0 +1,19 @@
mixin default-roles-list(guild, guild_id)
#default-roles-list(style="display: contents")
each roleID in select("role_default", "role_id", {guild_id}).pluck().all()
- let r = guild.roles.find(r => r.id === roleID)
if r
.s-tag.s-tag__md.fs-body1= r.name
span(id=`role-loading-${roleID}`)
button(name="remove_role" value=roleID hx-post="api/default-roles" hx-trigger="click consume" hx-indicator=`#role-loading-${roleID}`).s-tag--dismiss
!= icons.Icons.IconClearSm
mixin add-roles-menu(guild, guild_id)
ul.s-menu(role="menu" hx-swap-oob="true").overflow-y-auto.overflow-x-hidden#add-roles-menu
li.s-menu--title.d-flex(role="separator") Select role
span#add-role-loading
each r in guild.roles.sort((a, b) => b.position - a.position)
if r.id !== guild_id && !r.managed
- let selected = !!select("role_default", "role_id", {guild_id, role_id: r.id}).get()
li(role="menuitem")
button(name="toggle_role" value=r.id class={"is-selected": selected}).s-block-link.s-block-link__left= r.name

View file

@ -88,9 +88,28 @@ html(lang="en")
--_ts-multiple-bg: var(--green-400); --_ts-multiple-bg: var(--green-400);
--_ts-multiple-fc: var(--white); --_ts-multiple-fc: var(--white);
} }
.s-avatar {
--_av-bg: var(--white);
}
.s-avatar .s-avatar--letter {
color: var(--white);
}
.s-btn__dropdown:has(+ :popover-open) { .s-btn__dropdown:has(+ :popover-open) {
background-color: var(--theme-topbar-item-background-hover, var(--black-200)) !important; background-color: var(--theme-topbar-item-background-hover, var(--black-200)) !important;
} }
.s-btn__dropdown.s-tag:has(+ :popover-open) .s-tag--dismiss {
background-color: var(--black-500) !important;
color: var(--black-150) !important;
}
.s-tag .is-loading {
margin-right: -4px;
}
.s-tag .is-loading + .s-tag--dismiss {
display: none !important;
}
a.s-block-link, .s-block-link {
--_bl-bs-color: var(--green-400);
}
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
body.theme-system .s-popover { body.theme-system .s-popover {
--_po-bg: var(--black-100); --_po-bg: var(--black-100);
@ -141,11 +160,15 @@ html(lang="en")
//- Guild list popover //- Guild list popover
script. script.
document.querySelectorAll("[popovertarget]").forEach(e => { document.querySelectorAll("[popovertarget]").forEach(e => {
e.addEventListener("click", () => { const target = document.getElementById(e.getAttribute("popovertarget"))
const rect = e.getBoundingClientRect() e.addEventListener("click", calculate)
const t = `:popover-open { position: absolute; top: ${Math.floor(rect.bottom)}px; left: ${Math.floor(rect.left + rect.width / 2)}px; width: ${Math.floor(rect.width)}px; transform: translateX(-50%); box-sizing: content-box; margin: 0 }` target.addEventListener("toggle", calculate)
function calculate() {
const buttonRect = e.getBoundingClientRect()
const targetRect = target.getBoundingClientRect()
const t = `:popover-open { position: absolute; top: ${Math.floor(buttonRect.bottom + window.scrollY)}px; left: ${Math.floor(Math.max(targetRect.width / 2, buttonRect.left + buttonRect.width / 2))}px; width: ${Math.floor(buttonRect.width)}px; transform: translateX(-50%); box-sizing: content-box; margin: 0 }`
document.styleSheets[0].insertRule(t, document.styleSheets[0].cssRules.length) document.styleSheets[0].insertRule(t, document.styleSheets[0].cssRules.length)
}) }
}) })
//- Prevent default //- Prevent default
script. script.

View file

@ -1,7 +1,7 @@
// @ts-check // @ts-check
const assert = require("assert").strict const assert = require("assert").strict
const tryToCatch = require("try-to-catch") const {tryToCatch} = require("try-to-catch")
const {test} = require("supertape") const {test} = require("supertape")
const {router} = require("../../../test/web") const {router} = require("../../../test/web")
const {_cache} = require("./download-discord") const {_cache} = require("./download-discord")

View file

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

View file

@ -2,7 +2,7 @@
const fs = require("fs") const fs = require("fs")
const {convertImageStream} = require("../../m2d/converters/emoji-sheet") const {convertImageStream} = require("../../m2d/converters/emoji-sheet")
const tryToCatch = require("try-to-catch") const {tryToCatch} = require("try-to-catch")
const {test} = require("supertape") const {test} = require("supertape")
const {router} = require("../../../test/web") const {router} = require("../../../test/web")
const streamWeb = require("stream/web") const streamWeb = require("stream/web")

View file

@ -4,10 +4,12 @@ const assert = require("assert/strict")
const {z} = require("zod") const {z} = require("zod")
const {defineEventHandler, createError, readValidatedBody, getRequestHeader, setResponseHeader, sendRedirect, H3Event} = require("h3") const {defineEventHandler, createError, readValidatedBody, getRequestHeader, setResponseHeader, sendRedirect, H3Event} = require("h3")
const {as, db, sync, select} = require("../../passthrough") const {as, db, sync, select, discord} = require("../../passthrough")
/** @type {import("../auth")} */ /** @type {import("../auth")} */
const auth = sync.require("../auth") const auth = sync.require("../auth")
/** @type {import("../pug-sync")} */
const pugSync = sync.require("../pug-sync")
/** @type {import("../../d2m/actions/set-presence")} */ /** @type {import("../../d2m/actions/set-presence")} */
const setPresence = sync.require("../../d2m/actions/set-presence") const setPresence = sync.require("../../d2m/actions/set-presence")
@ -20,6 +22,14 @@ function getCreateSpace(event) {
return event.context.createSpace || sync.require("../../d2m/actions/create-space") return event.context.createSpace || sync.require("../../d2m/actions/create-space")
} }
const schema = {
defaultRoles: z.object({
guild_id: z.string(),
toggle_role: z.string().optional(),
remove_role: z.string().optional()
})
}
/** /**
* @typedef Options * @typedef Options
* @prop {(value: string?) => number} transform * @prop {(value: string?) => number} transform
@ -94,3 +104,39 @@ as.router.post("/api/privacy-level", defineToggle("privacy_level", {
await createSpace.syncSpaceFully(guildID) // this is inefficient but OK to call infrequently on user request await createSpace.syncSpaceFully(guildID) // this is inefficient but OK to call infrequently on user request
} }
})) }))
as.router.post("/api/default-roles", defineEventHandler(async event => {
const parsedBody = await readValidatedBody(event, schema.defaultRoles.parse)
const managed = await auth.getManagedGuilds(event)
const guildID = parsedBody.guild_id
if (!managed.has(guildID)) throw createError({status: 403, message: "Forbidden", data: "Can't change settings for a guild you don't have Manage Server permissions in"})
const roleID = parsedBody.toggle_role || parsedBody.remove_role
assert(roleID)
assert.notEqual(guildID, roleID) // the @everyone role is always default
const guild = discord.guilds.get(guildID)
assert(guild)
let shouldRemove = !!parsedBody.remove_role
if (!shouldRemove) {
shouldRemove = !!select("role_default", "role_id", {guild_id: guildID, role_id: roleID}).get()
}
if (shouldRemove) {
db.prepare("DELETE FROM role_default WHERE guild_id = ? AND role_id = ?").run(guildID, roleID)
} else {
assert(guild.roles.find(r => r.id === roleID))
db.prepare("INSERT OR IGNORE INTO role_default (guild_id, role_id) VALUES (?, ?)").run(guildID, roleID)
}
const createSpace = getCreateSpace(event)
await createSpace.syncSpaceFully(guildID) // this is inefficient but OK to call infrequently on user request
if (getRequestHeader(event, "HX-Request")) {
return pugSync.render(event, "fragments/default-roles-list.pug", {guild, guild_id: guildID})
} else {
return sendRedirect(event, `/guild?guild_id=${guildID}`, 302)
}
}))

View file

@ -1,6 +1,6 @@
// @ts-check // @ts-check
const tryToCatch = require("try-to-catch") const {tryToCatch} = require("try-to-catch")
const {router, test} = require("../../../test/web") const {router, test} = require("../../../test/web")
const {select} = require("../../passthrough") const {select} = require("../../passthrough")
const {MatrixServerError} = require("../../matrix/mreq") const {MatrixServerError} = require("../../matrix/mreq")

View file

@ -123,13 +123,14 @@ function getChannelRoomsLinks(guild, rooms, roles) {
let unlinkedRooms = [...rooms] let unlinkedRooms = [...rooms]
let removedLinkedRooms = dUtils.filterTo(unlinkedRooms, r => !linkedRoomIDs.includes(r.room_id)) let removedLinkedRooms = dUtils.filterTo(unlinkedRooms, r => !linkedRoomIDs.includes(r.room_id))
let removedWrongTypeRooms = dUtils.filterTo(unlinkedRooms, r => !r.room_type) let removedWrongTypeRooms = dUtils.filterTo(unlinkedRooms, r => !r.room_type)
let removedEncryptedRooms = dUtils.filterTo(unlinkedRooms, r => !r.encryption && !r["im.nheko.summary.encryption"])
// https://discord.com/developers/docs/topics/threads#active-archived-threads // https://discord.com/developers/docs/topics/threads#active-archived-threads
// need to filter out linked archived threads from unlinkedRooms, will just do that by comparing against the name // need to filter out linked archived threads from unlinkedRooms, will just do that by comparing against the name
let removedArchivedThreadRooms = dUtils.filterTo(unlinkedRooms, r => r.name && !r.name.match(/^\[(🔒)?⛓️\]/)) let removedArchivedThreadRooms = dUtils.filterTo(unlinkedRooms, r => r.name && !r.name.match(/^\[(🔒)?⛓️\]/))
return { return {
linkedChannelsWithDetails, unlinkedChannels, unlinkedRooms, linkedChannelsWithDetails, unlinkedChannels, unlinkedRooms,
removedUncachedChannels, removedWrongTypeChannels, removedPrivateChannels, removedLinkedRooms, removedWrongTypeRooms, removedArchivedThreadRooms removedUncachedChannels, removedWrongTypeChannels, removedPrivateChannels, removedLinkedRooms, removedWrongTypeRooms, removedArchivedThreadRooms, removedEncryptedRooms
} }
} }

View file

@ -1,7 +1,7 @@
// @ts-check // @ts-check
const DiscordTypes = require("discord-api-types/v10") const DiscordTypes = require("discord-api-types/v10")
const tryToCatch = require("try-to-catch") const {tryToCatch} = require("try-to-catch")
const {router, test} = require("../../../test/web") const {router, test} = require("../../../test/web")
const {MatrixServerError} = require("../../matrix/mreq") const {MatrixServerError} = require("../../matrix/mreq")
const {_getPosition} = require("./guild") const {_getPosition} = require("./guild")

View file

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

View file

@ -204,6 +204,12 @@ as.router.post("/api/link", defineEventHandler(async event => {
throw createError({status: 403, message: e.errcode, data: `${e.errcode} - ${e.message}`}) throw createError({status: 403, message: e.errcode, data: `${e.errcode} - ${e.message}`})
} }
// Check room is not encrypted
const encryption = await api.getStateEvent(parsedBody.matrix, "m.room.encryption", "").catch(() => null)
if (encryption) {
throw createError({status: 400, message: "Bad Request", data: "Encrypted rooms are not supported for bridging. Please replace it with an unencrypted room."})
}
// Check bridge has PL 100 // Check bridge has PL 100
const {powerLevels, powers: {[utils.bot]: selfPowerLevel}} = await utils.getEffectivePower(parsedBody.matrix, [utils.bot], api) const {powerLevels, powers: {[utils.bot]: selfPowerLevel}} = await utils.getEffectivePower(parsedBody.matrix, [utils.bot], api)
if (selfPowerLevel < (powerLevels?.state_default ?? 50) || selfPowerLevel < 100) throw createError({status: 400, message: "Bad Request", data: "OOYE needs power level 100 (admin) in the target Matrix room"}) if (selfPowerLevel < (powerLevels?.state_default ?? 50) || selfPowerLevel < 100) throw createError({status: 400, message: "Bad Request", data: "OOYE needs power level 100 (admin) in the target Matrix room"})

View file

@ -1,6 +1,6 @@
// @ts-check // @ts-check
const tryToCatch = require("try-to-catch") const {tryToCatch} = require("try-to-catch")
const {router, test} = require("../../../test/web") const {router, test} = require("../../../test/web")
const {MatrixServerError} = require("../../matrix/mreq") const {MatrixServerError} = require("../../matrix/mreq")
const {select, db} = require("../../passthrough") const {select, db} = require("../../passthrough")
@ -435,6 +435,47 @@ test("web link room: check that bridge can join room (uses via for join attempt)
t.equal(called, 2) t.equal(called, 2)
}) })
test("web link room: check that room is not encrypted", async t => {
let called = 0
const [error] = await tryToCatch(() => router.test("post", "/api/link", {
sessionData: {
managedGuilds: ["665289423482519565"]
},
body: {
discord: "665310973967597573",
matrix: "!NDbIqNpJyPvfKRnNcr:cadence.moe",
guild_id: "665289423482519565"
},
api: {
async joinRoom(roomID) {
called++
return roomID
},
async *generateFullHierarchy(spaceID) {
called++
t.equal(spaceID, "!zTMspHVUBhFLLSdmnS:cadence.moe")
yield {
room_id: "!NDbIqNpJyPvfKRnNcr:cadence.moe",
children_state: [],
guest_can_join: false,
num_joined_members: 2
}
/* c8 ignore next */
},
async getStateEvent(roomID, type, key) {
called++
t.equal(roomID, "!NDbIqNpJyPvfKRnNcr:cadence.moe")
if (type === "m.room.encryption" && key === "") {
return {algorithm: "m.megolm.v1.aes-sha2"}
}
throw new Error("Unknown state event")
}
}
}))
t.equal(error.data, "Encrypted rooms are not supported for bridging. Please replace it with an unencrypted room.")
t.equal(called, 3)
})
test("web link room: check that bridge has PL 100 in target room", async t => { test("web link room: check that bridge has PL 100 in target room", async t => {
let called = 0 let called = 0
const [error] = await tryToCatch(() => router.test("post", "/api/link", { const [error] = await tryToCatch(() => router.test("post", "/api/link", {
@ -465,9 +506,10 @@ test("web link room: check that bridge has PL 100 in target room", async t => {
async getStateEvent(roomID, type, key) { async getStateEvent(roomID, type, key) {
called++ called++
t.equal(roomID, "!NDbIqNpJyPvfKRnNcr:cadence.moe") t.equal(roomID, "!NDbIqNpJyPvfKRnNcr:cadence.moe")
t.equal(type, "m.room.power_levels") if (type === "m.room.power_levels" && key === "") {
t.equal(key, "") return {users_default: 50}
return {users_default: 50} }
throw new Error("Unknown state event")
}, },
async getStateEventOuter(roomID, type, key) { async getStateEventOuter(roomID, type, key) {
called++ called++
@ -489,7 +531,7 @@ test("web link room: check that bridge has PL 100 in target room", async t => {
} }
})) }))
t.equal(error.data, "OOYE needs power level 100 (admin) in the target Matrix room") t.equal(error.data, "OOYE needs power level 100 (admin) in the target Matrix room")
t.equal(called, 4) t.equal(called, 5)
}) })
test("web link room: successfully calls createRoom", async t => { test("web link room: successfully calls createRoom", async t => {

View file

@ -1,6 +1,6 @@
// @ts-check // @ts-check
const tryToCatch = require("try-to-catch") const {tryToCatch} = require("try-to-catch")
const {router, test} = require("../../../test/web") const {router, test} = require("../../../test/web")
const {MatrixServerError} = require("../../matrix/mreq") const {MatrixServerError} = require("../../matrix/mreq")

View file

@ -1,7 +1,7 @@
// @ts-check // @ts-check
const DiscordTypes = require("discord-api-types/v10") const DiscordTypes = require("discord-api-types/v10")
const tryToCatch = require("try-to-catch") const {tryToCatch} = require("try-to-catch")
const assert = require("assert/strict") const assert = require("assert/strict")
const {router, test} = require("../../../test/web") const {router, test} = require("../../../test/web")

View file

@ -1,6 +1,6 @@
// @ts-check // @ts-check
const tryToCatch = require("try-to-catch") const {tryToCatch} = require("try-to-catch")
const {test} = require("supertape") const {test} = require("supertape")
const {router} = require("../../../test/web") const {router} = require("../../../test/web")

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

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

View file

@ -83,7 +83,13 @@ function tryStatic(event, fallthrough) {
// Everything else // Everything else
else { else {
const mime = mimeTypes.lookup(id) const mime = mimeTypes.lookup(id)
if (typeof mime === "string") defaultContentType(event, mime) if (typeof mime === "string") {
if (mime.startsWith("text/")) {
defaultContentType(event, mime + "; charset=utf-8") // usually wise
} else {
defaultContentType(event, mime)
}
}
return { return {
size: stats.size size: stats.size
} }
@ -94,7 +100,7 @@ function tryStatic(event, fallthrough) {
const path = join(publicDir, id) const path = join(publicDir, id)
return pugSync.renderPath(event, path, {}) return pugSync.renderPath(event, path, {})
} else { } else {
return fs.promises.readFile(join(publicDir, id)) return fs.createReadStream(join(publicDir, id))
} }
} }
}) })
@ -124,7 +130,9 @@ sync.require("./routes/download-discord")
sync.require("./routes/guild-settings") sync.require("./routes/guild-settings")
sync.require("./routes/guild") sync.require("./routes/guild")
sync.require("./routes/info") sync.require("./routes/info")
sync.require("./routes/letter-avatar")
sync.require("./routes/link") sync.require("./routes/link")
sync.require("./routes/log-in-with-matrix") sync.require("./routes/log-in-with-matrix")
sync.require("./routes/oauth") sync.require("./routes/oauth")
sync.require("./routes/password") sync.require("./routes/password")
sync.require("./routes/stats")

View file

@ -19,6 +19,26 @@ module.exports = {
default_thread_rate_limit_per_user: 0, default_thread_rate_limit_per_user: 0,
guild_id: "112760669178241024" guild_id: "112760669178241024"
}, },
voice: {
voice_background_display: null,
version: 1774469910848,
user_limit: 0,
type: 2,
theme_color: null,
status: null,
rtc_region: null,
rate_limit_per_user: 0,
position: 0,
permission_overwrites: [],
parent_id: "805261291908104252",
nsfw: false,
name: "🍞丨[8user] Piece",
last_message_id: "1459912691098325137",
id: "1036840786093953084",
flags: 0,
bitrate: 256000,
guild_id: "112760669178241024"
},
updates: { updates: {
type: 0, type: 0,
topic: "Updates and release announcements for Out Of Your Element.", topic: "Updates and release announcements for Out Of Your Element.",
@ -2015,6 +2035,80 @@ module.exports = {
tts: false tts: false
} }
}, },
reply_to_member_join: {
type: 19,
content: "when the broke friend who we pay to bring food shows up at the medieval lord party",
mentions: [],
mention_roles: [],
attachments: [],
embeds: [],
timestamp: "2026-03-30T12:11:04.443000+00:00",
edited_timestamp: null,
flags: 0,
components: [],
id: "1488148556962332692",
channel_id: "475599038536744962",
author: {
id: "576945009408999426",
username: "randomllama121",
avatar: "08510a70f957106dad1580323c40cd7a",
discriminator: "0",
public_flags: 128,
flags: 128,
banner: null,
accent_color: null,
global_name: "random :3",
avatar_decoration_data: null,
collectibles: null,
display_name_styles: null,
banner_color: null,
clan: null,
primary_guild: null
},
pinned: false,
mention_everyone: false,
tts: false,
message_reference: {
type: 0,
channel_id: "475599038536744962",
message_id: "1488146734352826478",
guild_id: "475599038536744960"
},
referenced_message: {
type: 7,
content: "",
mentions: [],
mention_roles: [],
attachments: [],
embeds: [],
timestamp: "2026-03-30T12:03:49.899000+00:00",
edited_timestamp: null,
flags: 0,
components: [],
id: "1488146734352826478",
channel_id: "475599038536744962",
author: {
id: "1461677775554478161",
username: "peasant321_76775",
avatar: null,
discriminator: "0",
public_flags: 0,
flags: 0,
banner: null,
accent_color: null,
global_name: "PEASANT!!",
avatar_decoration_data: null,
collectibles: null,
display_name_styles: null,
banner_color: null,
clan: null,
primary_guild: null
},
pinned: false,
mention_everyone: false,
tts: false
}
},
attachment_no_content: { attachment_no_content: {
id: "1124628646670389348", id: "1124628646670389348",
type: 0, type: 0,
@ -4617,7 +4711,7 @@ module.exports = {
flags: 0, flags: 0,
components: [] components: []
}, },
escaping_crazy_html_tags: { extreme_html_escaping: {
id: "1158894131322552391", id: "1158894131322552391",
type: 0, type: 0,
content: "", content: "",
@ -5067,6 +5161,141 @@ module.exports = {
pinned: false, pinned: false,
mention_everyone: false, mention_everyone: false,
tts: false tts: false
},
four_images: {
type: 0,
content: "",
mentions: [],
mention_roles: [],
attachments: [],
embeds: [],
timestamp: "2026-03-12T18:00:50.737000+00:00",
edited_timestamp: null,
flags: 16384,
components: [],
id: "1481713598278533241",
channel_id: "687028734322147344",
author: {
id: "112760500130975744",
username: "minimus",
avatar: "a_a354b9eaff512485b49c82b13691b941",
discriminator: "0",
public_flags: 512,
flags: 512,
banner: null,
accent_color: null,
global_name: "minimus",
avatar_decoration_data: null,
collectibles: null,
display_name_styles: { font_id: 11, effect_id: 5, colors: [ 6106655 ] },
banner_color: null,
clan: null,
primary_guild: null
},
pinned: false,
mention_everyone: false,
tts: false,
message_reference: {
type: 1,
channel_id: "637339857118822430",
message_id: "1481696763483258891",
guild_id: "408573045540651009"
},
message_snapshots: [
{
message: {
type: 0,
content: "https://fixupx.com/i/status/2032003668787020046",
mentions: [],
mention_roles: [],
attachments: [],
embeds: [
{
type: "rich",
url: "https://fixupx.com/i/status/2032003668787020046",
description: "4chan owner Hiroyuki, Evangelion director Hideaki Anno and GACKT to participate in “humanitys 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**",
color: 6513919,
timestamp: "2026-03-12T08:00:02+00:00",
author: {
name: "AUTOMATON WEST (@AUTOMATON_ENG)",
url: "https://x.com/AUTOMATON_ENG/status/2032003668787020046",
icon_url: "https://pbs.twimg.com/profile_images/1353559126693961729/pz-WVnDc_200x200.jpg",
proxy_icon_url: "https://images-ext-1.discordapp.net/external/1OzGhjvZTRstTxM38_7pqHXlmdbMddqh1F8R0-WrKqw/https/pbs.twimg.com/profile_images/1353559126693961729/pz-WVnDc_200x200.jpg"
},
image: {
url: "https://pbs.twimg.com/media/HDMUyf6bQAM3yts.jpg?name=orig",
proxy_url: "https://images-ext-1.discordapp.net/external/NkNgp2SyY1OCH9IdS8hqsUqbnbrp3A9oLNwYusVVCVQ/%3Fname%3Dorig/https/pbs.twimg.com/media/HDMUyf6bQAM3yts.jpg",
width: 872,
height: 886,
content_type: "image/jpeg",
placeholder: "6vcFFwL6R3lye2V3l1mIl5l3WPN5FZ8H",
placeholder_version: 1,
flags: 0
},
footer: {
text: "FixupX",
icon_url: "https://assets.fxembed.com/logos/fixupx64.png",
proxy_icon_url: "https://images-ext-1.discordapp.net/external/LwQ70Uiqfu0OCN4ZbA4f482TGCgQa-xGsnUFYfhIgYA/https/assets.fxembed.com/logos/fixupx64.png"
},
content_scan_version: 4
},
{
type: "rich",
url: "https://fixupx.com/i/status/2032003668787020046",
image: {
url: "https://pbs.twimg.com/media/HDMUgxybQAE4FtJ.jpg?name=orig",
proxy_url: "https://images-ext-1.discordapp.net/external/Rquh1ec-tG9hMqdHqIVSphO7zf5B5Fg_7yTWhCjlsek/%3Fname%3Dorig/https/pbs.twimg.com/media/HDMUgxybQAE4FtJ.jpg",
width: 1114,
height: 991,
content_type: "image/jpeg",
placeholder: "JQgKDoL3epZ8ZIdnlmmHZ4d4CIGmUEc=",
placeholder_version: 1,
flags: 0
},
content_scan_version: 4
},
{
type: "rich",
url: "https://fixupx.com/i/status/2032003668787020046",
image: {
url: "https://pbs.twimg.com/media/HDMUrPobgAAeb90.jpg?name=orig",
proxy_url: "https://images-ext-1.discordapp.net/external/XrkhHNH3CvlZYvjkdykVnf-_xdz6HWX8uwesoAwwSfY/%3Fname%3Dorig/https/pbs.twimg.com/media/HDMUrPobgAAeb90.jpg",
width: 944,
height: 954,
content_type: "image/jpeg",
placeholder: "m/cJDwCbV0mfaoZzlihqeXdqCVN9A6oD",
placeholder_version: 1,
flags: 0
},
content_scan_version: 4
},
{
type: "rich",
url: "https://fixupx.com/i/status/2032003668787020046",
image: {
url: "https://pbs.twimg.com/media/HDMUuy5bgAAInj5.jpg?name=orig",
proxy_url: "https://images-ext-1.discordapp.net/external/lO-5hBMU9bGH13Ax9xum2T2Mg0ATdv0b6BEx_VeVi80/%3Fname%3Dorig/https/pbs.twimg.com/media/HDMUuy5bgAAInj5.jpg",
width: 1200,
height: 630,
content_type: "image/jpeg",
placeholder: "tfcJDIK3mIl1eIiPdY23dX9b9w==",
placeholder_version: 1,
flags: 0
},
content_scan_version: 4
}
],
timestamp: "2026-03-12T16:53:57.009000+00:00",
edited_timestamp: null,
flags: 0,
components: []
}
}
]
} }
}, },
message_with_components: { message_with_components: {
@ -5244,6 +5473,189 @@ module.exports = {
content: '-# Original Message ID: 1466556003645657118 · <t:1769724599:f>' content: '-# Original Message ID: 1466556003645657118 · <t:1769724599:f>'
} }
] ]
},
pk_ping_components_v1: {
type: 23,
content: "Psst, **Red** (<@772659086046658620>), you have been pinged by <@772659086046658620>.",
mentions: [
{
id: "772659086046658620",
username: "cadence.worm",
avatar: "466df0c98b1af1e1388f595b4c1ad1b9",
discriminator: "0",
public_flags: 0,
flags: 0,
banner: null,
accent_color: null,
global_name: "cadence",
avatar_decoration_data: null,
collectibles: null,
display_name_styles: null,
banner_color: null,
clan: {
identity_guild_id: "532245108070809601",
identity_enabled: true,
tag: "doll",
badge: "dba08126b4e810a0e096cc7cd5bc37f0"
},
primary_guild: {
identity_guild_id: "532245108070809601",
identity_enabled: true,
tag: "doll",
badge: "dba08126b4e810a0e096cc7cd5bc37f0"
}
}
],
mention_roles: [],
attachments: [],
embeds: [],
timestamp: "2026-03-25T07:07:02.626000+00:00",
edited_timestamp: null,
flags: 0,
components: [
{
type: 1,
id: 1,
components: [
{
type: 2,
id: 2,
style: 5,
label: "Jump",
url: "https://discord.com/channels/1160893336324931584/1160894080998461480/1440549403667468320"
}
]
}
],
id: "1486260105908457653",
channel_id: "1160894080998461480",
author: {
id: "466378653216014359",
username: "PluralKit",
avatar: "b78ef67a081737a830b60aa47d9ebcd9",
discriminator: "4020",
public_flags: 65536,
flags: 65536,
bot: true,
banner: null,
accent_color: null,
global_name: null,
avatar_decoration_data: null,
collectibles: null,
display_name_styles: null,
banner_color: null,
clan: null,
primary_guild: null
},
pinned: false,
mention_everyone: false,
tts: false,
application_id: "466378653216014359",
interaction: {
id: "1486260103928614932",
type: 2,
name: "🔔 Ping author",
user: {
id: "772659086046658620",
username: "cadence.worm",
avatar: "466df0c98b1af1e1388f595b4c1ad1b9",
discriminator: "0",
public_flags: 0,
flags: 0,
banner: null,
accent_color: null,
global_name: "cadence",
avatar_decoration_data: null,
collectibles: null,
display_name_styles: null,
banner_color: null,
clan: {
identity_guild_id: "532245108070809601",
identity_enabled: true,
tag: "doll",
badge: "dba08126b4e810a0e096cc7cd5bc37f0"
},
primary_guild: {
identity_guild_id: "532245108070809601",
identity_enabled: true,
tag: "doll",
badge: "dba08126b4e810a0e096cc7cd5bc37f0"
}
}
},
webhook_id: "466378653216014359",
message_reference: {
type: 0,
channel_id: "1160894080998461480",
message_id: "1440549403667468320",
guild_id: "1160893336324931584"
},
interaction_metadata: {
id: "1486260103928614932",
type: 2,
user: {
id: "772659086046658620",
username: "cadence.worm",
avatar: "466df0c98b1af1e1388f595b4c1ad1b9",
discriminator: "0",
public_flags: 0,
flags: 0,
banner: null,
accent_color: null,
global_name: "cadence",
avatar_decoration_data: null,
collectibles: null,
display_name_styles: null,
banner_color: null,
clan: {
identity_guild_id: "532245108070809601",
identity_enabled: true,
tag: "doll",
badge: "dba08126b4e810a0e096cc7cd5bc37f0"
},
primary_guild: {
identity_guild_id: "532245108070809601",
identity_enabled: true,
tag: "doll",
badge: "dba08126b4e810a0e096cc7cd5bc37f0"
}
},
authorizing_integration_owners: { "0": "1160893336324931584" },
name: "🔔 Ping author",
command_type: 3,
target_message_id: "1440549403667468320"
},
referenced_message: {
type: 0,
content: "test",
mentions: [],
mention_roles: [],
attachments: [],
embeds: [],
timestamp: "2025-11-19T03:49:01.948000+00:00",
edited_timestamp: null,
flags: 0,
components: [],
id: "1440549403667468320",
channel_id: "1160894080998461480",
author: {
id: "1195662438662680720",
username: "special name",
avatar: "a82347890f2739e5880cd82b8c1a708e",
discriminator: "0000",
public_flags: 0,
flags: 0,
bot: true,
global_name: null,
clan: null,
primary_guild: null
},
pinned: false,
mention_everyone: false,
tts: false,
application_id: "466378653216014359",
webhook_id: "1195662438662680720"
}
} }
}, },
message_update: { message_update: {
@ -6035,6 +6447,37 @@ module.exports = {
components: [], components: [],
position: 12 position: 12
}, },
channel_follow_add: {
type: 12,
content: "PluralKit #downtime",
attachments: [],
embeds: [],
timestamp: "2026-03-24T23:16:04.097Z",
edited_timestamp: null,
flags: 0,
components: [],
id: "1486141581047369888",
channel_id: "1451125453082591314",
author: {
id: "154058479798059009",
username: "exaptations",
discriminator: "0",
avatar: "57b5cfe09a48a5902f2eb8fa65bb1b80",
bot: false,
flags: 0,
globalName: "Exa",
},
pinned: false,
mentions: [],
mention_roles: [],
mention_everyone: false,
tts: false,
message_reference: {
type: 0,
channel_id: "1015204661701124206",
guild_id: "466707357099884544"
}
},
updated_to_start_thread_from_here: { updated_to_start_thread_from_here: {
t: "MESSAGE_UPDATE", t: "MESSAGE_UPDATE",
s: 19, s: 19,

View file

@ -38,15 +38,28 @@ INSERT INTO sim (user_id, username, sim_name, mxid) VALUES
('1109360903096369153', 'Amanda', 'amanda', '@_ooye_amanda:cadence.moe'), ('1109360903096369153', 'Amanda', 'amanda', '@_ooye_amanda:cadence.moe'),
('43d378d5-1183-47dc-ab3c-d14e21c3fe58', '_pk_zoego', '_pk_zoego', '@_ooye__pk_zoego:cadence.moe'), ('43d378d5-1183-47dc-ab3c-d14e21c3fe58', '_pk_zoego', '_pk_zoego', '@_ooye__pk_zoego:cadence.moe'),
('320067006521147393', 'papiophidian', 'papiophidian', '@_ooye_papiophidian:cadence.moe'), ('320067006521147393', 'papiophidian', 'papiophidian', '@_ooye_papiophidian:cadence.moe'),
('772659086046658620', 'cadence.worm', 'cadence', '@_ooye_cadence:cadence.moe'); ('772659086046658620', 'cadence.worm', 'cadence', '@_ooye_cadence:cadence.moe'),
('196188877885538304', 'ampflower', 'ampflower', '@_ooye_ampflower:cadence.moe'),
('1458668878107381800', 'Evil Lillith (she/her)', 'evil_lillith_sheher', '@_ooye_evil_lillith_sheher:cadence.moe'),
('197126718400626689', 'infinidoge1337', 'infinidoge1337', '@_ooye_infinidoge1337:cadence.moe');
INSERT INTO sim_member (mxid, room_id, hashed_profile_content) VALUES INSERT INTO sim_member (mxid, room_id, hashed_profile_content) VALUES
('@_ooye_bojack_horseman:cadence.moe', '!hYnGGlPHlbujVVfktC:cadence.moe', NULL), ('@_ooye_bojack_horseman:cadence.moe', '!hYnGGlPHlbujVVfktC:cadence.moe', NULL),
('@_ooye_cadence:cadence.moe', '!BnKuBPCvyfOkhcUjEu:cadence.moe', NULL); ('@_ooye_cadence:cadence.moe', '!BnKuBPCvyfOkhcUjEu:cadence.moe', NULL),
('@_ooye_cadence:cadence.moe', '!kLRqKKUQXcibIMtOpl:cadence.moe', NULL),
('@_ooye_cadence:cadence.moe', '!fGgIymcYWOqjbSRUdV:cadence.moe', NULL),
('@_ooye_ampflower:cadence.moe', '!qzDBLKlildpzrrOnFZ:cadence.moe', NULL),
('@_ooye__pk_zoego:cadence.moe', '!qzDBLKlildpzrrOnFZ:cadence.moe', NULL),
('@_ooye_infinidoge1337:cadence.moe', '!BnKuBPCvyfOkhcUjEu:cadence.moe', NULL),
('@_ooye_evil_lillith_sheher:cadence.moe', '!BnKuBPCvyfOkhcUjEu:cadence.moe', NULL);
INSERT INTO sim_proxy (user_id, proxy_owner_id, displayname) VALUES INSERT INTO sim_proxy (user_id, proxy_owner_id, displayname) VALUES
('43d378d5-1183-47dc-ab3c-d14e21c3fe58', '196188877885538304', 'Azalea &flwr; 🌺'); ('43d378d5-1183-47dc-ab3c-d14e21c3fe58', '196188877885538304', 'Azalea &flwr; 🌺');
INSERT INTO app_user_install (guild_id, app_bot_id, user_id) VALUES
('66192955777486848', '1458668878107381800', '197126718400626689');
INSERT INTO message_room (message_id, historical_room_index) INSERT INTO message_room (message_id, historical_room_index)
WITH a (message_id, channel_id) AS (VALUES WITH a (message_id, channel_id) AS (VALUES
('1106366167788044450', '122155380120748034'), ('1106366167788044450', '122155380120748034'),
@ -82,7 +95,8 @@ WITH a (message_id, channel_id) AS (VALUES
('1381212840957972480', '112760669178241024'), ('1381212840957972480', '112760669178241024'),
('1401760355339862066', '112760669178241024'), ('1401760355339862066', '112760669178241024'),
('1439351590262800565', '1438284564815548418'), ('1439351590262800565', '1438284564815548418'),
('1404133238414376971', '112760669178241024')) ('1404133238414376971', '112760669178241024'),
('1440549403667468320', '1160894080998461480'))
SELECT message_id, max(historical_room_index) as historical_room_index FROM a INNER JOIN historical_channel_room ON historical_channel_room.reference_channel_id = a.channel_id GROUP BY message_id; SELECT message_id, max(historical_room_index) as historical_room_index FROM a INNER JOIN historical_channel_room ON historical_channel_room.reference_channel_id = a.channel_id GROUP BY message_id;
INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part, reaction_part, source) VALUES INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part, reaction_part, source) VALUES
@ -130,7 +144,8 @@ INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part
('$7P2O_VTQNHvavX5zNJ35DV-dbJB1Ag80tGQP_JzGdhk', 'm.room.message', 'm.text', '1401760355339862066', 0, 0, 0), ('$7P2O_VTQNHvavX5zNJ35DV-dbJB1Ag80tGQP_JzGdhk', 'm.room.message', 'm.text', '1401760355339862066', 0, 0, 0),
('$ielAnR6geu0P1Tl5UXfrbxlIf-SV9jrNprxrGXP3v7M', 'm.room.message', 'm.image', '1439351590262800565', 0, 0, 0), ('$ielAnR6geu0P1Tl5UXfrbxlIf-SV9jrNprxrGXP3v7M', 'm.room.message', 'm.image', '1439351590262800565', 0, 0, 0),
('$uUKLcTQvik5tgtTGDKuzn0Ci4zcCvSoUcYn2X7mXm9I', 'm.room.message', 'm.text', '1404133238414376971', 0, 1, 1), ('$uUKLcTQvik5tgtTGDKuzn0Ci4zcCvSoUcYn2X7mXm9I', 'm.room.message', 'm.text', '1404133238414376971', 0, 1, 1),
('$LhmoWWvYyn5_AHkfb6FaXmLI6ZOC1kloql5P40YDmIk', 'm.room.message', 'm.notice', '1404133238414376971', 1, 0, 1); ('$LhmoWWvYyn5_AHkfb6FaXmLI6ZOC1kloql5P40YDmIk', 'm.room.message', 'm.notice', '1404133238414376971', 1, 0, 1),
('$l9FMmsEbh9K0NUReeEpWOMZYGRlUOE8yLcm6P-TYHSM', 'm.room.message', 'm.text', '1440549403667468320', 0, 0, 1);
INSERT INTO file (discord_url, mxc_url) VALUES INSERT INTO file (discord_url, mxc_url) VALUES
('https://cdn.discordapp.com/attachments/497161332244742154/1124628646431297546/image.png', 'mxc://cadence.moe/qXoZktDqNtEGuOCZEADAMvhM'), ('https://cdn.discordapp.com/attachments/497161332244742154/1124628646431297546/image.png', 'mxc://cadence.moe/qXoZktDqNtEGuOCZEADAMvhM'),
@ -179,6 +194,7 @@ INSERT INTO member_cache (room_id, mxid, displayname, avatar_url, power_level) V
('!TqlyQmifxGUggEmdBN:cadence.moe', '@ampflower:matrix.org', 'Ampflower 🌺', 'mxc://cadence.moe/PRfhXYBTOalvgQYtmCLeUXko', 0), ('!TqlyQmifxGUggEmdBN:cadence.moe', '@ampflower:matrix.org', 'Ampflower 🌺', 'mxc://cadence.moe/PRfhXYBTOalvgQYtmCLeUXko', 0),
('!TqlyQmifxGUggEmdBN:cadence.moe', '@aflower:syndicated.gay', 'Rose', 'mxc://syndicated.gay/ZkBUPXCiXTjdJvONpLJmcbKP', 0), ('!TqlyQmifxGUggEmdBN:cadence.moe', '@aflower:syndicated.gay', 'Rose', 'mxc://syndicated.gay/ZkBUPXCiXTjdJvONpLJmcbKP', 0),
('!TqlyQmifxGUggEmdBN:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', NULL, 0), ('!TqlyQmifxGUggEmdBN:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', NULL, 0),
('!qzDBLKlildpzrrOnFZ:cadence.moe', '@lavender.pet:queer.sh', 'lavender.pet', NULL, 0),
('!iSyXgNxQcEuXoXpsSn:pussthecat.org', '@austin:tchncs.de', 'Austin Huang', 'mxc://tchncs.de/090a2b5e07eed2f71e84edad5207221e6c8f8b8e', 0), ('!iSyXgNxQcEuXoXpsSn:pussthecat.org', '@austin:tchncs.de', 'Austin Huang', 'mxc://tchncs.de/090a2b5e07eed2f71e84edad5207221e6c8f8b8e', 0),
('!zq94fae5bVKUubZLp7:agiadn.org', '@underscore_x:agiadn.org', 'underscore_x', NULL, 100); ('!zq94fae5bVKUubZLp7:agiadn.org', '@underscore_x:agiadn.org', 'underscore_x', NULL, 100);

View file

@ -6,31 +6,29 @@ const sqlite = require("better-sqlite3")
const {Writable} = require("stream") const {Writable} = require("stream")
const migrate = require("../src/db/migrate") const migrate = require("../src/db/migrate")
const HeatSync = require("heatsync") const HeatSync = require("heatsync")
const {test, extend} = require("supertape") const {test} = require("supertape")
const data = require("./data") const data = require("./data")
const {green} = require("ansi-colors") const {green} = require("ansi-colors")
const mixin = require("@cloudrac3r/mixin-deep")
const passthrough = require("../src/passthrough") const passthrough = require("../src/passthrough")
const db = new sqlite(":memory:") const db = new sqlite(":memory:")
const {reg} = require("../src/matrix/read-registration") const registration = require("../src/matrix/read-registration")
reg.ooye.discord_token = "Njg0MjgwMTkyNTUzODQ0NzQ3.Xl3zlw.baby" registration.reg = mixin(registration.getTemplateRegistration("cadence.moe"), {
reg.ooye.server_origin = "https://matrix.cadence.moe" // so that tests will pass even when hard-coded id: "baby",
reg.ooye.server_name = "cadence.moe" url: "http://localhost:6693",
reg.ooye.namespace_prefix = "_ooye_" as_token: "don't actually take authenticated actions on the server",
reg.sender_localpart = "_ooye_bot" hs_token: "don't actually take authenticated actions on the server",
reg.id = "baby" ooye: {
reg.as_token = "don't actually take authenticated actions on the server" server_origin: "https://matrix.cadence.moe",
reg.hs_token = "don't actually take authenticated actions on the server" bridge_origin: "https://bridge.example.org",
reg.namespaces = { discord_token: "Njg0MjgwMTkyNTUzODQ0NzQ3.Xl3zlw.baby",
users: [{regex: "@_ooye_.*:cadence.moe", exclusive: true}], discord_client_secret: "baby",
aliases: [{regex: "#_ooye_.*:cadence.moe", exclusive: true}] web_password: "password123",
} time_zone: "Pacific/Auckland",
reg.ooye.bridge_origin = "https://bridge.example.org" }
reg.ooye.time_zone = "Pacific/Auckland" })
reg.ooye.max_file_size = 5000000
reg.ooye.web_password = "password123"
reg.ooye.include_user_id_in_mxid = false
const sync = new HeatSync({watchFS: false}) const sync = new HeatSync({watchFS: false})
@ -154,6 +152,7 @@ file._actuallyUploadDiscordFileToMxc = function(url, res) { throw new Error(`Not
require("../src/d2m/converters/message-to-event.test.embeds") require("../src/d2m/converters/message-to-event.test.embeds")
require("../src/d2m/converters/message-to-event.test.pk") require("../src/d2m/converters/message-to-event.test.pk")
require("../src/d2m/converters/pins-to-list.test") require("../src/d2m/converters/pins-to-list.test")
require("../src/d2m/converters/remove-member-mxids.test")
require("../src/d2m/converters/remove-reaction.test") require("../src/d2m/converters/remove-reaction.test")
require("../src/d2m/converters/thread-to-announcement.test") require("../src/d2m/converters/thread-to-announcement.test")
require("../src/d2m/converters/user-to-mxid.test") require("../src/d2m/converters/user-to-mxid.test")