Compare commits

...

24 commits

Author SHA1 Message Date
d45a0bdc10 UI for linking existing space 2025-02-04 02:45:38 +13:00
3d0609f8f1 Fix duplicate guilds in list 2025-02-03 23:30:32 +13:00
978eb40e1d Store invites in database 2025-02-03 16:37:56 +13:00
f9be1e39a1 Improve dropdown button 2025-02-03 15:48:16 +13:00
443618b974 Log in with Matrix 2025-02-02 01:23:36 +13:00
63cc089bdb Reset room topic immediately if it is cleared 2025-02-01 23:26:24 +13:00
ad51079448 Don't overwrite room custom topics 2025-02-01 23:12:50 +13:00
eec8b0f15b Add loading indicator to invite screens 2025-02-01 22:27:27 +13:00
17ea92a8c2 Fix unlinking left rooms 2025-02-01 22:11:32 +13:00
ae57fa2801 Only announce if they can reasonably type here 2025-02-01 22:03:41 +13:00
5b21344a65 Add room list debugger 2025-02-01 01:40:59 +13:00
cf8867f945 Fix test 2025-01-31 16:50:48 +13:00
eb4aa615be Fix web loading indicators 2025-01-31 16:42:48 +13:00
a459ee1d1c Use htmx.js instead of htmx.min.js
This wastes 30 kB gzipped, which I think is acceptable in exchange for
having method names in the debugger.
2025-01-31 16:42:15 +13:00
b1b9124052 Fully support unlinking channels 2025-01-31 15:09:01 +13:00
5c0e830658 Display XHR errors 2025-01-31 15:07:48 +13:00
d4a50cb8aa Do not run as root 2025-01-30 22:25:25 +13:00
6fe8c60f11 Add analyze of new data 2025-01-30 15:34:29 +13:00
a579b509d3 Catch PK API network errors 2025-01-28 16:08:43 +13:00
eadefef6a3 Clean up member_cache when unbridging 2025-01-21 15:08:12 +13:00
5b06d5984a Do cache space members in member_cache 2025-01-20 02:33:24 +13:00
f42eb6495f New unicode emoji processor 2025-01-17 18:05:34 +13:00
14574b4e2c Support alternate Discord hosts 2025-01-17 11:40:34 +13:00
8ad299b04c Add foreign keys to database 2025-01-17 11:33:29 +13:00
56 changed files with 10741 additions and 341 deletions

98
docs/foreign-keys.md Normal file
View file

@ -0,0 +1,98 @@
# Foreign keys in the Out Of Your Element database
Historically, Out Of Your Element did not use foreign keys in the database, but since I found a need for them, I have decided to add them. Referential integrity is probably valuable as well.
The need is that unlinking a channel and room using the web interface should clear up all related entries from `message_channel`, `event_message`, `reaction`, etc. Without foreign keys, this requires multiple DELETEs with tricky queries. With foreign keys and ON DELETE CASCADE, this just works.
## Quirks
* **REPLACE INTO** internally causes a DELETE followed by an INSERT, and the DELETE part **will trigger any ON DELETE CASCADE** foreign key conditions on the table, even when the primary key being replaced is the same.
* ```sql
CREATE TABLE discord_channel (channel_id TEXT NOT NULL, name TEXT NOT NULL, PRIMARY KEY (channel_id));
CREATE TABLE discord_message (message_id TEXT NOT NULL, channel_id TEXT NOT NULL, PRIMARY KEY (message_id),
FOREIGN KEY (channel_id) REFERENCES discord_channel (channel_id) ON DELETE CASCADE);
INSERT INTO discord_channel (channel_id, name) VALUES ("c_1", "place");
INSERT INTO discord_message (message_id, channel_id) VALUES ("m_2", "c_1"); -- i love my message
REPLACE INTO discord_channel (channel_id, name) VALUES ("c_1", "new place"); -- replace into time
-- i love my message
SELECT * FROM discord_message; -- where is my message
```
* In SQLite, `pragma foreign_keys = on` must be set **for each connection** after it's established. I've added this at the start of `migrate.js`, which is called by all database connections.
* Pragma? Pragma keys
* Whenever a child row is inserted, SQLite will look up a row from the parent table to ensure referential integrity. This means **the parent table should be sufficiently keyed or indexed on columns referenced by foreign keys**, or SQLite won't let you do it, with a cryptic error message later on during DML. Due to normal forms, foreign keys naturally tend to reference the parent table's primary key, which is indexed, so that's okay. But still keep this in mind, since many of OOYE's tables effectively have two primary keys, for the Discord and Matrix IDs. A composite primary key doesn't count, even when it's the first column. A unique index counts.
## Where keys
Here are some tables that could potentially have foreign keys added between them, and my thought process of whether foreign keys would be a good idea:
* `guild_active` <--(PK guild_id FK)-- `channel_room`
* Could be good for referential integrity.
* Linking to guild_space would be pretty scary in case the guild was being relinked to a different space - since rooms aren't tied to a space, this wouldn't actually disturb anything. So I pick guild_active instead.
* `channel_room` <--(PK channel_id FK)-- `message_channel`
* Seems useful as we want message records to be deleted when a channel is unlinked.
* `message_channel` <--(PK message_id PK)-- `event_message`
* Seems useful as we want event information to be deleted when a channel is unlinked.
* `guild_active` <--(PK guild_id PK)-- `guild_space`
* All bridged guilds should have a corresponding guild_active entry, so referential integrity would be useful here to make sure we haven't got any weird states.
* `channel_room` <--(**C** room_id PK)-- `member_cache`
* Seems useful as we want to clear the member cache when a channel is unlinked.
* There is no index on `channel_room.room_id` right now. It would be good to create this index. Will just make it UNIQUE in the table definition.
* `message_channel` <--(PK message_id FK)-- `reaction`
* Seems useful as we want to clear the reactions cache when a channel is unlinked.
* `sim` <--(**C** mxid FK)-- `sim_member`
* OOYE inner joins on this.
* Sims are never deleted so if this was added it would only be used for enforcing referential integrity.
* The storage cost of the additional index on `sim` would not be worth the benefits.
* `channel_room` <--(**C** room_id PK)-- `sim_member`
* If a room is being permanently unlinked, it may be useful to see a populated member list. If it's about to be relinked to another channel, we want to keep the sims in the room for more speed and to avoid spamming state events into the timeline.
* Either way, the sims could remain in the room even after it's been unlinked. So no referential integrity is desirable here.
* `sim` <--(PK user_id PK)-- `sim_proxy`
* OOYE left joins on this. In normal operation, this relationship might not exist.
* `channel_room` <--(PK channel_id PK)-- `webhook`
* Seems useful. Webhooks should be deleted from Discord just before the channel is unlinked. That should be mirrored in the database too.
## Occurrences of REPLACE INTO/DELETE FROM
* `edit-message.js``REPLACE INTO message_channel`
* Scary! Changed to INSERT OR IGNORE
* `send-message.js``REPLACE INTO message_channel`
* Changed to INSERT OR IGNORE
* `add-reaction.js``REPLACE INTO reaction`
* `channel-webhook.js``REPLACE INTO webhook`
* `send-event.js``REPLACE INTO message_channel`
* Seems incorrect? Maybe?? Originally added in fcbb045. Changed to INSERT
* `event-to-message.js``REPLACE INTO member_cache`
* `oauth.js``REPLACE INTO guild_active`
* Very scary!! Changed to INSERT .. ON CONFLICT DO UPDATE
* `create-room.js``DELETE FROM channel_room`
* Please cascade
* `delete-message.js`
* Removed redundant DELETEs
* `edit-message.js``DELETE FROM event_message`
* `register-pk-user.js``DELETE FROM sim`
* It's a failsafe during creation
* `register-user.js``DELETE FROM sim`
* It's a failsafe during creation
* `remove-reaction.js``DELETE FROM reaction`
* `event-dispatcher.js``DELETE FROM member_cache`
* `redact.js``DELETE FROM event_message`
* Removed this redundant DELETE
* `send-event.js``DELETE FROM event_message`
* Removed this redundant DELETE
## How keys
SQLite does not have a complete ALTER TABLE command, so I have to DROP and CREATE. According to [the docs](https://www.sqlite.org/lang_altertable.html), the correct strategy is:
1. (Not applicable) *If foreign key constraints are enabled, disable them using PRAGMA foreign_keys=OFF.*
2. Start a transaction.
3. (Not applicable) *Remember the format of all indexes, triggers, and views associated with table X. This information will be needed in step 8 below. One way to do this is to run a query like the following: SELECT type, sql FROM sqlite_schema WHERE tbl_name='X'.*
4. Use CREATE TABLE to construct a new table "new_X" that is in the desired revised format of table X. Make sure that the name "new_X" does not collide with any existing table name, of course.
5. Transfer content from X into new_X using a statement like: INSERT INTO new_X SELECT ... FROM X.
6. Drop the old table X: DROP TABLE X.
7. Change the name of new_X to X using: ALTER TABLE new_X RENAME TO X.
8. (Not applicable) *Use CREATE INDEX, CREATE TRIGGER, and CREATE VIEW to reconstruct indexes, triggers, and views associated with table X. Perhaps use the old format of the triggers, indexes, and views saved from step 3 above as a guide, making changes as appropriate for the alteration.*
9. (Not applicable) *If any views refer to table X in a way that is affected by the schema change, then drop those views using DROP VIEW and recreate them with whatever changes are necessary to accommodate the schema change using CREATE VIEW.*
10. If foreign key constraints were originally enabled then run PRAGMA foreign_key_check to verify that the schema change did not break any foreign key constraints.
11. Commit the transaction started in step 2.
12. (Not applicable) *If foreign keys constraints were originally enabled, reenable them now.*

View file

@ -69,3 +69,9 @@ So here's all the technical changes needed to support self-service in v3:
- When bot is added through "self-service" web button, REPLACE INTO state 0.
- Event dispatcher will only ensureRoom if the guild_active state is 1.
- createRoom will only create other dependencies if the guild is autocreate.
## Enough with your theory. How do rooms actually get bridged now?
After clicking the easy mode button on web and adding the bot to a server, it will create new Matrix rooms on-demand when any invite features are used (web or command) OR just when any message is sent on Discord.
Alternatively, pressing the self-service mode button and adding the bot to a server will prompt the web user to link it with a space. After doing so, they'll be on the standard guild management page where they can invite to the space and manually link rooms. Nothing will be autocreated.

22
package-lock.json generated
View file

@ -14,7 +14,7 @@
"@cloudrac3r/giframe": "^0.4.3",
"@cloudrac3r/html-template-tag": "^5.0.1",
"@cloudrac3r/in-your-element": "^1.0.0",
"@cloudrac3r/mixin-deep": "^3.0.0",
"@cloudrac3r/mixin-deep": "^3.0.1",
"@cloudrac3r/pngjs": "^7.0.3",
"@cloudrac3r/pug": "^4.0.4",
"@cloudrac3r/turndown": "^7.1.4",
@ -281,9 +281,10 @@
}
},
"node_modules/@cloudrac3r/mixin-deep": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@cloudrac3r/mixin-deep/-/mixin-deep-3.0.0.tgz",
"integrity": "sha512-yQz1wHSZbHfbKaGSjrV3wIG0e9MnElKlmekMKJPRdTn2jhF2Mt8wfMPX8U7v6rTyzR/7BTrX8CCUcrJMLgoQqw==",
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@cloudrac3r/mixin-deep/-/mixin-deep-3.0.1.tgz",
"integrity": "sha512-awxfIraHjJ/URNlZ0ROc78Tdjtfk/fM/Gnj1embfrSN08h/HpRtLmPc3xlG3T2vFAy1AkONaebd52u7o6kDaYw==",
"license": "MIT",
"engines": {
"node": ">=6"
}
@ -923,9 +924,10 @@
"dev": true
},
"node_modules/@stackoverflow/stacks": {
"version": "2.5.7",
"resolved": "https://registry.npmjs.org/@stackoverflow/stacks/-/stacks-2.5.7.tgz",
"integrity": "sha512-1ipTt7jqUszyd78Gn9TADT22PL0yXe14iEfgZyvJlDvrNrmyJLoGsFMRMwcduPol6/C/zkFt2dmfph/5vFDcYA==",
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@stackoverflow/stacks/-/stacks-2.7.0.tgz",
"integrity": "sha512-nn4tow6oTsYlpKwOcpPeKclFMvn0Py+rWCZppRWqcEVl9w2+U+nU7QyKsLzySvSFgXoo5hrBPWp5t7AlNVmF0A==",
"license": "MIT",
"dependencies": {
"@hotwired/stimulus": "^3.2.2",
"@popperjs/core": "^2.11.8"
@ -3217,9 +3219,9 @@
"license": "MIT"
},
"node_modules/undici": {
"version": "6.19.8",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.19.8.tgz",
"integrity": "sha512-U8uCCl2x9TK3WANvmBavymRzxbfFYG+tAu+fgx3zxQy3qdagQqBLwJVrdyO1TBfUXvfKveMKJZhpvUYoOjM+4g==",
"version": "6.21.1",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.21.1.tgz",
"integrity": "sha512-q/1rj5D0/zayJB2FraXdaWxbhWiNKDvu8naDT2dl1yTlvJp4BLtOcp2a5BvgGNQpYYJzau7tf1WgKv3b+7mqpQ==",
"license": "MIT",
"engines": {
"node": ">=18.17"

View file

@ -23,7 +23,7 @@
"@cloudrac3r/giframe": "^0.4.3",
"@cloudrac3r/html-template-tag": "^5.0.1",
"@cloudrac3r/in-your-element": "^1.0.0",
"@cloudrac3r/mixin-deep": "^3.0.0",
"@cloudrac3r/mixin-deep": "^3.0.1",
"@cloudrac3r/pngjs": "^7.0.3",
"@cloudrac3r/pug": "^4.0.4",
"@cloudrac3r/turndown": "^7.1.4",

View file

@ -62,7 +62,7 @@ Only necessary data and columns are queried from the database. We only contact t
File uploads (like avatars from bridged members) are checked locally and deduplicated. Only brand new files are uploaded to the homeserver. This saves loads of space in the homeserver's media repo, especially for Synapse.
Switching to [WAL mode](https://www.sqlite.org/wal.html) could improve your database access speed even more. Run `node scripts/wal.js` if you want to switch to WAL mode. This will also enable `synchronous = NORMAL`.
Switching to [WAL mode](https://www.sqlite.org/wal.html) could improve your database access speed even more. Run `node scripts/wal.js` if you want to switch to WAL mode. (This will also enable `synchronous = NORMAL`.)
# Setup
@ -78,6 +78,8 @@ Follow these steps:
1. [Get Node.js version 20 or later](https://nodejs.org/en/download/prebuilt-installer)
1. Switch to a normal user account. (i.e. do not run any of the following commands as root or sudo.)
1. Clone this repo and checkout a specific tag. (Development happens on main. Stable versions are tagged.)
* The latest release tag is ![](https://img.shields.io/gitea/v/release/cadence/out-of-your-element?gitea_url=https%3A%2F%2Fgitdab.com&style=flat-square&label=%20&color=black).

View file

@ -0,0 +1,77 @@
// @ts-check
const fs = require("fs")
const {join} = require("path")
const s = fs.readFileSync(join(__dirname, "..", "src", "m2d", "converters", "emojis.txt"), "utf8").split("\n").map(x => encodeURIComponent(x))
const searchPattern = "%EF%B8%8F"
/**
* adapted from es.map.group-by.js in core-js
* @template K,V
* @param {V[]} items
* @param {(item: V) => K} fn
* @returns {Map<K, V[]>}
*/
function groupBy(items, fn) {
var map = new Map();
for (const value of items) {
var key = fn(value);
if (!map.has(key)) map.set(key, [value]);
else map.get(key).push(value);
}
return map;
}
/**
* @param {number[]} items
* @param {number} width
*/
function xhistogram(items, width) {
const chars = " ▏▎▍▌▋▊▉"
const max = items.reduce((a, c) => c > a ? c : a, 0)
return items.map(v => {
const p = v / max * (width-1)
return (
Array(Math.floor(p)).fill("█").join("") /* whole part */
+ chars[Math.ceil((p % 1) * (chars.length-1))] /* decimal part */
).padEnd(width)
})
}
/**
* @param {number[]} items
* @param {[number, number]} xrange
*/
function yhistogram(items, xrange, printHeader = false) {
const chars = "░▁_▂▃▄▅▆▇█"
const ones = "₀₁₂₃₄₅₆₇₈₉"
const tens = "0123456789"
const xy = []
let max = 0
/** value (x) -> frequency (y) */
const grouped = groupBy(items, x => x)
for (let i = xrange[0]; i <= xrange[1]; i++) {
if (printHeader) {
if (i === -1) process.stdout.write("-")
else if (i.toString().at(-1) === "0") process.stdout.write(tens[i/10])
else process.stdout.write(ones[i%10])
}
const y = grouped.get(i)?.length ?? 0
if (y > max) max = y
xy.push(y)
}
if (printHeader) console.log()
return xy.map(y => chars[Math.ceil(y / max * (chars.length-1))]).join("")
}
const grouped = groupBy(s, x => x.length)
const sortedGroups = [...grouped.entries()].sort((a, b) => b[0] - a[0])
let length = 0
const lengthHistogram = xhistogram(sortedGroups.map(v => v[1].length), 10)
for (let i = 0; i < sortedGroups.length; i++) {
const [k, v] = sortedGroups[i]
const l = lengthHistogram[i]
const h = yhistogram(v.map(x => x.indexOf(searchPattern)), [-1, k - searchPattern.length], i === 0)
if (i === 0) length = h.length + 1
console.log(`${h.padEnd(length, i % 2 === 0 ? "⸱" : " ")}length ${k.toString().padEnd(3)} ${l} ${v.length}`)
}

View file

@ -319,8 +319,8 @@ function defineEchoHandler() {
await migrate.migrate(db)
// add initial rows to database, like adding the bot to sim...
const botID = Buffer.from(reg.ooye.discord_token.split(".")[0], "base64").toString()
db.prepare("INSERT OR IGNORE INTO sim (user_id, sim_name, localpart, mxid) VALUES (?, ?, ?, ?)").run(botID, reg.sender_localpart.slice(reg.ooye.namespace_prefix.length), reg.sender_localpart, mxid)
const client = await discord.snow.user.getSelf()
db.prepare("INSERT INTO sim (user_id, username, sim_name, mxid) VALUES (?, ?, ?, ?) ON CONFLICT DO NOTHING").run(client.id, client.username, reg.sender_localpart.slice(reg.ooye.namespace_prefix.length), mxid)
console.log("✅ Database is ready...")

View file

@ -38,4 +38,6 @@ passthrough.select = orm.select
await discord.cloud.connect()
console.log("Discord gateway started")
sync.require("../src/web/server")
require("../src/stdin")
})()

View file

@ -6,15 +6,19 @@ const Ty = require("../../types")
const {reg} = require("../../matrix/read-registration")
const passthrough = require("../../passthrough")
const {discord, sync, db, select} = passthrough
const {discord, sync, db, select, from} = passthrough
/** @type {import("../../matrix/file")} */
const file = sync.require("../../matrix/file")
/** @type {import("../../matrix/api")} */
const api = sync.require("../../matrix/api")
/** @type {import("../../matrix/mreq")} */
const mreq = sync.require("../../matrix/mreq")
/** @type {import("../../matrix/kstate")} */
const ks = sync.require("../../matrix/kstate")
/** @type {import("../../discord/utils")} */
const utils = sync.require("../../discord/utils")
const dUtils = sync.require("../../discord/utils")
/** @type {import("../../m2d/converters/utils")} */
const mUtils = sync.require("../../m2d/converters/utils")
/** @type {import("./create-space")} */
const createSpace = sync.require("./create-space")
@ -85,9 +89,10 @@ async function channelToKState(channel, guild, di) {
assert(typeof parentSpaceID === "string")
}
const channelRow = select("channel_room", ["nick", "custom_avatar"], {channel_id: channel.id}).get()
const channelRow = select("channel_room", ["nick", "custom_avatar", "custom_topic"], {channel_id: channel.id}).get()
const customName = channelRow?.nick
const customAvatar = channelRow?.custom_avatar
const hasCustomTopic = channelRow?.custom_topic
const [convertedName, convertedTopic] = convertNameAndTopic(channel, guild, customName)
const avatarEventContent = {}
@ -114,8 +119,8 @@ async function channelToKState(channel, guild, di) {
join_rules = {join_rule: PRIVACY_ENUMS.ROOM_JOIN_RULES[privacyLevel]}
}
const everyonePermissions = utils.getPermissions([], guild.roles, undefined, channel.permission_overwrites)
const everyoneCanMentionEveryone = utils.hasAllPermissions(everyonePermissions, ["MentionEveryone"])
const everyonePermissions = dUtils.getPermissions([], guild.roles, undefined, channel.permission_overwrites)
const everyoneCanMentionEveryone = dUtils.hasAllPermissions(everyonePermissions, ["MentionEveryone"])
const globalAdmins = select("member_power", ["mxid", "power_level"], {room_id: "*"}).all()
const globalAdminPower = globalAdmins.reduce((a, c) => (a[c.mxid] = c.power_level, a), {})
@ -163,6 +168,8 @@ async function channelToKState(channel, guild, di) {
}
}
if (hasCustomTopic) delete channelKState["m.room.topic/"]
return {spaceID: parentSpaceID, privacyLevel, channelKState}
}
@ -392,7 +399,7 @@ function syncRoom(channelID) {
return _syncRoom(channelID, true)
}
async function _unbridgeRoom(channelID) {
async function unbridgeChannel(channelID) {
/** @ts-ignore @type {DiscordTypes.APIGuildChannel} */
const channel = discord.channels.get(channelID)
assert.ok(channel)
@ -407,31 +414,80 @@ async function _unbridgeRoom(channelID) {
async function unbridgeDeletedChannel(channel, guildID) {
const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get()
assert.ok(roomID)
const spaceID = select("guild_space", "space_id", {guild_id: guildID}).pluck().get()
assert.ok(spaceID)
const row = from("guild_space").join("guild_active", "guild_id").select("space_id", "autocreate").get()
assert.ok(row)
// remove room from being a space member
await api.sendState(roomID, "m.space.parent", spaceID, {})
await api.sendState(spaceID, "m.space.child", roomID, {})
let botInRoom = true
// remove declaration that the room is bridged
try {
await api.sendState(roomID, "uk.half-shot.bridge", `moe.cadence.ooye://discord/${guildID}/${channel.id}`, {})
if ("topic" in channel) {
} catch (e) {
if (String(e).includes("not in room")) {
botInRoom = false
} else {
throw e
}
}
if (botInRoom && "topic" in channel) {
// previously the Matrix topic would say the channel ID. we should remove that
await api.sendState(roomID, "m.room.topic", "", {topic: channel.topic || ""})
}
// delete webhook on discord
const webhook = select("webhook", ["webhook_id", "webhook_token"], {channel_id: channel.id}).get()
if (webhook) {
await discord.snow.webhook.deleteWebhook(webhook.webhook_id, webhook.webhook_token)
db.prepare("DELETE FROM webhook WHERE channel_id = ?").run(channel.id)
}
// delete room from database
db.prepare("DELETE FROM member_cache WHERE room_id = ?").run(roomID)
db.prepare("DELETE FROM channel_room WHERE room_id = ? AND channel_id = ?").run(roomID, channel.id) // cascades to most other tables, like messages
if (!botInRoom) return
// demote admins in room
/** @type {Ty.Event.M_Power_Levels} */
const powerLevelContent = await api.getStateEvent(roomID, "m.room.power_levels", "")
powerLevelContent.users ??= {}
const bot = `@${reg.sender_localpart}:${reg.ooye.server_name}`
for (const mxid of Object.keys(powerLevelContent.users)) {
if (powerLevelContent.users[mxid] >= 100 && mUtils.eventSenderIsFromDiscord(mxid) && mxid !== bot) {
delete powerLevelContent.users[mxid]
await api.sendState(roomID, "m.room.power_levels", "", powerLevelContent, mxid)
}
}
// send a notification in the room
await api.sendEvent(roomID, "m.room.message", {
msgtype: "m.notice",
body: "⚠️ This room was removed from the bridge."
})
// if it is an easy mode room, clean up the room from the managed space and make it clear it's not being bridged
// (don't do this for self-service rooms, because they might continue to be used on Matrix or linked somewhere else later)
if (row.autocreate === 1) {
// remove room from being a space member
await api.sendState(roomID, "m.space.parent", row.space_id, {})
await api.sendState(row.space_id, "m.space.child", roomID, {})
// leave room
await api.leaveRoom(roomID)
}
// delete room from database
db.prepare("DELETE FROM channel_room WHERE room_id = ? AND channel_id = ?").run(roomID, channel.id)
// if it is a self-service room, remove sim members
// (the room can be used with less clutter and the member list makes sense if it's bridged somewhere else)
if (row.autocreate === 0) {
// remove sim members
const members = select("sim_member", "mxid", {room_id: roomID}).pluck().all()
const preparedDelete = db.prepare("DELETE FROM sim_member WHERE room_id = ? AND mxid = ?")
for (const mxid of members) {
await api.leaveRoom(roomID, mxid)
preparedDelete.run(roomID, mxid)
}
}
}
/**
@ -480,7 +536,7 @@ module.exports.createAllForGuild = createAllForGuild
module.exports.channelToKState = channelToKState
module.exports.postApplyPowerLevels = postApplyPowerLevels
module.exports._convertNameAndTopic = convertNameAndTopic
module.exports._unbridgeRoom = _unbridgeRoom
module.exports.unbridgeChannel = unbridgeChannel
module.exports.unbridgeDeletedChannel = unbridgeDeletedChannel
module.exports.existsOrAutocreatable = existsOrAutocreatable
module.exports.assertExistsOrAutocreatable = assertExistsOrAutocreatable

View file

@ -94,6 +94,26 @@ test("channel2room: room where limited people can mention everyone", async t =>
t.equal(called, 1)
})
test("channel2room: matrix room that already has a custom topic set", async t => {
let called = 0
async function getStateEvent(roomID, type, key) { // getting power levels from space to apply to room
called++
t.equal(roomID, "!jjWAGMeQdNrVZSSfvz:cadence.moe")
t.equal(type, "m.room.power_levels")
t.equal(key, "")
return {}
}
db.prepare("UPDATE channel_room SET custom_topic = 1 WHERE channel_id = ?").run(testData.channel.general.id)
const expected = mixin({}, testData.room.general, {"m.room.power_levels/": {notifications: {room: 20}}})
// @ts-ignore
delete expected["m.room.topic/"]
t.deepEqual(
kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general, {api: {getStateEvent}}).then(x => x.channelKState)),
expected
)
t.equal(called, 1)
})
test("convertNameAndTopic: custom name and topic", t => {
t.deepEqual(
_convertNameAndTopic({id: "123", name: "the-twilight-zone", topic: "Spooky stuff here. :ghost:", type: 0}, {id: "456"}, "hauntings"),

View file

@ -16,7 +16,6 @@ async function deleteMessage(data) {
const eventsToRedact = select("event_message", "event_id", {message_id: data.id}).pluck().all()
db.prepare("DELETE FROM message_channel WHERE message_id = ?").run(data.id)
db.prepare("DELETE FROM event_message WHERE message_id = ?").run(data.id)
for (const eventID of eventsToRedact) {
// Unfortunately, we can't specify a sender to do the redaction as, unless we find out that info via the audit logs
await api.redactEvent(row.room_id, eventID)
@ -35,7 +34,6 @@ async function deleteMessageBulk(data) {
const sids = JSON.stringify(data.ids)
const eventsToRedact = from("event_message").pluck("event_id").and("WHERE message_id IN (SELECT value FROM json_each(?))").all(sids)
db.prepare("DELETE FROM message_channel WHERE message_id IN (SELECT value FROM json_each(?))").run(sids)
db.prepare("DELETE FROM event_message WHERE message_id IN (SELECT value FROM json_each(?))").run(sids)
for (const eventID of eventsToRedact) {
// Awaiting will make it go slower, but since this could be a long-running operation either way, we want to leave rate limit capacity for other operations
await api.redactEvent(roomID, eventID)

View file

@ -61,7 +61,7 @@ async function editMessage(message, guild, row) {
// 4. Send all the things.
if (eventsToSend.length) {
db.prepare("REPLACE INTO message_channel (message_id, channel_id) VALUES (?, ?)").run(message.id, message.channel_id)
db.prepare("INSERT OR IGNORE INTO message_channel (message_id, channel_id) VALUES (?, ?)").run(message.id, message.channel_id)
}
for (const content of eventsToSend) {
const eventType = content.$type

View file

@ -33,7 +33,7 @@ async function createSim(pkMessage) {
const mxid = `@${localpart}:${reg.ooye.server_name}`
// Save chosen name in the database forever
db.prepare("INSERT INTO sim (user_id, sim_name, localpart, mxid) VALUES (?, ?, ?, ?)").run(pkMessage.member.uuid, simName, localpart, mxid)
db.prepare("INSERT INTO sim (user_id, username, sim_name, mxid) VALUES (?, ?, ?, ?)").run(pkMessage.member.uuid, simName, simName, mxid)
// Register matrix user with that name
try {
@ -146,15 +146,20 @@ async function fetchMessage(messageID) {
// Their backend is weird. Sometimes it says "message not found" (code 20006) on the first try, so we make multiple attempts.
let attempts = 0
do {
try {
var res = await fetch(`https://api.pluralkit.me/v2/messages/${messageID}`)
if (res.ok) return res.json()
var errorGetter = res.json
} catch (e) {
// Catch any network issues too.
errorGetter = e.toString
}
// I think the backend needs some time to update.
await new Promise(resolve => setTimeout(resolve, 2000))
await new Promise(resolve => setTimeout(resolve, 1500))
} while (++attempts < 3)
const errorMessage = await res.json()
throw new Error(`PK API returned an error after ${attempts} tries: ${JSON.stringify(errorMessage)}`)
throw new Error(`PK API returned an error after ${attempts} tries: ${JSON.stringify(await errorGetter())}`)
}
module.exports._memberToStateContent = memberToStateContent

View file

@ -33,7 +33,7 @@ async function createSim(user) {
// Save chosen name in the database forever
// Making this database change right away so that in a concurrent registration, the 2nd registration will already have generated a different localpart because it can see this row when it generates
db.prepare("INSERT INTO sim (user_id, sim_name, localpart, mxid) VALUES (?, ?, ?, ?)").run(user.id, simName, localpart, mxid)
db.prepare("INSERT INTO sim (user_id, username, sim_name, mxid) VALUES (?, ?, ?, ?)").run(user.id, user.username, simName, mxid)
// Register matrix user with that name
try {

View file

@ -53,7 +53,7 @@ async function removeReaction(data, reactions) {
*/
async function removeEmojiReaction(data, reactions) {
const key = await emojiToKey.emojiToKey(data.emoji)
const discordPreferredEncoding = emoji.encodeEmoji(key, undefined)
const discordPreferredEncoding = await emoji.encodeEmoji(key, undefined)
db.prepare("DELETE FROM reaction WHERE message_id = ? AND encoded_emoji = ?").run(data.message_id, discordPreferredEncoding)
return converter.removeEmojiReaction(data, reactions, key)

View file

@ -47,7 +47,7 @@ async function sendMessage(message, channel, guild, row) {
const events = await messageToEvent.messageToEvent(message, guild, {}, {api})
const eventIDs = []
if (events.length) {
db.prepare("REPLACE INTO message_channel (message_id, channel_id) VALUES (?, ?)").run(message.id, message.channel_id)
db.prepare("INSERT OR IGNORE INTO message_channel (message_id, channel_id) VALUES (?, ?)").run(message.id, message.channel_id)
if (senderMxid) api.sendTyping(roomID, false, senderMxid).catch(() => {})
}
for (const event of events) {

View file

@ -64,7 +64,7 @@ function userToSimName(user) {
}
// 1. Is sim user already registered?
const existing = select("sim", "sim_name", {user_id: user.id}).pluck().get()
const existing = select("sim", "user_id", {user_id: user.id}).pluck().get()
assert.equal(existing, null, "Shouldn't try to create a new name for an existing sim")
// 2. Register based on username (could be new or old format)

View file

@ -1,10 +1,14 @@
// @ts-check
const { SnowTransfer } = require("snowtransfer")
const { Client: CloudStorm } = require("cloudstorm")
const {Endpoints, SnowTransfer} = require("snowtransfer")
const {reg} = require("../matrix/read-registration")
const {Client: CloudStorm} = require("cloudstorm")
// @ts-ignore
Endpoints.BASE_HOST = reg.ooye.discord_origin || "https://discord.com"; Endpoints.CDN_URL = reg.ooye.discord_cdn_origin || "https://cdn.discordapp.com"
const passthrough = require("../passthrough")
const { sync } = passthrough
const {sync} = passthrough
/** @type {import("./discord-packets")} */
const discordPackets = sync.require("./discord-packets")

View file

@ -6,7 +6,8 @@ const {join} = require("path")
async function migrate(db) {
let files = fs.readdirSync(join(__dirname, "migrations"))
files = files.sort()
db.prepare("CREATE TABLE IF NOT EXISTS migration (filename TEXT NOT NULL)").run()
db.prepare("CREATE TABLE IF NOT EXISTS migration (filename TEXT NOT NULL, PRIMARY KEY (filename)) WITHOUT ROWID").run()
/** @type {string} */
let progress = db.prepare("SELECT * FROM migration").pluck().get()
if (!progress) {
progress = ""
@ -37,6 +38,8 @@ async function migrate(db) {
if (migrationRan) {
console.log("Database migrations all done.")
}
db.pragma("foreign_keys = on")
}
module.exports.migrate = migrate

View file

@ -0,0 +1,147 @@
-- /docs/foreign-keys.md
-- 2
BEGIN TRANSACTION;
-- *** channel_room ***
-- 4
-- adding UNIQUE to room_id here will auto-generate the usable index we wanted
CREATE TABLE "new_channel_room" (
"channel_id" TEXT NOT NULL,
"room_id" TEXT NOT NULL UNIQUE,
"name" TEXT NOT NULL,
"nick" TEXT,
"thread_parent" TEXT,
"custom_avatar" TEXT,
"last_bridged_pin_timestamp" INTEGER,
"speedbump_id" TEXT,
"speedbump_checked" INTEGER,
"speedbump_webhook_id" TEXT,
"guild_id" TEXT,
PRIMARY KEY("channel_id"),
FOREIGN KEY("guild_id") REFERENCES "guild_active"("guild_id") ON DELETE CASCADE
) WITHOUT ROWID;
-- 5
INSERT INTO new_channel_room (channel_id, room_id, name, nick, thread_parent, custom_avatar, last_bridged_pin_timestamp, speedbump_id, speedbump_checked, speedbump_webhook_id, guild_id) SELECT channel_id, room_id, name, nick, thread_parent, custom_avatar, last_bridged_pin_timestamp, speedbump_id, speedbump_checked, speedbump_webhook_id, guild_id FROM channel_room;
-- 6
DROP TABLE channel_room;
-- 7
ALTER TABLE new_channel_room RENAME TO channel_room;
-- *** message_channel ***
-- 4
CREATE TABLE "new_message_channel" (
"message_id" TEXT NOT NULL,
"channel_id" TEXT NOT NULL,
PRIMARY KEY("message_id"),
FOREIGN KEY("channel_id") REFERENCES "channel_room"("channel_id") ON DELETE CASCADE
) WITHOUT ROWID;
-- 5
-- don't copy any orphaned messages
INSERT INTO new_message_channel (message_id, channel_id) SELECT message_id, channel_id FROM message_channel WHERE channel_id IN (SELECT channel_id FROM channel_room);
-- 6
DROP TABLE message_channel;
-- 7
ALTER TABLE new_message_channel RENAME TO message_channel;
-- *** event_message ***
-- clean up any orphaned events
DELETE FROM event_message WHERE message_id NOT IN (SELECT message_id FROM message_channel);
-- 4
CREATE TABLE "new_event_message" (
"event_id" TEXT NOT NULL,
"event_type" TEXT,
"event_subtype" TEXT,
"message_id" TEXT NOT NULL,
"part" INTEGER NOT NULL,
"reaction_part" INTEGER NOT NULL,
"source" INTEGER NOT NULL,
PRIMARY KEY("message_id","event_id"),
FOREIGN KEY("message_id") REFERENCES "message_channel"("message_id") ON DELETE CASCADE
) WITHOUT ROWID;
-- 5
INSERT INTO new_event_message (event_id, event_type, event_subtype, message_id, part, reaction_part, source) SELECT event_id, event_type, event_subtype, message_id, part, reaction_part, source FROM event_message;
-- 6
DROP TABLE event_message;
-- 7
ALTER TABLE new_event_message RENAME TO event_message;
-- *** guild_space ***
-- 4
CREATE TABLE "new_guild_space" (
"guild_id" TEXT NOT NULL,
"space_id" TEXT NOT NULL,
"privacy_level" INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY("guild_id"),
FOREIGN KEY("guild_id") REFERENCES "guild_active"("guild_id") ON DELETE CASCADE
) WITHOUT ROWID;
-- 5
INSERT INTO new_guild_space (guild_id, space_id, privacy_level) SELECT guild_id, space_id, privacy_level FROM guild_space;
-- 6
DROP TABLE guild_space;
-- 7
ALTER TABLE new_guild_space RENAME TO guild_space;
-- *** reaction ***
-- 4
CREATE TABLE "new_reaction" (
"hashed_event_id" INTEGER NOT NULL,
"message_id" TEXT NOT NULL,
"encoded_emoji" TEXT NOT NULL,
PRIMARY KEY("hashed_event_id"),
FOREIGN KEY("message_id") REFERENCES "message_channel"("message_id") ON DELETE CASCADE
) WITHOUT ROWID;
-- 5
INSERT INTO new_reaction (hashed_event_id, message_id, encoded_emoji) SELECT hashed_event_id, message_id, encoded_emoji FROM reaction WHERE message_id IN (SELECT message_id FROM message_channel);
-- 6
DROP TABLE reaction;
-- 7
ALTER TABLE new_reaction RENAME TO reaction;
-- *** webhook ***
-- 4
-- using RESTRICT instead of CASCADE as a reminder that the webhooks also need to be deleted using the Discord API, it can't just be entirely automatic
CREATE TABLE "new_webhook" (
"channel_id" TEXT NOT NULL,
"webhook_id" TEXT NOT NULL,
"webhook_token" TEXT NOT NULL,
PRIMARY KEY("channel_id"),
FOREIGN KEY("channel_id") REFERENCES "channel_room"("channel_id") ON DELETE RESTRICT
) WITHOUT ROWID;
-- 5
INSERT INTO new_webhook (channel_id, webhook_id, webhook_token) SELECT channel_id, webhook_id, webhook_token FROM webhook WHERE channel_id IN (SELECT channel_id FROM channel_room);
-- 6
DROP TABLE webhook;
-- 7
ALTER TABLE new_webhook RENAME TO webhook;
-- *** sim ***
-- 4
-- while we're at it, rebuild this table to give it WITHOUT ROWID, remove UNIQUE, and replace the localpart column with username. no foreign keys needed
CREATE TABLE "new_sim" (
"user_id" TEXT NOT NULL,
"username" TEXT NOT NULL,
"sim_name" TEXT NOT NULL,
"mxid" TEXT NOT NULL,
PRIMARY KEY("user_id")
) WITHOUT ROWID;
-- 5
INSERT INTO new_sim (user_id, username, sim_name, mxid) SELECT user_id, sim_name, sim_name, mxid FROM sim;
-- 6
DROP TABLE sim;
-- 7
ALTER TABLE new_sim RENAME TO sim;
-- *** end ***
-- 10
PRAGMA foreign_key_check;
-- 11
COMMIT;

View file

@ -0,0 +1,225 @@
-- https://www.sqlite.org/lang_analyze.html
BEGIN TRANSACTION;
ANALYZE sqlite_schema;
DELETE FROM "sqlite_stat1";
INSERT INTO "sqlite_stat1" ("tbl","idx","stat") VALUES ('sim','sim','625 1'),
('reaction','reaction','3242 1'),
('channel_room','channel_room','389 1'),
('channel_room','sqlite_autoindex_channel_room_1','389 1'),
('media_proxy','media_proxy','5068 1'),
('sim_proxy','sim_proxy','36 1'),
('webhook','webhook','155 1'),
('member_cache','member_cache','784 3 1'),
('member_power','member_power','1 1 1'),
('file','file','21862 1'),
('message_channel','message_channel','366884 1'),
('lottie','lottie','19 1'),
('event_message','event_message','382920 1 1'),
('migration',NULL,'1'),
('sim_member','sim_member','2871 7 1'),
('guild_space','guild_space','32 1'),
('guild_active','guild_active','34 1'),
('emoji','emoji','2563 1'),
('auto_emoji','auto_emoji','3 1');
DELETE FROM "sqlite_stat4";
INSERT INTO "sqlite_stat4" ("tbl","idx","neq","nlt","ndlt","sample") VALUES ('sim','sim','1','69','69',X'0231313137363631373038303932333039353039'),
('sim','sim','1','139','139',X'0231313530383936363934333439373931323332'),
('sim','sim','1','209','209',X'0231323231383737363334373737323139303732'),
('sim','sim','1','279','279',X'0231333039313431353735353334313136383636'),
('sim','sim','1','349','349',X'0231333935343433383235363034313635363434'),
('sim','sim','1','419','419',X'0231353335363239373830383338353134373030'),
('sim','sim','1','489','489',X'0231363930333339333730353930363636383034'),
('sim','sim','1','559','559',X'0231383535353736303637393137323137383133'),
('reaction','reaction','1','360','360',X'020699d5faceefb5fb4f'),
('reaction','reaction','1','721','721',X'0206b61095e98b6b2fb1'),
('reaction','reaction','1','1082','1082',X'0206d1dcb418603a5eaa'),
('reaction','reaction','1','1443','1443',X'0206ef9fc42b9df746ad'),
('reaction','reaction','1','1804','1804',X'02060f38c1f98f130605'),
('reaction','reaction','1','2165','2165',X'02062b53df6dab7b1067'),
('reaction','reaction','1','2526','2526',X'020645dd7e7f60c4aac7'),
('reaction','reaction','1','2887','2887',X'0206658d2fe735805979'),
('channel_room','channel_room','1','43','43',X'023331313434393131333330393139333231363330'),
('channel_room','channel_room','1','87','87',X'023331313835343033343830303934303335393738'),
('channel_room','channel_room','1','131','131',X'023331323139353036353836343139303638393839'),
('channel_room','channel_room','1','175','175',X'023331323336353538333034323331303334393630'),
('channel_room','channel_room','1','219','219',X'023331323933373932323135333930323234343235'),
('channel_room','channel_room','1','263','263',X'023331333333323139363936393333323038303937'),
('channel_room','channel_room','1','307','307',X'0231343835363635393733363433333738363938'),
('channel_room','channel_room','1','351','351',X'0231373039303432313039353632323234363731'),
('channel_room','sqlite_autoindex_channel_room_1','1 1','6 6','6 6',X'034b3321416a6c4c49464e6248646474424a6d4d73503a636164656e63652e6d6f6531313531333434383735363139343833373233'),
('channel_room','sqlite_autoindex_channel_room_1','1 1','34 34','34 34',X'034b3321474b4a63424a6b527a47634e4855686c50613a636164656e63652e6d6f6531303237393433323532323237323630343637'),
('channel_room','sqlite_autoindex_channel_room_1','1 1','43 43','43 43',X'034b3121484b50534d62736d694673506d6268414f513a636164656e63652e6d6f65313931343837343839393433343034353434'),
('channel_room','sqlite_autoindex_channel_room_1','1 1','58 58','58 58',X'034b33214a4479425a685545706874784f6e6f6569513a636164656e63652e6d6f6531323937323836373434353633313236333532'),
('channel_room','sqlite_autoindex_channel_room_1','1 1','87 87','87 87',X'034b33214e544d724e686e715271695755654d494d523a636164656e63652e6d6f6531323235323434353738393939373031353536'),
('channel_room','sqlite_autoindex_channel_room_1','1 1','108 108','108 108',X'034b332151444e44796656674e7657565345656876713a636164656e63652e6d6f6531313432333134303935353535363435343830'),
('channel_room','sqlite_autoindex_channel_room_1','1 1','131 131','131 131',X'034b3121544171536b575752654b43506f584c6a75483a636164656e63652e6d6f65383737303730363531343733363631393532'),
('channel_room','sqlite_autoindex_channel_room_1','1 1','175 175','175 175',X'034b3321594249486864714e697255585941587845563a636164656e63652e6d6f6531323335303831373939353936373639333730'),
('channel_room','sqlite_autoindex_channel_room_1','1 1','177 177','177 177',X'034b3321594b46454e79716667696951686956496b533a636164656e63652e6d6f6531323934363237303431343530333933373034'),
('channel_room','sqlite_autoindex_channel_room_1','1 1','186 186','186 186',X'034b3321596f54644f55766a53765349767266716c653a636164656e63652e6d6f6531323734313936373733383435333430323933'),
('channel_room','sqlite_autoindex_channel_room_1','1 1','202 202','202 202',X'034b3121625877616673695372655647676470535a463a636164656e63652e6d6f65373339303137363739373936343336393932'),
('channel_room','sqlite_autoindex_channel_room_1','1 1','208 208','208 208',X'034b3321634a4b6843764943795377717a47634551423a636164656e63652e6d6f6531323732363632303331323238373331343834'),
('channel_room','sqlite_autoindex_channel_room_1','1 1','219 219','219 219',X'034b3121656455786a56647a6755765844554951434b3a636164656e63652e6d6f65343937313631333530393334353630373738'),
('channel_room','sqlite_autoindex_channel_room_1','1 1','242 242','242 242',X'034b31216a4d746e6e6f51414e4278466a486458494d3a636164656e63652e6d6f65373634353135323932303539323731313939'),
('channel_room','sqlite_autoindex_channel_room_1','1 1','263 263','263 263',X'034b31216c7a776870666a5a6e59797468656a7453483a636164656e63652e6d6f65383838343831373132383438343030343534'),
('channel_room','sqlite_autoindex_channel_room_1','1 1','264 264','264 264',X'034b33216d454c5846716a426958726d7558796943723a636164656e63652e6d6f6531313936393134373631303430393234373432'),
('channel_room','sqlite_autoindex_channel_room_1','1 1','268 268','268 268',X'034b33216d557765577571546761574a767769576a653a636164656e63652e6d6f6531323936373131393236333032333830303834'),
('channel_room','sqlite_autoindex_channel_room_1','1 1','291 291','291 291',X'034b3321704761494e45534643587a634e42497a724e3a636164656e63652e6d6f6531303237343531333333333533313532353232'),
('channel_room','sqlite_autoindex_channel_room_1','1 1','306 306','306 306',X'034b3321717646656248564f4b6876454e54494563763a636164656e63652e6d6f6531323737373238383139323232303230313436'),
('channel_room','sqlite_autoindex_channel_room_1','1 1','307 307','307 307',X'034b332171767370666d716f476449634a66794c506c3a636164656e63652e6d6f6531323936393138333638393539343633343735'),
('channel_room','sqlite_autoindex_channel_room_1','1 1','351 351','351 351',X'034b3321774e7a7741724a47796f4c5168426f544e4b3a636164656e63652e6d6f6531303238303436373930333435333739383930'),
('channel_room','sqlite_autoindex_channel_room_1','1 1','368 368','368 368',X'034b3321794e6d504c7765654a69756570725a677a733a636164656e63652e6d6f6531323531393631373233373731313632363234'),
('channel_room','sqlite_autoindex_channel_room_1','1 1','376 376','376 376',X'034b3121796a4879795772466f704c66646878564e423a636164656e63652e6d6f65333336313537353037303734353233313336'),
('channel_room','sqlite_autoindex_channel_room_1','1 1','379 379','379 379',X'034b31217a4c4f6b62766b44587551465948594555673a636164656e63652e6d6f65393933383838313433373030393330363331'),
('media_proxy','media_proxy','1','563','563',X'02069e6054680b610946'),
('media_proxy','media_proxy','1','1127','1127',X'0206bb489b717c9320e4'),
('media_proxy','media_proxy','1','1691','1691',X'0206d75f602775b7a27c'),
('media_proxy','media_proxy','1','2255','2255',X'0206f2c705ddca4e2b14'),
('media_proxy','media_proxy','1','2819','2819',X'02061060db7a5151967b'),
('media_proxy','media_proxy','1','3383','3383',X'02062cc47366f7550d22'),
('media_proxy','media_proxy','1','3947','3947',X'020647d275ec0d781fc7'),
('media_proxy','media_proxy','1','4511','4511',X'02066402024a7ea38249'),
('sim_proxy','sim_proxy','1','4','4',X'025531316564343731342d636635652d346333372d393331382d376136353266383732636634'),
('sim_proxy','sim_proxy','1','9','9',X'025533346636333932642d323263372d346337382d393063372d326536323734313535613266'),
('sim_proxy','sim_proxy','1','14','14',X'025535396662363131392d626133392d346565382d393738612d386432376366303631393633'),
('sim_proxy','sim_proxy','1','19','19',X'025539373066366536332d646234632d346531342d383063362d336639343938643961363665'),
('sim_proxy','sim_proxy','1','24','24',X'025561636231613335642d313336662d343362332d626365622d326566646634616265306436'),
('sim_proxy','sim_proxy','1','29','29',X'025563316635623735392d336136342d343633342d623634632d643461656436316539656632'),
('sim_proxy','sim_proxy','1','34','34',X'025566323230373135632d633436332d343532622d626233612d373662646662306365353537'),
('webhook','webhook','1','17','17',X'023331313532383834313435343038373230393936'),
('webhook','webhook','1','35','35',X'023331313939303936333434333830343631313138'),
('webhook','webhook','1','53','53',X'023331323331383036353337373032353736313938'),
('webhook','webhook','1','71','71',X'023331323933373836383939343238383036363536'),
('webhook','webhook','1','89','89',X'023331333132363031353130363535353537373132'),
('webhook','webhook','1','107','107',X'0231323937323734313733303636333133373339'),
('webhook','webhook','1','125','125',X'0231353239313736313536333938363832313137'),
('webhook','webhook','1','143','143',X'0231363837303238373334333232313437333434'),
('member_cache','member_cache','4 1','73 74','48 74',X'034b3921496f4866536e67625a6762747061747a494e3a636164656e63652e6d6f65406875636b6c65746f6e3a636164656e63652e6d6f65'),
('member_cache','member_cache','2 1','86 87','57 87',X'034b2d214b5169714663546e764f6f4f424475746a7a3a636164656e63652e6d6f6540726e6c3a636164656e63652e6d6f65'),
('member_cache','member_cache','4 1','101 104','68 104',X'034b43214e446249714e704a795076664b526e4e63723a636164656e63652e6d6f6540776f756e6465645f77617272696f723a6d61747269782e6f7267'),
('member_cache','member_cache','4 1','110 113','73 113',X'034b3b214f485844457370624d485348716c4445614f3a636164656e63652e6d6f6540717561647261646963616c3a6d61747269782e6f7267'),
('member_cache','member_cache','5 1','171 175','111 175',X'034b3b215450616f6a5454444446444847776c7276743a636164656e63652e6d6f6540766962656973766572796f3a6d61747269782e6f7267'),
('member_cache','member_cache','39 1','180 208','116 208',X'034b4d2154716c79516d69667847556767456d64424e3a636164656e63652e6d6f6540726f626c6b796f6772653a6372616674696e67636f6d72616465732e6e6574'),
('member_cache','member_cache','4 1','231 231','126 231',X'034b3b2156624f77675559777146614e4c5345644e413a636164656e63652e6d6f654061666c6f7765723a73796e646963617465642e676179'),
('member_cache','member_cache','9 1','262 263','141 263',X'034b3b21594b46454e79716667696951686956496b533a636164656e63652e6d6f654062656e6d61633a636861742e62656e6d61632e78797a'),
('member_cache','member_cache','3 1','283 283','149 283',X'034b35215a615a4d78456f52724d6d4e49554d79446c3a636164656e63652e6d6f6540636164656e63653a636164656e63652e6d6f65'),
('member_cache','member_cache','88 1','307 351','166 351',X'034b3b2163427874565278446c5a765356684a58564b3a636164656e63652e6d6f65406a61736b617274683a736c656570696e672e746f776e'),
('member_cache','member_cache','11 1','408 415','177 415',X'034b5121654856655270706e6c6f57587177704a6e553a636164656e63652e6d6f65406a61636b736f6e6368656e3636363a6a61636b736f6e6368656e3636362e636f6d'),
('member_cache','member_cache','7 1','423 424','181 424',X'034b4b2165724f7079584e465a486a48724568784e583a636164656e63652e6d6f6540616d796973636f6f6c7a3a6d61747269782e6174697573616d792e636f6d'),
('member_cache','member_cache','96 1','436 439','187 439',X'034b4b21676865544b5a7451666c444e7070684c49673a636164656e63652e6d6f6540616c65783a73706163652e67616d65727374617665726e2e6f6e6c696e65'),
('member_cache','member_cache','96 1','436 527','187 527',X'034b3121676865544b5a7451666c444e7070684c49673a636164656e63652e6d6f654078796c6f626f6c3a616d6265722e74656c'),
('member_cache','member_cache','10 1','546 555','197 555',X'0351312169537958674e7851634575586f587073536e3a707573737468656361742e6f726740797562697175653a6e6f70652e63686174'),
('member_cache','member_cache','13 1','594 601','224 601',X'034b2b216c7570486a715444537a774f744d59476d493a636164656e63652e6d6f6540656c6c69753a68617368692e7265'),
('member_cache','member_cache','2 1','614 615','229 615',X'034b2f216d584978494644676c4861734e53427371773a636164656e63652e6d6f654077696e673a666561746865722e6f6e6c'),
('member_cache','member_cache','4 1','616 619','230 619',X'034b2f216d616767455367755a427147425a74536e723a636164656e63652e6d6f654077696e673a666561746865722e6f6e6c'),
('member_cache','member_cache','4 1','659 660','259 660',X'034b332172454f73706e5971644f414c4149466e69563a636164656e63652e6d6f6540656c797369613a636164656e63652e6d6f65'),
('member_cache','member_cache','4 1','699 701','284 701',X'034b3521766e717a56767678534a586c5a504f5276533a636164656e63652e6d6f6540636164656e63653a636164656e63652e6d6f65'),
('member_cache','member_cache','1 1','703 703','285 703',X'034b3521767165714c474851616842464a56566779483a636164656e63652e6d6f654063696465723a6361746769726c2e636c6f7564'),
('member_cache','member_cache','4 1','705 705','287 705',X'034b35217750454472596b77497a6f744e66706e57503a636164656e63652e6d6f6540636164656e63653a636164656e63652e6d6f65'),
('member_cache','member_cache','35 1','709 709','288 709',X'034b2d2177574f667376757356486f4e4e567242585a3a636164656e63652e6d6f654061613a6361747669626572732e6d65'),
('member_cache','member_cache','14 1','747 749','291 749',X'034b3721776c534544496a44676c486d42474b7254703a636164656e63652e6d6f654062616461746e616d65733a62616461742e646576'),
('member_power','member_power','1 1','0 0','0 0',X'03350f40636164656e63653a636164656e63652e6d6f652a'),
('file','file','1','2429','2429',X'03815f68747470733a2f2f63646e2e646973636f72646170702e636f6d2f6174746163686d656e74732f313039393033313838373530303033343038382f313333313336303134333238333036303833372f50584c5f32303235303132315f3230323934323137372e6a7067'),
('file','file','1','4859','4859',X'03817568747470733a2f2f63646e2e646973636f72646170702e636f6d2f6174746163686d656e74732f313134353832313533383832323637323436362f313330323232393131303834373936373331352f53637265656e73686f745f32303234313130325f3034313332365f5265646469742e6a7067'),
('file','file','1','7289','7289',X'03815968747470733a2f2f63646e2e646973636f72646170702e636f6d2f6174746163686d656e74732f313231393439383932363436363636323433302f313239373634363930353038353636313234362f494d475f32303234313032305f3135323230302e6a7067'),
('file','file','1','9719','9719',X'03814168747470733a2f2f63646e2e646973636f72646170702e636f6d2f6174746163686d656e74732f3135393136353731343139343735393638302f313236383537333933363531343337313635392f494d475f353433362e6a7067'),
('file','file','1','12149','12149',X'03813b68747470733a2f2f63646e2e646973636f72646170702e636f6d2f6174746163686d656e74732f3236363736373539303634313233383032372f313237323430333931313939383730313630392f696d6167652e706e67'),
('file','file','1','14579','14579',X'03816b68747470733a2f2f63646e2e646973636f72646170702e636f6d2f6174746163686d656e74732f3539383730363933323736303434343936392f313237373532343532343330383632373437372f45585445524e414c5f454449545f323032345f4d5f64726166745f322e646f6378'),
('file','file','1','17009','17009',X'03815768747470733a2f2f63646e2e646973636f72646170702e636f6d2f6174746163686d656e74732f3635353231363137333639363238363734362f313333333630333132383634313036303938342f323032352d30312d32375f31372e30312e31352e706e67'),
('file','file','1','19439','19439',X'027f68747470733a2f2f63646e2e646973636f72646170702e636f6d2f656d6f6a69732f313230323936303730343936313931323836322e706e67'),
('message_channel','message_channel','1','40764','40764',X'023331313630333434353733303030383232383735'),
('message_channel','message_channel','1','81529','81529',X'023331313830323437393130343238393837343333'),
('message_channel','message_channel','1','122294','122294',X'023331313938303533383732383337363131363230'),
('message_channel','message_channel','1','163059','163059',X'023331323237373739373330333839303738303537'),
('message_channel','message_channel','1','203824','203824',X'023331323437303438333039303031313538373137'),
('message_channel','message_channel','1','244589','244589',X'023331323635363939353734333034303830303034'),
('message_channel','message_channel','1','285354','285354',X'023331323835343637363238323434313732383234'),
('message_channel','message_channel','1','326119','326119',X'023331333038373932333935313031333736353732'),
('lottie','lottie','1','2','2',X'0231373439303534363630373639323138363331'),
('lottie','lottie','1','5','5',X'0231373534313037353339323030363731373635'),
('lottie','lottie','1','8','8',X'0231373936313430363338303933343433303932'),
('lottie','lottie','1','11','11',X'0231373936313431373032363935343835353030'),
('lottie','lottie','1','14','14',X'0231383136303837373932323931323832393434'),
('lottie','lottie','1','17','17',X'0231383233393736313032393736323930383636'),
('event_message','event_message','11 1','14788 14796','14356 14796',X'03336531313532303033373639343137303839303434246d44714a474b3530424a715170394273684e534d64365f3768494354776e70362d793130786d6669766563'),
('event_message','event_message','11 1','33806 33815','32914 33815',X'033365313135373630333638373035373836363736322459526f7139484b376e55677a397668796f6e3053424a49497978497a5750734e4f6e39756f765644664d45'),
('event_message','event_message','1 1','42546 42546','41335 42546',X'03336531313630363236383737353835363938393237245033436e4f6d6a35462d6939454e4e79586b70796f5679306b74324a654464764276326e746d33566a4355'),
('event_message','event_message','2 1','85093 85093','81999 85093',X'0333653131383039393939363531323930343831303424496748666562784533746e623047412d534f7176594a354e4e55385735706c68523159676854636a554734'),
('event_message','event_message','11 1','116157 116165','111525 116165',X'03336531313933363837333730363137333237363536245a32305734766c737079566e387a6e2d526a6f64694a51745f5a7851644f5f33744e415169453755356477'),
('event_message','event_message','1 1','127640 127640','122328 127640',X'0333653132303132353637313733373633303332323624702d626f415672476a4b45327a7158664f3738387a5a597a376a42624648717431334d386464705467476f'),
('event_message','event_message','16 1','140270 140281','134379 140281',X'03336531323039333735363534373138343830343235246c374a4f7543526b7756306d627a69356e5843496b4538416a6951374f67473455456c2d7053445a516649'),
('event_message','event_message','11 1','162065 162071','154933 162071',X'0333653132323434383135393033313937373537373424674c77513179796e4b6d5859496b5a597a4a55627a66557a55552d714c4b5f524f454e4250325f6e44766b'),
('event_message','event_message','1 1','170187 170187','162530 170187',X'0333653132323937373533363839343532373038333424656c6c76416a544a5847627936767249767470677a555572787231716f5a75536e50525f474b4e35455945'),
('event_message','event_message','11 1','178736 178736','170762 178736',X'03336531323333353238303533323338333337353537242d39304668552d36455373594b6435484d7237666d6a414a5f6a576149616e356c4776384e655436564959'),
('event_message','event_message','10 1','180317 180325','172228 180325',X'03336531323334363237303030393333383130323637246a4679416449665a4f54432d2d735971715472473735374c7a50504f34386439657963485477686d797751'),
('event_message','event_message','1 1','212734 212734','203278 212734',X'03336531323438303131373930303633373637363734246557493950456f4d576b614b4d33416a7030782d6d455f6c4b4a6c495f6d6d44686f6379464b5170534f59'),
('event_message','event_message','11 1','228100 228103','217856 228103',X'033365313235333734353736363733373132313336322447326a7a746e5977716a3676304a7a4168576e30725950596470667a5f496f76426771574a4876434c516b'),
('event_message','event_message','11 1','240172 240181','229123 240181',X'033365313235393239383538323233303630313735382472635679454c5a76647453547a37366f3669434a4f614a316a756f683835535778494d546c5072517a676f'),
('event_message','event_message','10 1','240259 240264','229132 240264',X'0333653132353933303030343035333535373235323024535849376a465f696e71424d714c4f564b347659746852644b31724747502d435a5f355a354f6b774e3751'),
('event_message','event_message','2 1','255280 255281','243230 255281',X'03336531323636373837343338353137343234313738246e6e4e576f526a54495757723441463770625454746b7a73784c4d336b312d6d6645665031496b43586e59'),
('event_message','event_message','1 1','297828 297828','283436 297828',X'0333653132383637303937353334363834323032313024544342465970356f39767a2d4f6b2d33654f4e433772426d354966615934476b48536e58445257474f4767'),
('event_message','event_message','1 1','340375 340375','323489 340375',X'033365313330393336363936343530353934303030392431664536386d2d50546e786d5474345458584c35754847594139353779396c76582d50797150496d395f30'),
('event_message','event_message','11 1','343791 343799','326730 343799',X'03336531333131353731333337393331363537323737246731767241315269725951592d3933304f6973587142372d6961686e34684e492d6462374952714176616b'),
('event_message','event_message','10 1','363207 363216','344868 363216',X'0333653133323434393732323633393438393834393524784f78636e4749364269545941734d7a4f7557316f526e68356b675378544e30466442386a3037695f4f67'),
('event_message','event_message','10 1','363219 363219','344871 363219',X'033365313332343439373532363733323039393732352432586f7938567937643843375a47767472657966326b4c4f4159397546386b4755364c3642435f446b6d77'),
('event_message','event_message','10 1','363340 363343','344918 363343',X'03336531333234353037313732333634373530393830244f46645731396649534176706d34646c36367a2d5543337236432d436354506e34752d63444f7036733345'),
('event_message','event_message','11 1','369452 369456','350712 369456',X'0333653133323736353831313733343439323336383024574d774330644574417277375f4562554a534465546532577174506d3747584347774570646c4f79326d30'),
('event_message','event_message','10 1','372353 372356','353425 372356',X'0333653133323933313737353039313234353035373224366259364d313472667163486d5854476349716d4a4366467471796839794472375a6a487463715a6d4f34'),
('sim_member','sim_member','225 1','0 12','0 12',X'034b4721414956694e775a64636b4652764c4f4567433a636164656e63652e6d6f65405f6f6f79655f616b6972615f6e6965723a636164656e63652e6d6f65'),
('sim_member','sim_member','2 1','319 319','14 319',X'034b43214456706f6e54524d56456570486378744c423a636164656e63652e6d6f65405f6f6f79655f656e746f6c6f6d613a636164656e63652e6d6f65'),
('sim_member','sim_member','68 1','391 440','26 440',X'034b4921457a54624a496c496d45534f746b4e644e4a3a636164656e63652e6d6f65405f6f6f79655f73617475726461797465643a636164656e63652e6d6f65'),
('sim_member','sim_member','8 1','638 639','59 639',X'034b3f21497a4f675169446e757346516977796d614c3a636164656e63652e6d6f65405f6f6f79655f636f6f6b69653a636164656e63652e6d6f65'),
('sim_member','sim_member','31 1','743 771','86 771',X'034b49214d5071594e414a62576b72474f544a7461703a636164656e63652e6d6f65405f6f6f79655f746865666f6f6c323239343a636164656e63652e6d6f65'),
('sim_member','sim_member','26 1','774 787','87 787',X'034b49214d687950614b4250506f496c7365794d6d743a636164656e63652e6d6f65405f6f6f79655f6b79757567727970686f6e3a636164656e63652e6d6f65'),
('sim_member','sim_member','27 1','877 881','104 881',X'034b45215063734371724f466a48476f41424270414c3a636164656e63652e6d6f65405f6f6f79655f62696c6c795f626f623a636164656e63652e6d6f65'),
('sim_member','sim_member','16 1','956 959','117 959',X'034b43215158526f4a777a63506d5047546d454b454d3a636164656e63652e6d6f65405f6f6f79655f626f7472616334723a636164656e63652e6d6f65'),
('sim_member','sim_member','32 1','997 1012','121 1012',X'034b3f215170676c734e587a4c7751594d4c6c734f503a636164656e63652e6d6f65405f6f6f79655f6a75746f6d693a636164656e63652e6d6f65'),
('sim_member','sim_member','7 1','1274 1279','157 1279',X'034b4121554d6f6e68556765644d47585a78466658753a636164656e63652e6d6f65405f6f6f79655f6d696e696d75733a636164656e63652e6d6f65'),
('sim_member','sim_member','27 1','1415 1439','188 1439',X'034b3d21595868717249786d586e47736961796a59783a636164656e63652e6d6f65405f6f6f79655f73746161663a636164656e63652e6d6f65'),
('sim_member','sim_member','16 1','1597 1599','217 1599',X'034b512163466a4479477274466d48796d794c6652453a636164656e63652e6d6f65405f6f6f79655f626f6a61636b5f686f7273656d616e3a636164656e63652e6d6f65'),
('sim_member','sim_member','27 1','1758 1761','248 1761',X'034b3d2168665a74624d656f5355564e424850736a743a636164656e63652e6d6f65405f6f6f79655f617a7572653a636164656e63652e6d6f65'),
('sim_member','sim_member','25 1','1865 1886','270 1886',X'034b3f216b4c52714b4b555158636962494d744f706c3a636164656e63652e6d6f65405f6f6f79655f7361796f72693a636164656e63652e6d6f65'),
('sim_member','sim_member','19 1','1918 1919','276 1919',X'034b3d216b68497350756c465369736d43646c596e493a636164656e63652e6d6f65405f6f6f79655f617a7572653a636164656e63652e6d6f65'),
('sim_member','sim_member','33 1','1986 2015','286 2015',X'034b3d216d5451744d736a534c4f646c576f7265594d3a636164656e63652e6d6f65405f6f6f79655f73746161663a636164656e63652e6d6f65'),
('sim_member','sim_member','37 1','2027 2028','289 2028',X'034b4d216d616767455367755a427147425a74536e723a636164656e63652e6d6f65405f6f6f79655f2e7265616c2e706572736f6e2e3a636164656e63652e6d6f65'),
('sim_member','sim_member','28 1','2117 2130','297 2130',X'034b3f216e4e595a794b6f4e70797859417a50466f733a636164656e63652e6d6f65405f6f6f79655f6a75746f6d693a636164656e63652e6d6f65'),
('sim_member','sim_member','20 1','2230 2239','310 2239',X'034b41217046504c7270594879487a784e4c69594b413a636164656e63652e6d6f65405f6f6f79655f686578676f61743a636164656e63652e6d6f65'),
('sim_member','sim_member','30 1','2381 2398','332 2398',X'034b3b21717a44626c4b6c69444c577a52524f6e465a3a636164656e63652e6d6f65405f6f6f79655f6d6e696b3a636164656e63652e6d6f65'),
('sim_member','sim_member','38 1','2490 2518','344 2518',X'034b3d2173445250714549546e4f4e57474176496b423a636164656e63652e6d6f65405f6f6f79655f727974686d3a636164656e63652e6d6f65'),
('sim_member','sim_member','11 1','2555 2559','358 2559',X'034b4321746751436d526b426e6474516362687150583a636164656e63652e6d6f65405f6f6f79655f6a6f7365707065793a636164656e63652e6d6f65'),
('sim_member','sim_member','47 1','2633 2666','377 2666',X'034b4b217750454472596b77497a6f744e66706e57503a636164656e63652e6d6f65405f6f6f79655f6e61706f6c656f6e333038393a636164656e63652e6d6f65'),
('sim_member','sim_member','52 1','2817 2837','414 2837',X'034b43217a66654e574d744b4f764f48766f727979563a636164656e63652e6d6f65405f6f6f79655f696e736f676e69613a636164656e63652e6d6f65'),
('guild_space','guild_space','1','3','3',X'0231313132373630363639313738323431303234'),
('guild_space','guild_space','1','7','7',X'023331313534383638343234373234343633363837'),
('guild_space','guild_space','1','11','11',X'023331323139303338323637383430393235383138'),
('guild_space','guild_space','1','15','15',X'023331323839353939363232353930383930313335'),
('guild_space','guild_space','1','19','19',X'0231323733383737363437323234393935383431'),
('guild_space','guild_space','1','23','23',X'0231353239313736313536333938363832313135'),
('guild_space','guild_space','1','27','27',X'0231373535303134333534373334313533383138'),
('guild_space','guild_space','1','31','31',X'0231393933383838313432343535323130303834'),
('guild_active','guild_active','1','3','3',X'0231313132373630363639313738323431303234'),
('guild_active','guild_active','1','7','7',X'023331313534383638343234373234343633363837'),
('guild_active','guild_active','1','11','11',X'023331323139303338323637383430393235383138'),
('guild_active','guild_active','1','15','15',X'023331323839353939363232353930383930313335'),
('guild_active','guild_active','1','19','19',X'023331333333323139363936393333323038303934'),
('guild_active','guild_active','1','23','23',X'0231343735353939303338353336373434393630'),
('guild_active','guild_active','1','27','27',X'022f3636313932393535373737343836383438'),
('guild_active','guild_active','1','31','31',X'0231383737303635303431393930353136373637'),
('emoji','emoji','1','284','284',X'023331313132323031303430303637303339333332'),
('emoji','emoji','1','569','569',X'023331323334393032303131393436383630363638'),
('emoji','emoji','1','854','854',X'0231323735313734373438353034313935303732'),
('emoji','emoji','1','1139','1139',X'0231333837343730383630303134393737303235'),
('emoji','emoji','1','1424','1424',X'0231353435363639393734303130383838313932'),
('emoji','emoji','1','1709','1709',X'0231363339313034333030393139393437323634'),
('emoji','emoji','1','1994','1994',X'0231373532363932363230303638373832313231'),
('emoji','emoji','1','2279','2279',X'0231383935343737353331303434363138323430'),
('auto_emoji','auto_emoji','1','0','0',X'02114c31'),
('auto_emoji','auto_emoji','1','1','1',X'02114c32'),
('auto_emoji','auto_emoji','1','2','2',X'020f5f');
ANALYZE sqlite_schema;
COMMIT;

View file

@ -0,0 +1,5 @@
BEGIN TRANSACTION;
ALTER TABLE channel_room ADD COLUMN custom_topic INTEGER DEFAULT 0;
COMMIT;

View file

@ -0,0 +1,13 @@
BEGIN TRANSACTION;
CREATE TABLE "invite" (
"mxid" TEXT NOT NULL,
"room_id" TEXT NOT NULL,
"type" TEXT,
"name" TEXT,
"topic" TEXT,
"avatar" TEXT,
PRIMARY KEY("mxid","room_id")
) WITHOUT ROWID;
COMMIT;

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

@ -10,6 +10,8 @@ export type Models = {
speedbump_id: string | null
speedbump_webhook_id: string | null
speedbump_checked: number | null
guild_id: string | null
custom_topic: number
}
event_message: {
@ -38,6 +40,14 @@ export type Models = {
autocreate: 0 | 1
}
invite: {
mxid: string
room_id: string
type: string | null
name: string | null
avatar: string | null
}
lottie: {
sticker_id: string
mxc_url: string

View file

@ -64,7 +64,9 @@ discord.snow.interaction.bulkOverwriteApplicationCommands(id, [{
}]
}
]
}])
}]).catch(e => {
console.error(e)
})
async function dispatchInteraction(interaction) {
const interactionId = interaction.data.custom_id || interaction.data.name

View file

@ -20,7 +20,7 @@ async function addReaction(event) {
if (!messageID) return // Nothing can be done if the parent message was never bridged.
const key = event.content["m.relates_to"].key
const discordPreferredEncoding = emoji.encodeEmoji(key, event.content.shortcode)
const discordPreferredEncoding = await emoji.encodeEmoji(key, event.content.shortcode)
if (!discordPreferredEncoding) return
await discord.snow.channel.createReaction(channelID, messageID, discordPreferredEncoding) // acting as the discord bot itself

View file

@ -13,7 +13,6 @@ const utils = sync.require("../converters/utils")
*/
async function deleteMessage(event) {
const rows = from("event_message").join("message_channel", "message_id").select("channel_id", "message_id").where({event_id: event.redacts}).all()
db.prepare("DELETE FROM event_message WHERE event_id = ?").run(event.redacts)
for (const row of rows) {
db.prepare("DELETE FROM message_channel WHERE message_id = ?").run(row.message_id)
await discord.snow.channel.deleteMessage(row.channel_id, row.message_id, event.content.reason)

View file

@ -102,14 +102,13 @@ async function sendEvent(event) {
for (const id of messagesToDelete) {
db.prepare("DELETE FROM message_channel WHERE message_id = ?").run(id)
db.prepare("DELETE FROM event_message WHERE message_id = ?").run(id)
await channelWebhook.deleteMessageWithWebhook(channelID, id, threadID)
}
for (const message of messagesToSend) {
const reactionPart = messagesToEdit.length === 0 && message === messagesToSend[messagesToSend.length - 1] ? 0 : 1
const messageResponse = await channelWebhook.sendMessageWithWebhook(channelID, message, threadID)
db.prepare("REPLACE INTO message_channel (message_id, channel_id) VALUES (?, ?)").run(messageResponse.id, threadID || channelID)
db.prepare("INSERT INTO message_channel (message_id, channel_id) VALUES (?, ?)").run(messageResponse.id, threadID || channelID)
db.prepare("INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part, reaction_part, source) VALUES (?, ?, ?, ?, ?, ?, 0)").run(event.event_id, event.type, event.content["msgtype"] || null, messageResponse.id, eventPart, reactionPart) // source 0 = matrix
eventPart = 1

View file

@ -1,19 +1,19 @@
// @ts-check
const assert = require("assert").strict
const Ty = require("../../types")
const fsp = require("fs").promises
const {join} = require("path")
const emojisp = fsp.readFile(join(__dirname, "emojis.txt"), "utf8").then(content => content.split("\n"))
const passthrough = require("../../passthrough")
const {sync, select} = passthrough
const {select} = passthrough
/**
* @param {string} input
* @param {string | null | undefined} shortcode
* @returns {string?}
*/
function encodeEmoji(input, shortcode) {
let discordPreferredEncoding
if (input.startsWith("mxc://")) {
function encodeCustomEmoji(input, shortcode) {
// Custom emoji
let row = select("emoji", ["emoji_id", "name"], {mxc_url: input}).get()
if (!row && shortcode) {
@ -22,37 +22,77 @@ function encodeEmoji(input, shortcode) {
row = select("emoji", ["emoji_id", "name"], {name: name}).get()
}
if (!row) {
// We don't have this emoji and there's no realistic way to just-in-time upload a new emoji somewhere.
// Sucks!
// We don't have this emoji and there's no realistic way to just-in-time upload a new emoji somewhere. Sucks!
return null
}
// Cool, we got an exact or a candidate emoji.
discordPreferredEncoding = encodeURIComponent(`${row.name}:${row.emoji_id}`)
} else {
return encodeURIComponent(`${row.name}:${row.emoji_id}`)
}
/**
* @param {string} input
* @returns {Promise<string?>} URL encoded!
*/
async function encodeDefaultEmoji(input) {
// Default emoji
// https://github.com/discord/discord-api-docs/issues/2723#issuecomment-807022205 ????????????
// Shortcut: If there are ASCII letters then it's not an emoji, it's a freeform Matrix text reaction.
// (Regional indicator letters are not ASCII. ASCII digits might be part of an emoji.)
if (input.match(/[A-Za-z]/)) return null
// Check against the dataset
const emojis = await emojisp
const encoded = encodeURIComponent(input)
const encodedTrimmed = encoded.replace(/%EF%B8%8F/g, "")
const forceTrimmedList = [
"%F0%9F%91%8D", // 👍
"%F0%9F%91%8E", // 👎️
"%E2%AD%90", // ⭐
"%F0%9F%90%88", // 🐈
"%E2%9D%93", // ❓
"%F0%9F%8F%86", // 🏆️
"%F0%9F%93%9A", // 📚️
"%F0%9F%90%9F", // 🐟️
]
// Best case scenario: they reacted with an exact replica of a valid emoji.
if (emojis.includes(input)) return encoded
discordPreferredEncoding =
( forceTrimmedList.includes(encodedTrimmed) ? encodedTrimmed
: encodedTrimmed !== encoded && [...input].length === 2 ? encoded
: encodedTrimmed)
console.log("add reaction from matrix:", input, encoded, encodedTrimmed, "chosen:", discordPreferredEncoding)
// Maybe it has some extraneous \ufe0f or \ufe0e (at the end or in the middle), and it'll be valid if they're removed.
const trimmed = input.replace(/\ufe0e|\ufe0f/g, "")
const trimmedEncoded = encodeURIComponent(trimmed)
if (trimmed !== input) {
if (emojis.includes(trimmed)) return trimmedEncoded
}
// Okay, well, maybe it was already missing one and it actually needs an extra \ufe0f, and it'll be valid if that's added.
else {
const appended = input + "\ufe0f"
const appendedEncoded = encodeURIComponent(appended)
if (emojis.includes(appended)) return appendedEncoded
}
// Hmm, so adding or removing that from the end didn't help, but maybe there needs to be one in the middle? We can try some heuristics.
// These heuristics come from executing scripts/emoji-surrogates-statistics.js.
if (trimmedEncoded.length <= 21 && trimmed.match(/^[*#0-9]/)) { // ->19: Keycap digit? 0⃣ 1⃣ 2⃣ 3⃣ 4⃣ 5⃣ 6⃣ 7⃣ 8⃣ 9⃣ *️⃣ #️⃣
const keycap = trimmed[0] + "\ufe0f" + trimmed.slice(1)
if (emojis.includes(keycap)) return encodeURIComponent(keycap)
} else if (trimmedEncoded.length === 27 && trimmed[0] === "⛹") { // ->45: ⛹️‍♀️ ⛹️‍♂️
const balling = trimmed[0] + "\ufe0f" + trimmed.slice(1) + "\ufe0f"
if (emojis.includes(balling)) return encodeURIComponent(balling)
} else if (trimmedEncoded.length === 30) { // ->39: ⛓️‍💥 ❤️‍🩹 ❤️‍🔥 or ->48: 🏳️‍⚧️ 🏌️‍♀️ 🕵️‍♀️ 🏋️‍♀️ and gender variants
const thriving = trimmed[0] + "\ufe0f" + trimmed.slice(1)
if (emojis.includes(thriving)) return encodeURIComponent(thriving)
const powerful = trimmed.slice(0, 2) + "\ufe0f" + trimmed.slice(2) + "\ufe0f"
if (emojis.includes(powerful)) return encodeURIComponent(powerful)
} else if (trimmedEncoded.length === 51 && trimmed[3] === "❤") { // ->60: 👩‍❤️‍👨 👩‍❤️‍👩 👨‍❤️‍👨
const yellowRomance = trimmed.slice(0, 3) + "❤\ufe0f" + trimmed.slice(4)
if (emojis.includes(yellowRomance)) return encodeURIComponent(yellowRomance)
}
// there are a few more longer ones but I got bored
return null
}
/**
* @param {string} input
* @param {string | null | undefined} shortcode
* @returns {Promise<string?>}
*/
async function encodeEmoji(input, shortcode) {
if (input.startsWith("mxc://")) {
return encodeCustomEmoji(input, shortcode)
} else {
return encodeDefaultEmoji(input)
}
return discordPreferredEncoding
}
module.exports.encodeEmoji = encodeEmoji

View file

@ -0,0 +1,52 @@
// @ts-check
const {test} = require("supertape")
const {encodeEmoji} = require("./emoji")
test("emoji: valid", async t => {
t.equal(await encodeEmoji("🦄", null), "%F0%9F%A6%84")
})
test("emoji: freeform text", async t => {
t.equal(await encodeEmoji("ha", null), null)
})
test("emoji: suspicious unicode", async t => {
t.equal(await encodeEmoji("Ⓐ", null), null)
})
test("emoji: needs u+fe0f added", async t => {
t.equal(await encodeEmoji("☺", null), "%E2%98%BA%EF%B8%8F")
})
test("emoji: needs u+fe0f removed", async t => {
t.equal(await encodeEmoji("⭐️", null), "%E2%AD%90")
})
test("emoji: number key needs u+fe0f in the middle", async t => {
t.equal(await encodeEmoji("3⃣", null), "3%EF%B8%8F%E2%83%A3")
})
test("emoji: hash key needs u+fe0f in the middle", async t => {
t.equal(await encodeEmoji("#⃣", null), "%23%EF%B8%8F%E2%83%A3")
})
test("emoji: broken chains needs u+fe0f in the middle", async t => {
t.equal(await encodeEmoji("⛓‍💥", null), "%E2%9B%93%EF%B8%8F%E2%80%8D%F0%9F%92%A5")
})
test("emoji: balling needs u+fe0f in the middle", async t => {
t.equal(await encodeEmoji("⛹‍♀", null), "%E2%9B%B9%EF%B8%8F%E2%80%8D%E2%99%80%EF%B8%8F")
})
test("emoji: trans flag needs u+fe0f in the middle", async t => {
t.equal(await encodeEmoji("🏳‍⚧", null), "%F0%9F%8F%B3%EF%B8%8F%E2%80%8D%E2%9A%A7%EF%B8%8F")
})
test("emoji: spy needs u+fe0f in the middle", async t => {
t.equal(await encodeEmoji("🕵‍♀", null), "%F0%9F%95%B5%EF%B8%8F%E2%80%8D%E2%99%80%EF%B8%8F")
})
test("emoji: couple needs u+fe0f in the middle", async t => {
t.equal(await encodeEmoji("👩‍❤‍👩", null), "%F0%9F%91%A9%E2%80%8D%E2%9D%A4%EF%B8%8F%E2%80%8D%F0%9F%91%A9")
})

File diff suppressed because it is too large Load diff

View file

@ -258,7 +258,18 @@ async function getMemberFromCacheOrHomeserver(roomID, mxid, api) {
const row = select("member_cache", ["displayname", "avatar_url"], {room_id: roomID, mxid}).get()
if (row) return row
return api.getStateEvent(roomID, "m.room.member", mxid).then(event => {
db.prepare("REPLACE INTO member_cache (room_id, mxid, displayname, avatar_url) VALUES (?, ?, ?, ?)").run(roomID, mxid, event?.displayname || null, event?.avatar_url || null)
const room = select("channel_room", "room_id", {room_id: roomID}).get()
if (room) {
// save the member to the cache so we don't have to check with the homeserver next time
// the cache will be kept in sync by the `m.room.member` event listener
const displayname = event?.displayname || null
const avatar_url = event?.avatar_url || null
db.prepare("INSERT INTO member_cache (room_id, mxid, displayname, avatar_url) VALUES (?, ?, ?, ?) ON CONFLICT DO UPDATE SET displayname = ?, avatar_url = ?").run(
roomID, mxid,
displayname, avatar_url,
displayname, avatar_url
)
}
return event
}).catch(() => {
return {displayname: null, avatar_url: null}

View file

@ -559,7 +559,7 @@ test("event2message: lists are bridged correctly", async t => {
"transaction_id": "m1692967313951.441"
},
"event_id": "$l-xQPY5vNJo3SNxU9d8aOWNVD1glMslMyrp4M_JEF70",
"room_id": "!BpMdOUkWWhFxmTrENV:cadence.moe"
"room_id": "!kLRqKKUQXcibIMtOpl:cadence.moe"
}),
{
ensureJoined: [],
@ -662,7 +662,7 @@ test("event2message: code block contents are formatted correctly and not escaped
formatted_body: "<pre><code>input = input.replace(/(&lt;\\/?([^ &gt;]+)[^&gt;]*&gt;)?\\n(&lt;\\/?([^ &gt;]+)[^&gt;]*&gt;)?/g,\n_input_ = input = input.replace(/(&lt;\\/?([^ &gt;]+)[^&gt;]*&gt;)?\\n(&lt;\\/?([^ &gt;]+)[^&gt;]*&gt;)?/g,\n</code></pre>\n<p><code>input = input.replace(/(&lt;\\/?([^ &gt;]+)[^&gt;]*&gt;)?\\n(&lt;\\/?([^ &gt;]+)[^&gt;]*&gt;)?/g,</code></p>\n"
},
event_id: "$pGkWQuGVmrPNByrFELxhzI6MCBgJecr5I2J3z88Gc2s",
room_id: "!BpMdOUkWWhFxmTrENV:cadence.moe"
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe"
}),
{
ensureJoined: [],
@ -692,7 +692,7 @@ test("event2message: code blocks use double backtick as delimiter when necessary
formatted_body: "<code>backtick in ` the middle</code>, <code>backtick at the edge`</code>"
},
event_id: "$pGkWQuGVmrPNByrFELxhzI6MCBgJecr5I2J3z88Gc2s",
room_id: "!BpMdOUkWWhFxmTrENV:cadence.moe"
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe"
}),
{
ensureJoined: [],
@ -722,7 +722,7 @@ test("event2message: inline code is converted to code block if it contains both
formatted_body: "<code>` one two ``</code>"
},
event_id: "$pGkWQuGVmrPNByrFELxhzI6MCBgJecr5I2J3z88Gc2s",
room_id: "!BpMdOUkWWhFxmTrENV:cadence.moe"
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe"
}),
{
ensureJoined: [],
@ -752,7 +752,7 @@ test("event2message: code blocks are uploaded as attachments instead if they con
formatted_body: 'So if you run code like this<pre><code class="language-java">System.out.println("```");</code></pre>it should print a markdown formatted code block'
},
event_id: "$pGkWQuGVmrPNByrFELxhzI6MCBgJecr5I2J3z88Gc2s",
room_id: "!BpMdOUkWWhFxmTrENV:cadence.moe"
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe"
}),
{
ensureJoined: [],
@ -784,7 +784,7 @@ test("event2message: code blocks are uploaded as attachments instead if they con
formatted_body: 'So if you run code like this<pre><code>System.out.println("```");</code></pre>it should print a markdown formatted code block'
},
event_id: "$pGkWQuGVmrPNByrFELxhzI6MCBgJecr5I2J3z88Gc2s",
room_id: "!BpMdOUkWWhFxmTrENV:cadence.moe"
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe"
}),
{
ensureJoined: [],
@ -821,7 +821,7 @@ test("event2message: characters are encoded properly in code blocks", async t =>
+ '\n</code></pre>'
},
event_id: "$pGkWQuGVmrPNByrFELxhzI6MCBgJecr5I2J3z88Gc2s",
room_id: "!BpMdOUkWWhFxmTrENV:cadence.moe"
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe"
}),
{
ensureJoined: [],
@ -902,7 +902,7 @@ test("event2message: lists have appropriate line breaks", async t => {
'm.mentions': {},
msgtype: 'm.text'
},
room_id: '!cBxtVRxDlZvSVhJXVK:cadence.moe',
room_id: '!TqlyQmifxGUggEmdBN:cadence.moe',
sender: '@Milan:tchncs.de',
type: 'm.room.message',
}),
@ -943,7 +943,7 @@ test("event2message: ordered list start attribute works", async t => {
'm.mentions': {},
msgtype: 'm.text'
},
room_id: '!cBxtVRxDlZvSVhJXVK:cadence.moe',
room_id: '!TqlyQmifxGUggEmdBN:cadence.moe',
sender: '@Milan:tchncs.de',
type: 'm.room.message',
}),
@ -1088,7 +1088,7 @@ test("event2message: rich reply to a rich reply to a multi-line message should c
content: {
body: "> <@cadence:cadence.moe> I just checked in a fix that will probably work, can you try reproducing this on the latest `main` branch and see if I fixed it?\n\nwill try later (tomorrow if I don't forgor)",
format: "org.matrix.custom.html",
formatted_body: "<mx-reply><blockquote><a href=\"https://matrix.to/#/!cBxtVRxDlZvSVhJXVK:cadence.moe/$A0Rj559NKOh2VndCZSTJXcvgi42gZWVfVQt73wA2Hn0?via=matrix.org&via=cadence.moe&via=syndicated.gay\">In reply to</a> <a href=\"https://matrix.to/#/@cadence:cadence.moe\">@cadence:cadence.moe</a><br />I just checked in a fix that will probably work, can you try reproducing this on the latest <code>main</code> branch and see if I fixed it?</blockquote></mx-reply>will try later (tomorrow if I don't forgor)",
formatted_body: "<mx-reply><blockquote><a href=\"https://matrix.to/#/!TqlyQmifxGUggEmdBN:cadence.moe/$A0Rj559NKOh2VndCZSTJXcvgi42gZWVfVQt73wA2Hn0?via=matrix.org&via=cadence.moe&via=syndicated.gay\">In reply to</a> <a href=\"https://matrix.to/#/@cadence:cadence.moe\">@cadence:cadence.moe</a><br />I just checked in a fix that will probably work, can you try reproducing this on the latest <code>main</code> branch and see if I fixed it?</blockquote></mx-reply>will try later (tomorrow if I don't forgor)",
"m.relates_to": {
"m.in_reply_to": {
event_id: "$A0Rj559NKOh2VndCZSTJXcvgi42gZWVfVQt73wA2Hn0"
@ -1111,7 +1111,7 @@ test("event2message: rich reply to a rich reply to a multi-line message should c
"msgtype": "m.text",
"body": "> <@solonovamax:matrix.org> multipart messages will be deleted if the message is edited to require less space\n> \n> \n> steps to reproduce:\n> \n> 1. send a message that is longer than 2000 characters (discord character limit)\n> - bot will split message into two messages on discord\n> 2. edit message to be under 2000 characters (discord character limit)\n> - bot will delete one of the messages on discord, and then edit the other one to include the edited content\n> - the bot will *then* delete the message on matrix (presumably) because one of the messages on discord was deleted (by \n\nI just checked in a fix that will probably work, can you try reproducing this on the latest `main` branch and see if I fixed it?",
"format": "org.matrix.custom.html",
"formatted_body": "<mx-reply><blockquote><a href=\"https://matrix.to/#/!cBxtVRxDlZvSVhJXVK:cadence.moe/$u4OD19vd2GETkOyhgFVla92oDKI4ojwBf2-JeVCG7EI?via=cadence.moe&via=matrix.org&via=conduit.rory.gay\">In reply to</a> <a href=\"https://matrix.to/#/@solonovamax:matrix.org\">@solonovamax:matrix.org</a><br /><p>multipart messages will be deleted if the message is edited to require less space</p>\n<p>steps to reproduce:</p>\n<ol>\n<li>send a message that is longer than 2000 characters (discord character limit)</li>\n</ol>\n<ul>\n<li>bot will split message into two messages on discord</li>\n</ul>\n<ol start=\"2\">\n<li>edit message to be under 2000 characters (discord character limit)</li>\n</ol>\n<ul>\n<li>bot will delete one of the messages on discord, and then edit the other one to include the edited content</li>\n<li>the bot will <em>then</em> delete the message on matrix (presumably) because one of the messages on discord was deleted (by</li>\n</ul>\n</blockquote></mx-reply>I just checked in a fix that will probably work, can you try reproducing this on the latest <code>main</code> branch and see if I fixed it?",
"formatted_body": "<mx-reply><blockquote><a href=\"https://matrix.to/#/!TqlyQmifxGUggEmdBN:cadence.moe/$u4OD19vd2GETkOyhgFVla92oDKI4ojwBf2-JeVCG7EI?via=cadence.moe&via=matrix.org&via=conduit.rory.gay\">In reply to</a> <a href=\"https://matrix.to/#/@solonovamax:matrix.org\">@solonovamax:matrix.org</a><br /><p>multipart messages will be deleted if the message is edited to require less space</p>\n<p>steps to reproduce:</p>\n<ol>\n<li>send a message that is longer than 2000 characters (discord character limit)</li>\n</ol>\n<ul>\n<li>bot will split message into two messages on discord</li>\n</ul>\n<ol start=\"2\">\n<li>edit message to be under 2000 characters (discord character limit)</li>\n</ol>\n<ul>\n<li>bot will delete one of the messages on discord, and then edit the other one to include the edited content</li>\n<li>the bot will <em>then</em> delete the message on matrix (presumably) because one of the messages on discord was deleted (by</li>\n</ul>\n</blockquote></mx-reply>I just checked in a fix that will probably work, can you try reproducing this on the latest <code>main</code> branch and see if I fixed it?",
"m.relates_to": {
"m.in_reply_to": {
"event_id": "$u4OD19vd2GETkOyhgFVla92oDKI4ojwBf2-JeVCG7EI"
@ -1123,7 +1123,7 @@ test("event2message: rich reply to a rich reply to a multi-line message should c
"age": 19069564
},
"event_id": "$A0Rj559NKOh2VndCZSTJXcvgi42gZWVfVQt73wA2Hn0",
"room_id": "!cBxtVRxDlZvSVhJXVK:cadence.moe"
"room_id": "!TqlyQmifxGUggEmdBN:cadence.moe"
})
},
snow: {
@ -3485,7 +3485,7 @@ test("event2message: caches the member if the member is not known", async t => {
},
event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU",
origin_server_ts: 1688301929913,
room_id: "!should_be_newly_cached:cadence.moe",
room_id: "!qzDBLKlildpzrrOnFZ:cadence.moe",
sender: "@should_be_newly_cached:cadence.moe",
type: "m.room.message",
unsigned: {
@ -3495,7 +3495,7 @@ test("event2message: caches the member if the member is not known", async t => {
api: {
getStateEvent: async (roomID, type, stateKey) => {
called++
t.equal(roomID, "!should_be_newly_cached:cadence.moe")
t.equal(roomID, "!qzDBLKlildpzrrOnFZ:cadence.moe")
t.equal(type, "m.room.member")
t.equal(stateKey, "@should_be_newly_cached:cadence.moe")
return {
@ -3519,12 +3519,60 @@ test("event2message: caches the member if the member is not known", async t => {
}
)
t.deepEqual(select("member_cache", ["avatar_url", "displayname", "mxid"], {room_id: "!should_be_newly_cached:cadence.moe"}).all(), [
t.deepEqual(select("member_cache", ["avatar_url", "displayname", "mxid"], {room_id: "!qzDBLKlildpzrrOnFZ:cadence.moe"}).all(), [
{avatar_url: "mxc://cadence.moe/this_is_the_avatar", displayname: null, mxid: "@should_be_newly_cached:cadence.moe"}
])
t.equal(called, 1, "getStateEvent should be called once")
})
test("event2message: does not cache the member if the room is not known", async t => {
let called = 0
t.deepEqual(
await eventToMessage({
content: {
body: "testing the member state cache",
msgtype: "m.text"
},
event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU",
origin_server_ts: 1688301929913,
room_id: "!not_real:cadence.moe",
sender: "@should_not_be_cached:cadence.moe",
type: "m.room.message",
unsigned: {
age: 405299
}
}, {}, {
api: {
getStateEvent: async (roomID, type, stateKey) => {
called++
t.equal(roomID, "!not_real:cadence.moe")
t.equal(type, "m.room.member")
t.equal(stateKey, "@should_not_be_cached:cadence.moe")
return {
avatar_url: "mxc://cadence.moe/this_is_the_avatar"
}
}
}
}),
{
ensureJoined: [],
messagesToDelete: [],
messagesToEdit: [],
messagesToSend: [{
username: "should_not_be_cached",
content: "testing the member state cache",
avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/this_is_the_avatar",
allowed_mentions: {
parse: ["users", "roles"]
}
}]
}
)
t.deepEqual(select("member_cache", ["avatar_url", "displayname", "mxid"], {room_id: "!not_real:cadence.moe"}).all(), [])
t.equal(called, 1, "getStateEvent should be called once")
})
test("event2message: skips caching the member if the member does not exist, somehow", async t => {
let called = 0
t.deepEqual(
@ -3580,7 +3628,7 @@ test("event2message: overly long usernames are shifted into the message content"
},
event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU",
origin_server_ts: 1688301929913,
room_id: "!should_be_newly_cached_2:cadence.moe",
room_id: "!cqeGDbPiMFAhLsqqqq:cadence.moe",
sender: "@should_be_newly_cached_2:cadence.moe",
type: "m.room.message",
unsigned: {
@ -3590,7 +3638,7 @@ test("event2message: overly long usernames are shifted into the message content"
api: {
getStateEvent: async (roomID, type, stateKey) => {
called++
t.equal(roomID, "!should_be_newly_cached_2:cadence.moe")
t.equal(roomID, "!cqeGDbPiMFAhLsqqqq:cadence.moe")
t.equal(type, "m.room.member")
t.equal(stateKey, "@should_be_newly_cached_2:cadence.moe")
return {
@ -3613,7 +3661,7 @@ test("event2message: overly long usernames are shifted into the message content"
}]
}
)
t.deepEqual(select("member_cache", ["avatar_url", "displayname", "mxid"], {room_id: "!should_be_newly_cached_2:cadence.moe"}).all(), [
t.deepEqual(select("member_cache", ["avatar_url", "displayname", "mxid"], {room_id: "!cqeGDbPiMFAhLsqqqq:cadence.moe"}).all(), [
{avatar_url: null, displayname: "I am BLACK I am WHITE I am SHORT I am LONG I am EVERYTHING YOU THINK IS IMPORTANT and I DON'T MATTER", mxid: "@should_be_newly_cached_2:cadence.moe"}
])
t.equal(called, 1, "getStateEvent should be called once")
@ -3628,7 +3676,7 @@ test("event2message: overly long usernames are not treated specially when the ms
},
event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU",
origin_server_ts: 1688301929913,
room_id: "!should_be_newly_cached_2:cadence.moe",
room_id: "!cqeGDbPiMFAhLsqqqq:cadence.moe",
sender: "@should_be_newly_cached_2:cadence.moe",
type: "m.room.message",
unsigned: {
@ -4477,7 +4525,7 @@ slow()("event2message: all unknown chess emojis are reuploaded as a sprite sheet
formatted_body: "testing <img data-mx-emoticon height=\"32\" src=\"mxc://cadence.moe/lHfmJpzgoNyNtYHdAmBHxXix\" title=\":chess_good_move:\" alt=\":chess_good_move:\"><img data-mx-emoticon height=\"32\" src=\"mxc://cadence.moe/MtRdXixoKjKKOyHJGWLsWLNU\" title=\":chess_incorrect:\" alt=\":chess_incorrect:\"><img data-mx-emoticon height=\"32\" src=\"mxc://cadence.moe/HXfFuougamkURPPMflTJRxGc\" title=\":chess_blund:\" alt=\":chess_blund:\"><img data-mx-emoticon height=\"32\" src=\"mxc://cadence.moe/ikYKbkhGhMERAuPPbsnQzZiX\" title=\":chess_brilliant_move:\" alt=\":chess_brilliant_move:\"><img data-mx-emoticon height=\"32\" src=\"mxc://cadence.moe/AYPpqXzVJvZdzMQJGjioIQBZ\" title=\":chess_blundest:\" alt=\":chess_blundest:\"><img data-mx-emoticon height=\"32\" src=\"mxc://cadence.moe/UVuzvpVUhqjiueMxYXJiFEAj\" title=\":chess_draw_black:\" alt=\":chess_draw_black:\"><img data-mx-emoticon height=\"32\" src=\"mxc://cadence.moe/lHfmJpzgoNyNtYHdAmBHxXix\" title=\":chess_good_move:\" alt=\":chess_good_move:\"><img data-mx-emoticon height=\"32\" src=\"mxc://cadence.moe/MtRdXixoKjKKOyHJGWLsWLNU\" title=\":chess_incorrect:\" alt=\":chess_incorrect:\"><img data-mx-emoticon height=\"32\" src=\"mxc://cadence.moe/HXfFuougamkURPPMflTJRxGc\" title=\":chess_blund:\" alt=\":chess_blund:\"><img data-mx-emoticon height=\"32\" src=\"mxc://cadence.moe/ikYKbkhGhMERAuPPbsnQzZiX\" title=\":chess_brilliant_move:\" alt=\":chess_brilliant_move:\"><img data-mx-emoticon height=\"32\" src=\"mxc://cadence.moe/AYPpqXzVJvZdzMQJGjioIQBZ\" title=\":chess_blundest:\" alt=\":chess_blundest:\"><img data-mx-emoticon height=\"32\" src=\"mxc://cadence.moe/UVuzvpVUhqjiueMxYXJiFEAj\" title=\":chess_draw_black:\" alt=\":chess_draw_black:\">"
},
event_id: "$Me6iE8C8CZyrDEOYYrXKSYRuuh_25Jj9kZaNrf7LKr4",
room_id: "!maggESguZBqGBZtSnr:cadence.moe"
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe"
}, {}, {mxcDownloader: mockGetAndConvertEmoji})
const testResult = {
content: messages.messagesToSend[0].content,

View file

@ -22,6 +22,8 @@ const matrixCommandHandler = sync.require("../matrix/matrix-command-handler")
const utils = sync.require("./converters/utils")
/** @type {import("../matrix/api")}) */
const api = sync.require("../matrix/api")
/** @type {import("../d2m/actions/create-room")} */
const createRoom = sync.require("../d2m/actions/create-room")
const {reg} = require("../matrix/read-registration")
let lastReportedEvent = 0
@ -164,6 +166,20 @@ async event => {
db.prepare("UPDATE channel_room SET nick = ? WHERE room_id = ?").run(name, event.room_id)
}))
sync.addTemporaryListener(as, "type:m.room.topic", guard("m.room.topic",
/**
* @param {Ty.Event.StateOuter<Ty.Event.M_Room_Topic>} event
*/
async event => {
if (event.state_key !== "") return
if (utils.eventSenderIsFromDiscord(event.sender)) return
const customTopic = +!!event.content.topic
const row = select("channel_room", ["channel_id", "custom_topic"], {room_id: event.room_id}).get()
if (!row) return
if (customTopic !== row.custom_topic) db.prepare("UPDATE channel_room SET custom_topic = ? WHERE channel_id = ?").run(customTopic, row.channel_id)
if (!customTopic) await createRoom.syncRoom(row.channel_id) // if it's cleared we should reset it to whatever's on discord
}))
sync.addTemporaryListener(as, "type:m.room.pinned_events", guard("m.room.pinned_events",
/**
* @param {Ty.Event.StateOuter<Ty.Event.M_Room_PinnedEvents>} event
@ -192,25 +208,69 @@ async event => {
await api.ackEvent(event)
}))
function getFromInviteRoomState(inviteRoomState, nskey, key) {
if (!Array.isArray(inviteRoomState)) return null
for (const event of inviteRoomState) {
if (event.type === nskey && event.state_key === "") {
return event.content[key]
}
}
return null
}
sync.addTemporaryListener(as, "type:m.space.child", guard("m.space.child",
/**
* @param {Ty.Event.StateOuter<Ty.Event.M_Space_Child>} event
*/
async event => {
if (Array.isArray(event.content.via) && event.content.via.length) { // space child is being added
await api.joinRoom(event.state_key).catch(() => {}) // try to join if able, it's okay if it doesn't want, bot will still respond to invites
}
}))
sync.addTemporaryListener(as, "type:m.room.member", guard("m.room.member",
/**
* @param {Ty.Event.StateOuter<Ty.Event.M_Room_Member>} event
*/
async event => {
if (event.state_key[0] !== "@") return
if (event.content.membership === "invite" && event.state_key === `@${reg.sender_localpart}:${reg.ooye.server_name}`) {
// We were invited to a room. We should join, and register the invite details for future reference in web.
const name = getFromInviteRoomState(event.unsigned?.invite_room_state, "m.room.name", "name")
const topic = getFromInviteRoomState(event.unsigned?.invite_room_state, "m.room.topic", "topic")
const avatar = getFromInviteRoomState(event.unsigned?.invite_room_state, "m.room.avatar", "url")
const creationType = getFromInviteRoomState(event.unsigned?.invite_room_state, "m.room.create", "type")
if (!name) return await api.leaveRoomWithReason(event.room_id, "Please only invite me to rooms that have a name/avatar set. Update the room details and reinvite!")
await api.joinRoom(event.room_id)
db.prepare("INSERT OR IGNORE INTO invite (mxid, room_id, type, name, topic, avatar) VALUES (?, ?, ?, ?, ?, ?)").run(event.sender, event.room_id, creationType, name, topic, avatar)
if (avatar) utils.getPublicUrlForMxc(avatar) // make sure it's available in the media_proxy allowed URLs
}
if (utils.eventSenderIsFromDiscord(event.state_key)) return
if (event.content.membership === "leave" || event.content.membership === "ban") {
// Member is gone
db.prepare("DELETE FROM member_cache WHERE room_id = ? and mxid = ?").run(event.room_id, event.state_key)
} else {
// Member is here
db.prepare("INSERT INTO member_cache (room_id, mxid, displayname, avatar_url) VALUES (?, ?, ?, ?) ON CONFLICT DO UPDATE SET displayname = ?, avatar_url = ?")
.run(
event.room_id, event.state_key,
event.content.displayname || null, event.content.avatar_url || null,
event.content.displayname || null, event.content.avatar_url || null
)
return db.prepare("DELETE FROM member_cache WHERE room_id = ? and mxid = ?").run(event.room_id, event.state_key)
}
const exists = select("channel_room", "room_id", {room_id: event.room_id}) ?? select("guild_space", "space_id", {space_id: event.room_id})
if (!exists) return // don't cache members in unbridged rooms
// Member is here
let powerLevel = 0
try {
/** @type {Ty.Event.M_Power_Levels} */
const powerLevelsEvent = await api.getStateEvent(event.room_id, "m.room.power_levels", "")
powerLevel = powerLevelsEvent.users?.[event.state_key] ?? powerLevelsEvent.users_default ?? 0
} catch (e) {}
const displayname = event.content.displayname || null
const avatar_url = event.content.avatar_url
db.prepare("INSERT INTO member_cache (room_id, mxid, displayname, avatar_url, power_level) VALUES (?, ?, ?, ?, ?) ON CONFLICT DO UPDATE SET displayname = ?, avatar_url = ?, power_level = ?").run(
event.room_id, event.state_key,
displayname, avatar_url, powerLevel,
displayname, avatar_url, powerLevel
)
}))
sync.addTemporaryListener(as, "type:m.room.power_levels", guard("m.room.power_levels",

View file

@ -82,6 +82,16 @@ async function leaveRoom(roomID, mxid) {
await mreq.mreq("POST", path(`/client/v3/rooms/${roomID}/leave`, mxid), {})
}
/**
* @param {string} roomID
* @param {string} reason
* @param {string} [mxid]
*/
async function leaveRoomWithReason(roomID, reason, mxid) {
console.log(`[api] leave: ${roomID}: ${mxid}, because ${reason}`)
await mreq.mreq("POST", path(`/client/v3/rooms/${roomID}/leave`, mxid), {reason})
}
/**
* @param {string} roomID
* @param {string} eventID
@ -377,12 +387,34 @@ async function getAlias(alias) {
return root.room_id
}
/**
* @param {string} type namespaced event type, e.g. m.direct
* @param {string} [mxid] you
* @returns the *content* of the account data "event"
*/
async function getAccountData(type, mxid) {
if (!mxid) mxid = `@${reg.sender_localpart}:${reg.ooye.server_name}`
const root = await mreq.mreq("GET", `/client/v3/user/${mxid}/account_data/${type}`)
return root
}
/**
* @param {string} type namespaced event type, e.g. m.direct
* @param {any} content whatever you want
* @param {string} [mxid] you
*/
async function setAccountData(type, content, mxid) {
if (!mxid) mxid = `@${reg.sender_localpart}:${reg.ooye.server_name}`
await mreq.mreq("PUT", `/client/v3/user/${mxid}/account_data/${type}`, content)
}
module.exports.path = path
module.exports.register = register
module.exports.createRoom = createRoom
module.exports.joinRoom = joinRoom
module.exports.inviteToRoom = inviteToRoom
module.exports.leaveRoom = leaveRoom
module.exports.leaveRoomWithReason = leaveRoomWithReason
module.exports.getEvent = getEvent
module.exports.getEventForTimestamp = getEventForTimestamp
module.exports.getAllState = getAllState
@ -406,3 +438,5 @@ module.exports.getMedia = getMedia
module.exports.sendReadReceipt = sendReadReceipt
module.exports.ackEvent = ackEvent
module.exports.getAlias = getAlias
module.exports.getAccountData = getAccountData
module.exports.setAccountData = setAccountData

11
src/types.d.ts vendored
View file

@ -28,6 +28,8 @@ export type AppServiceRegistrationConfig = {
content_length_workaround: boolean
include_user_id_in_mxid: boolean
invite: string[]
discord_origin?: string
discord_cdn_origin?: string
}
old_bridge?: {
as_token: string
@ -241,6 +243,10 @@ export namespace Event {
name?: string
}
export type M_Room_Topic = {
topic?: string
}
export type M_Room_PinnedEvents = {
pinned: string[]
}
@ -275,6 +281,11 @@ export namespace Event {
users_default?: number
}
export type M_Space_Child = {
via?: string[]
suggested?: boolean
}
export type M_Reaction = {
"m.relates_to": {
rel_type: "m.annotation"

View file

@ -35,12 +35,13 @@ function render(event, filename, locals) {
pugCache.set(path, async (event, locals) => {
defaultContentType(event, "text/html; charset=utf-8")
const session = await useSession(event, {password: reg.as_token})
const managed = new Set((session.data.managedGuilds || []).concat(session.data.matrixGuilds || []))
const rel = x => getRelativePath(event.path, x)
return template(Object.assign({},
getQuery(event), // Query parameters can be easily accessed on the top level but don't allow them to overwrite anything
globals, // Globals
locals, // Explicit locals overwrite globals in case we need to DI something
{session, event, rel} // These are assigned last so they overwrite everything else. It would be catastrophically bad if they can't be trusted.
{session, event, rel, managed} // These are assigned last so they overwrite everything else. It would be catastrophically bad if they can't be trusted.
))
})
/* c8 ignore start */

View file

@ -56,7 +56,7 @@ block body
.fl-grow1
h2.fs-headline1 Invite a Matrix user
form.d-grid.g-af-column.gy4.gx8.jc-start(method="post" action="/api/invite" style="grid-template-rows: repeat(2, auto)")
form.d-grid.g-af-column.gy4.gx8.jc-start(method="post" action="/api/invite" hx-post="/api/invite" hx-indicator="#invite-button")
label.s-label(for="mxid") Matrix ID
input.fl-grow1.s-input.wmx3#mxid(name="mxid" required placeholder="@user:example.org")
label.s-label(for="permissions") Permissions
@ -67,23 +67,23 @@ block body
option(value="admin") Admin
input(type="hidden" name="guild_id" value=guild_id)
.grid--row-start2
button.s-btn.s-btn__filled.htmx-indicator Invite
button.s-btn.s-btn__filled#invite-button Invite
div
!= svg
h2.mt48.fs-headline1 Moderation
if space_id
h2.mt48.fs-headline1 Matrix setup
h3.mt32.fs-category Linked channels
.s-card.bs-sm.p0
.s-table-container
form.s-table-container(method="post" action="/api/unlink" hx-confirm="Do you want to unlink these channels?\nIt may take a moment to clean up Matrix resources.")
input(type="hidden" name="guild_id" value=guild_id)
table.s-table.s-table__bx-simple
each row in linkedChannelsWithDetails
tr
td.w40: +discord(row.channel)
td.p2: button.s-btn.s-btn__muted.s-btn__xs!= icons.Icons.IconLinkSm
td.p2: button.s-btn.s-btn__muted.s-btn__xs(name="channel_id" value=row.channel.id hx-post="/api/unlink" hx-trigger="click" hx-disabled-elt="this")!= icons.Icons.IconLinkSm
td: +matrix(row)
else
tr
@ -98,17 +98,18 @@ block body
p.s-description If you want, OOYE can automatically create new Matrix rooms and link them when an unlinked Discord channel is spoken in.
- let value = !!select("guild_active", "autocreate", {guild_id}).pluck().get()
input(type="hidden" name="guild_id" value=guild_id)
input.s-toggle-switch.order-last#autocreate(name="autocreate" type="checkbox" hx-post="/api/autocreate" hx-indicator="#autocreate-loading" hx-disabled-elt="this" checked=value)
.is-loading#autocreate-loading
input.s-toggle-switch.order-last#autocreate(name="autocreate" type="checkbox" hx-post="/api/autocreate" hx-indicator="#autocreate-loading" hx-disabled-elt="this" checked=value autocomplete="off")
#autocreate-loading
if space_id
h3.mt32.fs-category Privacy level
.s-card
form(hx-post="/api/privacy-level" hx-trigger="change" hx-indicator="#privacy-level-loading" hx-disabled-elt="this")
form(hx-post="/api/privacy-level" hx-trigger="change" hx-indicator="#privacy-level-loading" hx-disabled-elt="input")
input(type="hidden" name="guild_id" value=guild_id)
.d-flex.ai-center.mb4
label.s-label.fl-grow1
| How people can join on Matrix
span.is-loading#privacy-level-loading
span#privacy-level-loading
.s-toggle-switch.s-toggle-switch__multiple.s-toggle-switch__incremental.d-grid.gx16.ai-center(style="grid-template-columns: auto 1fr")
input(type="radio" name="level" value="directory" id="privacy-level-directory" checked=(privacy_level === 2))
label.d-flex.gx8.jc-center.grid--row-start3(for="privacy-level-directory")
@ -133,23 +134,60 @@ block body
p.s-description.m0 Publicly listed in directory, like Discord server discovery
h3.mt32.fs-category Manually link channels
form.d-flex.g16.ai-start(hx-post="/api/link" hx-trigger="submit" hx-disabled-elt="this")
form.d-flex.g16.ai-start(hx-post="/api/link" hx-trigger="submit" hx-disabled-elt="input, button" hx-indicator="#link-button")
.fl-grow2.s-btn-group.fd-column.w40
each channel in unlinkedChannels
input.s-btn--radio(type="radio" name="discord" id=channel.id value=channel.id)
input.s-btn--radio(type="radio" name="discord" required id=channel.id value=channel.id)
label.s-btn.s-btn__muted.ta-left.truncate(for=channel.id)
+discord(channel, true, "Announcement")
else
.s-empty-state.p8 All Discord channels are linked.
.fl-grow1.s-btn-group.fd-column.w30
each room in unlinkedRooms
input.s-btn--radio(type="radio" name="matrix" 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)
+matrix(room, true)
else
.s-empty-state.p8 All Matrix rooms are linked.
input(type="hidden" name="guild_id" value=guild_id)
div
button.s-btn.s-btn__icon.s-btn__filled.htmx-indicator
button.s-btn.s-btn__icon.s-btn__filled#link-button
!= icons.Icons.IconMerge
= ` Link`
details.mt48
summary Debug room list
.d-grid.grid__2.gx24
div
h3.mt24 Channels
p Channels are read from the channel_room table and then merged with the discord.channels memory cache to make the merged list. Anything in memory cache that's not in channel_room is considered unlinked.
div
h3.mt24 Rooms
p Rooms use the same merged list as channels, based on augmented channel_room data. Then, rooms are read from the space. Anything in the space that's not merged is considered unlinked.
div
h3.mt24 Unavailable channels: Deleted from Discord
.s-card.p0
ul.my8.ml24
each row in removedUncachedChannels
li: a(href=`https://discord.com/channels/${guild_id}/${row.channel_id}`)= row.nick || row.name
h3.mt24 Unavailable channels: Wrong type
.s-card.p0
ul.my8.ml24
each row in removedWrongTypeChannels
li: a(href=`https://discord.com/channels/${guild_id}/${row.channel_id}`) (#{row.type}) #{row.name}
div- // Rooms
h3.mt24 Unavailable rooms: Already linked
.s-card.p0
ul.my8.ml24
each row in removedLinkedRooms
li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name
h3.mt24 Unavailable rooms: Wrong type
.s-card.p0
ul.my8.ml24
each row in removedWrongTypeRooms
li: a(href=`https://matrix.to/#/${row.room_id}`) (#{row.room_type}) #{row.name}
h3.mt24 Unavailable rooms: Archived thread
.s-card.p0
ul.my8.ml24
each row in removedArchivedThreadRooms
li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name

View file

@ -1,13 +1,16 @@
extends includes/template.pug
block body
if !session.data.managedGuilds
if !session.data.user_id
.s-empty-state.wmx4.p48
!= icons.Spots.SpotEmptyXL
p You need to log in to manage your servers.
a.s-btn.s-btn__icon.s-btn__filled(href=rel("/oauth"))
a.s-btn.s-btn__icon.s-btn__featured.s-btn__filled(href=rel("/oauth"))
!= icons.Icons.IconDiscord
= ` Log in with Discord`
a.s-btn.s-btn__icon.s-btn__matrix.s-btn__filled(href=rel("/log-in-with-matrix"))
!= icons.Icons.IconChatBubble
= ` Log in with Matrix`
else if !guild_id
.s-empty-state.wmx4.p48
@ -15,7 +18,7 @@ block body
p Select a server from the top right corner to continue.
p If the server you're looking for isn't there, try #[a(href=rel("/oauth?action=add")) logging in again.]
else if !discord.guilds.has(guild_id) || !session.data.managedGuilds.includes(guild_id)
else if !discord.guilds.has(guild_id) || !managed.has(guild_id)
.s-empty-state.wmx4.p48
!= icons.Spots.SpotAlertXL
p Either the selected server doesn't exist, or you don't have the Manage Server permission on Discord.

View file

@ -0,0 +1,52 @@
extends includes/template.pug
mixin space(space)
.s-user-card.flex__1
span.s-avatar.s-avatar__32.s-user-card--avatar
if space.avatar
img.s-avatar--image(src=mUtils.getPublicUrlForMxc(space.avatar))
else
.s-avatar--letter.bg-silver-400.bar-md(aria-hidden="true")= space.name[0]
.s-user-card--info.ai-start
strong= space.name
if space.topic
ul.s-user-card--awards
li space.topic
block body
.s-notice.s-notice__info.d-flex.g16
div
!= icons.Icons.IconInfo
div
strong You picked self-service mode
.mt4 To complete setup, you need to manually choose a Matrix space to link with #[strong= guild.name].
h3.mt32.fs-category Choose a space
form.s-card.bs-sm.p0.s-table-container.bar-md(method="post" action="/api/link-space")
input(type="hidden" name="guild_id" value=guild_id)
table.s-table.s-table__bx-simple
each space in spaces
tr
td.p0: +space(space)
td: button.s-btn(name="space_id" value=space.room_id hx-post="/api/link-space" hx-trigger="click" hx-disabled-elt="this") Link with this space
else
if session.data.mxid
tr
- const self = `@${reg.sender_localpart}:${reg.ooye.server_name}`
td.p16 On Matrix, invite #[code.s-code-block: a.s-link(href=`https://matrix.to/#/${self}` target="_blank")= self] to a space. Then you can pick it from this list.
else
tr
td.d-flex.ai-center.pl16.g16
| You need to log in with Matrix first.
a.s-btn.s-btn__matrix.s-btn__outlined(href=rel("/log-in-with-matrix")) Log in with Matrix
h3.mt48.fs-category Auto-create
.s-card
form.d-flex.ai-center.g8(method="post" action="/api/autocreate" hx-post="/api/autocreate" hx-indicator="#easy-mode-button")
input(type="hidden" name="guild_id" value=guild_id)
input(type="hidden" name="autocreate" value="true")
label.s-label.fl-grow1
| Changed your mind?
p.s-description If you want, OOYE can create and manage the Matrix space so you don't have to.
button.s-btn.s-btn__outlined#easy-mode-button Use easy mode

View file

@ -16,7 +16,7 @@ html(lang="en")
<meta name="viewport" content="width=device-width, initial-scale=1">
link(rel="stylesheet" type="text/css" href=rel("/static/stacks.min.css"))
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 80%22><text y=%22.83em%22 font-size=%2283%22>💬</text></svg>">
meta(name="htmx-config" content='{"indicatorClass":"is-loading"}')
meta(name="htmx-config" content='{"requestClass":"is-loading"}')
style.
.themed {
--theme-base-primary-color-h: 266;
@ -30,6 +30,34 @@ html(lang="en")
--_ts-multiple-bg: var(--green-400);
--_ts-multiple-fc: var(--white);
}
.s-btn.s-btn__matrix {
--_bu-bg-active: var(--black-300);
--_bu-bg-hover: var(--black-200);
--_bu-bg-selected: var(--black-300);
--_bu-fc: var(--black-500);
--_bu-fc-active: var(--_bu-fc);
--_bu-fc-hover: var(--black-500);
--_bu-fc-selected: var(--black-600);
--_bu-filled-bc: transparent;
--_bu-filled-bc-selected: var(--_bu-filled-bc);
--_bu-filled-bg: var(--black-400);
--_bu-filled-bg-active: var(--black-500);
--_bu-filled-bg-hover: var(--black-500);
--_bu-filled-bg-selected: var(--black-600);
--_bu-filled-fc: var(--white);
--_bu-filled-fc-active: var(--_bu-filled-fc);
--_bu-filled-fc-hover: var(--_bu-filled-fc);
--_bu-filled-fc-selected: var(--_bu-filled-fc);
--_bu-outlined-bc: var(--black-400);
--_bu-outlined-bc-selected: var(--black-500);
--_bu-outlined-bg-selected: var(--_bu-bg-selected);
--_bu-outlined-fc-selected: var(--_bu-fc-selected);
--_bu-number-fc: var(--white);
--_bu-number-fc-filled: var(--black);
}
.s-btn__dropdown:has(+ :popover-open) {
background-color: var(--theme-topbar-item-background-hover, var(--black-200)) !important;
}
body.themed.theme-system
header.s-topbar
.s-topbar--skip-link(href="#content") Skip to main content
@ -38,34 +66,55 @@ html(lang="en")
img.s-avatar.s-avatar__32(src=rel("/icon.png"))
nav.s-topbar--navigation
ul.s-topbar--content
li.ps-relative
if !session.data.managedGuilds || session.data.managedGuilds.length === 0
a.s-btn.s-btn__icon.as-center(href=rel("/oauth"))
li.ps-relative.g8
if !session.data.mxid
a.s-btn.s-btn__icon.s-btn__matrix.s-btn__outlined.as-center(href=rel("/log-in-with-matrix"))
!= icons.Icons.IconSpeechBubble
= ` Log in with Matrix`
if !session.data.userID
a.s-btn.s-btn__icon.s-btn__featured.s-btn__outlined.as-center(href=rel("/oauth"))
!= icons.Icons.IconDiscord
= ` Log in`
else if guild_id && session.data.managedGuilds.includes(guild_id) && discord.guilds.has(guild_id)
button.s-topbar--item.s-btn.s-btn__muted.s-user-card(popovertarget="guilds")
= ` Log in with Discord`
if guild_id && managed.has(guild_id) && discord.guilds.has(guild_id)
button.s-topbar--item.s-btn.s-btn__muted.s-btn__dropdown.pr32.bar0.s-user-card(popovertarget="guilds")
+guild(discord.guilds.get(guild_id))
else if session.data.managedGuilds
button.s-topbar--item.s-btn.s-btn__muted.s-btn__dropdown.pr24.s-user-card.s-label(popovertarget="guilds")
else if managed.size
button.s-topbar--item.s-btn.s-btn__muted.s-btn__dropdown.pr24.s-user-card.bar0.fc-black(popovertarget="guilds")
| Your servers
#guilds(popover data-popper-placement="bottom" style="display: revert; width: revert;").s-popover.overflow-visible
.s-popover--arrow.s-popover--arrow__tc
.s-popover--content.overflow-y-auto.overflow-x-hidden
ul.s-menu(role="menu")
each guild in (session.data.managedGuilds || []).map(id => discord.guilds.get(id)).filter(g => g)
each guild in [...managed].map(id => discord.guilds.get(id)).filter(g => g).sort((a, b) => a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1)
li(role="menuitem")
a.s-topbar--item.s-user-card.d-flex.p4(href=rel(`/guild?guild_id=${guild.id}`))
+guild(guild)
//- Body
.mx-auto.w100.wmx9.py24.px8.fs-body1#content
block body
//- Guild list popover
script.
document.querySelectorAll("[popovertarget]").forEach(e => {
e.addEventListener("click", () => {
const rect = e.getBoundingClientRect()
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 }`
// console.log(t)
document.styleSheets[0].insertRule(t)
document.styleSheets[0].insertRule(t, document.styleSheets[0].cssRules.length)
})
})
script(src=rel("/static/htmx.min.js"))
script(src=rel("/static/htmx.js"))
//- Error dialog
aside.s-modal#server-error(aria-hidden="true")
.s-modal--dialog
h1.s-modal--header Server error
pre.overflow-auto#server-error-content
button.s-modal--close.s-btn.s-btn__muted(aria-label="Close" type="button" onclick="hideError()")!= icons.Icons.IconClearSm
.s-modal--footer
button.s-btn.s-btn__outlined.s-btn__muted(type="button" onclick="hideError()") OK
script.
function hideError() {
document.getElementById("server-error").setAttribute("aria-hidden", "true")
}
document.body.addEventListener("htmx:responseError", event => {
document.getElementById("server-error").setAttribute("aria-hidden", "false")
document.getElementById("server-error-content").textContent = event.detail.xhr.responseText
})

View file

@ -13,11 +13,11 @@ block body
.s-page-title.mb24
h1.s-page-title--header= guild.name
.d-flex.g16
.d-flex.g16#form-container
.fl-grow1
h2.fs-headline1 Invite a Matrix user
form.d-flex.gy16.fd-column(method="post" action="/api/invite" style="grid-template-rows: repeat(2, auto)")
form.d-flex.gy16.fd-column(method="post" action="/api/invite" hx-post="/api/invite" hx-indicator="#invite-button" hx-select="#ok" hx-target="#form-container")
.d-flex.gy4.fd-column
label.s-label(for="mxid") Matrix ID
input.fl-grow1.s-input.wmx3#mxid(name="mxid" required placeholder="@user:example.org")
@ -30,4 +30,4 @@ block body
option(value="admin") Admin
input(type="hidden" name="nonce" value=nonce)
div
button.s-btn.s-btn__filled.htmx-indicator Invite
button.s-btn.s-btn__filled#invite-button Invite

View file

@ -0,0 +1,14 @@
extends includes/template.pug
block body
.s-page-title.mb24
h1.s-page-title--header Log in with Matrix
.d-flex.g16#form-container
.fl-grow1
form.d-flex.gy16.fd-column(method="post" action="/api/log-in-with-matrix" hx-post="/api/log-in-with-matrix" hx-indicator="#log-in-button" hx-select="#ok" hx-target="#form-container")
.d-flex.gy4.fd-column
label.s-label(for="mxid") Your Matrix ID
input.fl-grow1.s-input.wmx3#mxid(name="mxid" required placeholder="@user:example.org")
div
button.s-btn.s-btn__github#log-in-button Continue with Matrix

View file

@ -1,6 +1,6 @@
extends includes/template.pug
block body
.ta-center.wmx5.p48.mx-auto
!= icons.Spots.SpotApproveXL
.ta-center.wmx5.p48.mx-auto#ok
!= spot ? icons.Spots[spot] : icons.Spots.SpotApproveXL
p.mt24.fs-body2= msg

View file

@ -2,9 +2,9 @@
const assert = require("assert/strict")
const {z} = require("zod")
const {defineEventHandler, sendRedirect, useSession, createError, readValidatedBody} = require("h3")
const {defineEventHandler, useSession, createError, readValidatedBody, getRequestHeader, setResponseHeader, sendRedirect} = require("h3")
const {as, db, sync} = require("../../passthrough")
const {as, db, sync, select} = require("../../passthrough")
const {reg} = require("../../matrix/read-registration")
/** @type {import("../../d2m/actions/create-space")} */
@ -26,16 +26,27 @@ const schema = {
as.router.post("/api/autocreate", defineEventHandler(async event => {
const parsedBody = await readValidatedBody(event, schema.autocreate.parse)
const session = await useSession(event, {password: reg.as_token})
if (!(session.data.managedGuilds || []).includes(parsedBody.guild_id)) throw createError({status: 403, message: "Forbidden", data: "Can't change settings for a guild you don't have Manage Server permissions in"})
if (!(session.data.managedGuilds || []).concat(session.data.matrixGuilds || []).includes(parsedBody.guild_id)) throw createError({status: 403, message: "Forbidden", data: "Can't change settings for a guild you don't have Manage Server permissions in"})
db.prepare("UPDATE guild_active SET autocreate = ? WHERE guild_id = ?").run(+!!parsedBody.autocreate, parsedBody.guild_id)
// If showing a partial page due to incomplete setup, need to refresh the whole page to show the alternate version
const spaceID = select("guild_space", "space_id", {guild_id: parsedBody.guild_id}).pluck().get()
if (!spaceID) {
if (getRequestHeader(event, "HX-Request")) {
setResponseHeader(event, "HX-Refresh", "true")
} else {
return sendRedirect(event, "", 302)
}
}
return null // 204
}))
as.router.post("/api/privacy-level", defineEventHandler(async event => {
const parsedBody = await readValidatedBody(event, schema.privacyLevel.parse)
const session = await useSession(event, {password: reg.as_token})
if (!(session.data.managedGuilds || []).includes(parsedBody.guild_id)) throw createError({status: 403, message: "Forbidden", data: "Can't change settings for a guild you don't have Manage Server permissions in"})
if (!(session.data.managedGuilds || []).concat(session.data.matrixGuilds || []).includes(parsedBody.guild_id)) throw createError({status: 403, message: "Forbidden", data: "Can't change settings for a guild you don't have Manage Server permissions in"})
const i = levels.indexOf(parsedBody.level)
assert.notEqual(i, -1)

View file

@ -2,13 +2,13 @@
const assert = require("assert/strict")
const {z} = require("zod")
const {H3Event, defineEventHandler, sendRedirect, useSession, createError, getValidatedQuery, readValidatedBody} = require("h3")
const {H3Event, defineEventHandler, sendRedirect, useSession, createError, getValidatedQuery, readValidatedBody, setResponseHeader} = require("h3")
const {randomUUID} = require("crypto")
const {LRUCache} = require("lru-cache")
const Ty = require("../../types")
const uqr = require("uqr")
const {discord, as, sync, select} = require("../../passthrough")
const {discord, as, sync, select, from, db} = require("../../passthrough")
/** @type {import("../pug-sync")} */
const pugSync = sync.require("../pug-sync")
/** @type {import("../../d2m/actions/create-space")} */
@ -42,6 +42,26 @@ function getAPI(event) {
/** @type {LRUCache<string, string>} nonce to guild id */
const validNonce = new LRUCache({max: 200})
/**
* Modifies the input, removing items that don't pass the filter. Returns the items that didn't pass.
* @param {T[]} xs
* @param {(x: T, i?: number) => any} fn
* @template T
* @returns T[]
*/
function filterTo(xs, fn) {
/** @type {T[]} */
const filtered = []
for (let i = xs.length-1; i >= 0; i--) {
const x = xs[i]
if (!fn(x, i)) {
filtered.unshift(x)
xs.splice(i, 1)
}
}
return filtered
}
/**
* @param {string} guildID
* @param {Ty.R.Hierarchy[]} rooms
@ -62,35 +82,48 @@ function getChannelRoomsLinks(guildID, rooms) {
assert(channelIDs)
let linkedChannels = select("channel_room", ["channel_id", "room_id", "name", "nick"], {channel_id: channelIDs}).all()
let linkedChannelsWithDetails = linkedChannels.map(c => ({channel: discord.channels.get(c.channel_id), ...c})).filter(c => c.channel)
let linkedChannelsWithDetails = linkedChannels.map(c => ({channel: discord.channels.get(c.channel_id), ...c}))
let removedUncachedChannels = filterTo(linkedChannelsWithDetails, c => c.channel)
let linkedChannelIDs = linkedChannelsWithDetails.map(c => c.channel_id)
linkedChannelsWithDetails.sort((a, b) => getPosition(a.channel) - getPosition(b.channel))
let unlinkedChannelIDs = channelIDs.filter(c => !linkedChannelIDs.includes(c))
let unlinkedChannels = unlinkedChannelIDs.map(c => discord.channels.get(c)).filter(c => c && [0, 5].includes(c.type))
let unlinkedChannels = unlinkedChannelIDs.map(c => discord.channels.get(c))
let removedWrongTypeChannels = filterTo(unlinkedChannels, c => c && [0, 5].includes(c.type))
unlinkedChannels.sort((a, b) => getPosition(a) - getPosition(b))
let linkedRoomIDs = linkedChannels.map(c => c.room_id)
let unlinkedRooms = rooms.filter(r => !linkedRoomIDs.includes(r.room_id) && !r.room_type)
let unlinkedRooms = [...rooms]
let removedLinkedRooms = filterTo(unlinkedRooms, r => !linkedRoomIDs.includes(r.room_id))
let removedWrongTypeRooms = filterTo(unlinkedRooms, r => !r.room_type)
// https://discord.com/developers/docs/topics/threads#active-archived-threads
// need to filter out linked archived threads from unlinkedRooms, will just do that by comparing against the name
unlinkedRooms = unlinkedRooms.filter(r => r.name && !r.name.match(/^\[(🔒)?⛓️\]/))
let removedArchivedThreadRooms = filterTo(unlinkedRooms, r => r.name && !r.name.match(/^\[(🔒)?⛓️\]/))
return {linkedChannelsWithDetails, unlinkedChannels, unlinkedRooms}
return {
linkedChannelsWithDetails, unlinkedChannels, unlinkedRooms,
removedUncachedChannels, removedWrongTypeChannels, removedLinkedRooms, removedWrongTypeRooms, removedArchivedThreadRooms
}
}
as.router.get("/guild", defineEventHandler(async event => {
const {guild_id} = await getValidatedQuery(event, schema.guild.parse)
const session = await useSession(event, {password: reg.as_token})
const row = select("guild_space", ["space_id", "privacy_level"], {guild_id}).get()
const row = from("guild_active").join("guild_space", "guild_id", "left").select("space_id", "privacy_level", "autocreate").where({guild_id}).get()
// @ts-ignore
const guild = discord.guilds.get(guild_id)
// Permission problems
if (!guild_id || !guild || !session.data.managedGuilds || !session.data.managedGuilds.includes(guild_id)) {
if (!guild_id || !guild || !(session.data.managedGuilds || []).concat(session.data.matrixGuilds || []).includes(guild_id) || !row) {
return pugSync.render(event, "guild_access_denied.pug", {guild_id})
}
// Self-service guild that hasn't been linked yet - needs a special page encouraging the link flow
if (!row.space_id && row.autocreate === 0) {
const spaces = db.prepare("SELECT room_id, type, name, avatar FROM invite LEFT JOIN guild_space ON invite.room_id = guild_space.space_id WHERE mxid = ? AND space_id IS NULL and type = 'm.space'").all(session.data.mxid)
return pugSync.render(event, "guild_not_linked.pug", {guild, guild_id, spaces})
}
const nonce = randomUUID()
validNonce.set(nonce, guild_id)
@ -101,10 +134,10 @@ as.router.get("/guild", defineEventHandler(async event => {
const svg = generatedSvg.replace(/viewBox="0 0 ([0-9]+) ([0-9]+)"/, `data-nonce="${nonce}" width="$1" height="$2" $&`)
assert.notEqual(svg, generatedSvg)
// Unlinked guild
if (!row) {
// Easy mode guild that hasn't been linked yet - need to remove elements that would require an existing space
if (!row.space_id) {
const links = getChannelRoomsLinks(guild_id, [])
return pugSync.render(event, "guild.pug", {guild, guild_id, svg, ...links})
return pugSync.render(event, "guild.pug", {guild, guild_id, svg, ...links, ...row})
}
// Linked guild
@ -132,7 +165,7 @@ as.router.post("/api/invite", defineEventHandler(async event => {
// Check guild ID or nonce
if (parsedBody.guild_id) {
var guild_id = parsedBody.guild_id
if (!(session.data.managedGuilds || []).includes(guild_id)) throw createError({status: 403, message: "Forbidden", data: "Can't invite users to a guild you don't have Manage Server permissions in"})
if (!(session.data.managedGuilds || []).concat(session.data.matrixGuilds || []).includes(guild_id)) throw createError({status: 403, message: "Forbidden", data: "Can't invite users to a guild you don't have Manage Server permissions in"})
} else if (parsedBody.nonce) {
if (!validNonce.has(parsedBody.nonce)) throw createError({status: 403, message: "Nonce expired", data: "Nonce means number-used-once, and, well, you tried to use it twice..."})
let ok = validNonce.get(parsedBody.nonce)
@ -164,9 +197,10 @@ as.router.post("/api/invite", defineEventHandler(async event => {
( parsedBody.permissions === "admin" ? 100
: parsedBody.permissions === "moderator" ? 50
: 0)
await api.setUserPowerCascade(spaceID, parsedBody.mxid, powerLevel)
if (powerLevel) await api.setUserPowerCascade(spaceID, parsedBody.mxid, powerLevel)
if (parsedBody.guild_id) {
setResponseHeader(event, "HX-Refresh", true)
return sendRedirect(event, `/guild?guild_id=${guild_id}`, 302)
} else {
return sendRedirect(event, "/ok?msg=User has been invited.", 302)

View file

@ -18,6 +18,7 @@ test("web guild: access denied when not logged in", async t => {
test("web guild: asks to select guild if not selected", async t => {
const content = await router.test("get", "/guild", {
sessionData: {
user_id: "1",
managedGuilds: []
},
})
@ -27,6 +28,7 @@ test("web guild: asks to select guild if not selected", async t => {
test("web guild: access denied when guild id messed up", async t => {
const content = await router.test("get", "/guild?guild_id=1", {
sessionData: {
user_id: "1",
managedGuilds: []
},
})
@ -43,6 +45,7 @@ test("web invite: access denied with invalid nonce", async t => {
test("web guild: can view unbridged guild", async t => {
const content = await router.test("get", "/guild?guild_id=66192955777486848", {
sessionData: {
user_id: "1",
managedGuilds: ["66192955777486848"]
},
api: {
@ -177,16 +180,12 @@ test("api invite: can invite to a moderated guild", async t => {
async inviteToRoom(roomID, mxidToInvite, mxid) {
t.equal(roomID, "!jjWAGMeQdNrVZSSfvz:cadence.moe")
called++
},
async setUserPowerCascade(roomID, mxid, power) {
t.equal(power, 0)
called++
}
}
})
)
t.notOk(error)
t.equal(called, 3)
t.equal(called, 2)
})
test("api invite: does not reinvite joined users", async t => {
@ -205,14 +204,10 @@ test("api invite: does not reinvite joined users", async t => {
async getStateEvent(roomID, type, key) {
called++
return {membership: "join"}
},
async setUserPowerCascade(roomID, mxid, power) {
t.equal(power, 0)
called++
}
}
})
)
t.notOk(error)
t.equal(called, 2)
t.equal(called, 1)
})

View file

@ -1,8 +1,9 @@
// @ts-check
const {z} = require("zod")
const {defineEventHandler, useSession, createError, readValidatedBody} = require("h3")
const {defineEventHandler, useSession, createError, readValidatedBody, setResponseHeader} = require("h3")
const Ty = require("../../types")
const DiscordTypes = require("discord-api-types/v10")
const {discord, db, as, sync, select, from} = require("../../passthrough")
/** @type {import("../../d2m/actions/create-space")} */
@ -15,20 +16,70 @@ const {reg} = require("../../matrix/read-registration")
const api = sync.require("../../matrix/api")
const schema = {
linkSpace: z.object({
guild_id: z.string(),
space_id: z.string()
}),
link: z.object({
guild_id: z.string(),
matrix: z.string(),
discord: z.string()
}),
unlink: z.object({
guild_id: z.string(),
channel_id: z.string()
})
}
as.router.post("/api/link-space", defineEventHandler(async event => {
const parsedBody = await readValidatedBody(event, schema.linkSpace.parse)
const session = await useSession(event, {password: reg.as_token})
// Check guild ID
const guildID = parsedBody.guild_id
if (!(session.data.managedGuilds || []).concat(session.data.matrixGuilds || []).includes(guildID)) throw createError({status: 403, message: "Forbidden", data: "Can't edit a guild you don't have Manage Server permissions in"})
// Check space ID
if (!session.data.mxid) throw createError({status: 403, message: "Forbidden", data: "Can't link with your Matrix space if you aren't logged in to Matrix"})
const spaceID = parsedBody.space_id
const inviteType = select("invite", "type", {mxid: session.data.mxid, room_id: spaceID}).pluck().get()
if (inviteType !== "m.space") throw createError({status: 403, message: "Forbidden", data: "No past invitations detected from your Matrix account for that space."})
// Check they are not already bridged
const existing = select("guild_space", "guild_id", {}, "WHERE guild_id = ? OR space_id = ?").get(guildID, spaceID)
if (existing) throw createError({status: 400, message: "Bad Request", data: `Guild ID ${guildID} or space ID ${spaceID} are already bridged and cannot be reused`})
// Check space exists and bridge is joined and bridge has PL 100
const self = `@${reg.sender_localpart}:${reg.ooye.server_name}`
/** @type {Ty.Event.M_Room_Member} */
const memberEvent = await api.getStateEvent(spaceID, "m.room.member", self)
if (memberEvent.membership !== "join") throw createError({status: 400, message: "Bad Request", data: "Matrix space does not exist"})
/** @type {Ty.Event.M_Power_Levels} */
const powerLevelsStateContent = await api.getStateEvent(spaceID, "m.room.power_levels", "")
const selfPowerLevel = powerLevelsStateContent.users?.[self] || powerLevelsStateContent.users_default || 0
if (selfPowerLevel < (powerLevelsStateContent.state_default || 50) || selfPowerLevel < 100) throw createError({status: 400, message: "Bad Request", data: "OOYE needs power level 100 (admin) in the target Matrix space"})
// Check inviting user is a moderator in the space
const invitingPowerLevel = powerLevelsStateContent.users?.[session.data.mxid] || powerLevelsStateContent.users_default || 0
if (invitingPowerLevel < (powerLevelsStateContent.state_default || 50)) throw createError({status: 403, message: "Forbidden", data: `You need to be at least power level 50 (moderator) in the target Matrix space to use OOYE, but you are currently power level ${invitingPowerLevel}.`})
// Insert database entry
db.transaction(() => {
db.prepare("INSERT INTO guild_space (guild_id, space_id) VALUES (?, ?)").run(guildID, spaceID)
db.prepare("DELETE FROM invite WHERE room_id = ?").run(spaceID)
})()
setResponseHeader(event, "HX-Refresh", "true")
return null // 204
}))
as.router.post("/api/link", defineEventHandler(async event => {
const parsedBody = await readValidatedBody(event, schema.link.parse)
const session = await useSession(event, {password: reg.as_token})
// Check guild ID or nonce
const guildID = parsedBody.guild_id
if (!(session.data.managedGuilds || []).includes(guildID)) throw createError({status: 403, message: "Forbidden", data: "Can't edit a guild you don't have Manage Server permissions in"})
if (!(session.data.managedGuilds || []).concat(session.data.matrixGuilds || []).includes(guildID)) throw createError({status: 403, message: "Forbidden", data: "Can't edit a guild you don't have Manage Server permissions in"})
// Check guild is bridged
const guild = discord.guilds.get(guildID)
@ -59,5 +110,37 @@ as.router.post("/api/link", defineEventHandler(async event => {
// Sync room data and space child
await createRoom.syncRoom(parsedBody.discord)
// Send a notification in the room
if (channel.type === DiscordTypes.ChannelType.GuildText) {
await api.sendEvent(parsedBody.matrix, "m.room.message", {
msgtype: "m.notice",
body: "👋 This room is now bridged with Discord. Say hi!"
})
}
setResponseHeader(event, "HX-Refresh", "true")
return null // 204
}))
as.router.post("/api/unlink", defineEventHandler(async event => {
const {channel_id, guild_id} = await readValidatedBody(event, schema.unlink.parse)
const session = await useSession(event, {password: reg.as_token})
// Check guild ID or nonce
if (!(session.data.managedGuilds || []).concat(session.data.matrixGuilds || []).includes(guild_id)) throw createError({status: 403, message: "Forbidden", data: "Can't edit a guild you don't have Manage Server permissions in"})
// Check channel is part of this guild
const channel = discord.channels.get(channel_id)
if (!channel) throw createError({status: 400, message: "Bad Request", data: `Channel ID ${channel_id} does not exist`})
if (!("guild_id" in channel) || channel.guild_id !== guild_id) throw createError({status: 400, message: "Bad Request", data: `Channel ID ${channel_id} is not part of guild ${guild_id}`})
// Check channel is currently bridged
const row = select("channel_room", "channel_id", {channel_id: channel_id}).get()
if (!row) throw createError({status: 400, message: "Bad Request", data: `Channel ID ${channel_id} is not currently bridged`})
// Do it
await createRoom.unbridgeDeletedChannel(channel, guild_id)
setResponseHeader(event, "HX-Refresh", "true")
return null // 204
}))

View file

@ -0,0 +1,126 @@
// @ts-check
const {z} = require("zod")
const {randomUUID} = require("crypto")
const {defineEventHandler, getValidatedQuery, sendRedirect, readValidatedBody, useSession, createError, getRequestHeader} = require("h3")
const {SnowTransfer} = require("snowtransfer")
const DiscordTypes = require("discord-api-types/v10")
const fetch = require("node-fetch")
const getRelativePath = require("get-relative-path")
const {LRUCache} = require("lru-cache")
const {as, db, select, from} = require("../../passthrough")
const {id} = require("../../../addbot")
const {reg} = require("../../matrix/read-registration")
const {sync} = require("../../passthrough")
const assert = require("assert").strict
/** @type {import("../pug-sync")} */
const pugSync = sync.require("../pug-sync")
/** @type {import("../../matrix/api")} */
const api = sync.require("../../matrix/api")
const redirect_uri = `${reg.ooye.bridge_origin}/oauth`
const schema = {
form: z.object({
mxid: z.string()
}),
token: z.object({
token: z.string()
})
}
/** @type {LRUCache<string, string>} token to mxid */
const validToken = new LRUCache({max: 200})
/*
1st request, GET, they clicked the button, need to input their mxid
2nd request, POST, they input their mxid and we need to send a link
3rd request, GET, they clicked the link and we need to set the session data (just their mxid)
*/
as.router.get("/log-in-with-matrix", defineEventHandler(async event => {
const parsed = await getValidatedQuery(event, schema.token.safeParse)
if (!parsed.success) {
// We are in the first request and need to tell them to input their mxid
return pugSync.render(event, "log-in-with-matrix.pug", {})
}
const userAgent = getRequestHeader(event, "User-Agent")
if (userAgent?.match(/bot/)) throw createError({status: 400, data: "Sorry URL previewer, you can't have this URL."})
const token = parsed.data.token
if (!validToken.has(token)) return sendRedirect(event, `${reg.ooye.bridge_origin}/log-in-with-matrix`, 302)
const session = await useSession(event, {password: reg.as_token})
const mxid = validToken.get(token)
assert(mxid)
validToken.delete(token)
const matrixGuilds = db.prepare("SELECT guild_id FROM guild_space INNER JOIN member_cache ON space_id = room_id WHERE mxid = ? AND power_level >= 50").pluck().all(mxid)
await session.update({mxid, matrixGuilds})
return sendRedirect(event, "./", 302) // open to homepage where they can see they're logged in
}))
as.router.post("/api/log-in-with-matrix", defineEventHandler(async event => {
const {mxid} = await readValidatedBody(event, schema.form.parse)
let roomID = null
// Don't extend a duplicate invite for the same user
for (const alreadyInvited of validToken.values()) {
if (mxid === alreadyInvited) {
return sendRedirect(event, "../ok?msg=We already sent you a link on Matrix. Please click it!", 302)
}
}
// See if we can reuse an existing room from account data
let directData = {}
try {
directData = await api.getAccountData("m.direct")
} catch (e) {}
const rooms = directData[mxid] || []
for (const candidate of rooms) {
// Check that the person is/still in the room
let member
try {
member = await api.getStateEvent(candidate, "m.room.member", mxid)
} catch (e) {}
if (!member || member.membership === "leave") {
// We can reinvite them back to the same room!
await api.inviteToRoom(candidate, mxid)
roomID = candidate
} else {
// Member is in this room
roomID = candidate
}
if (roomID) break // no need to check other candidates
}
// No candidates available, create a new room and invite
if (!roomID) {
roomID = await api.createRoom({
invite: [mxid],
is_direct: true,
preset: "trusted_private_chat"
})
// Store the newly created room in account data (Matrix doesn't do this for us automatically, sigh...)
;(directData[mxid] ??= []).push(roomID)
await api.setAccountData("m.direct", directData)
}
const token = randomUUID()
validToken.set(token, mxid)
console.log(`web log in requested for ${mxid}`)
const body = `Hi, this is Out Of Your Element! You just clicked the "log in" button on the website.\nOpen this link to finish: ${reg.ooye.bridge_origin}/log-in-with-matrix?token=${token}\nThe link can be used once.`
await api.sendEvent(roomID, "m.room.message", {
msgtype: "m.text",
body
})
return sendRedirect(event, "../ok?msg=Please check your inbox on Matrix!&spot=SpotMailXL", 302)
}))

View file

@ -2,7 +2,7 @@
const {z} = require("zod")
const {randomUUID} = require("crypto")
const {defineEventHandler, getValidatedQuery, sendRedirect, getQuery, useSession, createError} = require("h3")
const {defineEventHandler, getValidatedQuery, sendRedirect, useSession, createError} = require("h3")
const {SnowTransfer} = require("snowtransfer")
const DiscordTypes = require("discord-api-types/v10")
const fetch = require("node-fetch")
@ -75,11 +75,12 @@ as.router.get("/oauth", defineEventHandler(async event => {
throw createError({status: 502, message: "Invalid token response", data: `Discord completed OAuth, but returned this instead of an OAuth access token: ${JSON.stringify(root)}`})
}
const userID = Buffer.from(parsedToken.data.access_token.split(".")[0], "base64").toString()
const client = new SnowTransfer(`Bearer ${parsedToken.data.access_token}`)
try {
const guilds = await client.user.getGuilds()
var managedGuilds = guilds.filter(g => BigInt(g.permissions) & DiscordTypes.PermissionFlagsBits.ManageGuild).map(g => g.id)
await session.update({managedGuilds})
await session.update({managedGuilds, userID, state: undefined})
} catch (e) {
throw createError({status: 502, message: "API call failed", data: e.message})
}
@ -87,7 +88,8 @@ as.router.get("/oauth", defineEventHandler(async event => {
// Set auto-create for the guild
// @ts-ignore
if (managedGuilds.includes(parsedQuery.data.guild_id)) {
db.prepare("REPLACE INTO guild_active (guild_id, autocreate) VALUES (?, ?)").run(parsedQuery.data.guild_id, +!session.data.selfService)
const autocreateInteger = +!session.data.selfService
db.prepare("INSERT INTO guild_active (guild_id, autocreate) VALUES (?, ?) ON CONFLICT DO UPDATE SET autocreate = ?").run(parsedQuery.data.guild_id, autocreateInteger, autocreateInteger)
}
if (parsedQuery.data.guild_id) {

View file

@ -7,15 +7,18 @@ const {defineEventHandler, defaultContentType, getRequestHeader, setResponseHead
const icons = require("@stackoverflow/stacks-icons")
const DiscordTypes = require("discord-api-types/v10")
const dUtils = require("../discord/utils")
const reg = require("../matrix/read-registration")
const {sync, discord, as, select} = require("../passthrough")
/** @type {import("./pug-sync")} */
const pugSync = sync.require("./pug-sync")
/** @type {import("../m2d/converters/utils")} */
const mUtils = sync.require("../m2d/converters/utils")
const {id} = require("../../addbot")
// Pug
pugSync.addGlobals({id, h3, discord, select, DiscordTypes, dUtils, icons})
pugSync.addGlobals({id, h3, discord, select, DiscordTypes, dUtils, mUtils, icons, reg: reg.reg})
pugSync.createRoute(as.router, "/", "home.pug")
pugSync.createRoute(as.router, "/ok", "ok.pug")
@ -27,6 +30,7 @@ sync.require("./routes/guild-settings")
sync.require("./routes/guild")
sync.require("./routes/link")
sync.require("./routes/oauth")
sync.require("./routes/log-in-with-matrix")
// Files
@ -49,12 +53,12 @@ as.router.get("/static/stacks.min.css", defineEventHandler({
}
}))
as.router.get("/static/htmx.min.js", defineEventHandler({
as.router.get("/static/htmx.js", defineEventHandler({
onBeforeResponse: compressResponse,
handler: async event => {
handleCacheHeaders(event, {maxAge: 86400})
defaultContentType(event, "text/javascript")
return fs.promises.readFile(join(__dirname, "static", "htmx.min.js"), "utf-8")
return fs.promises.readFile(join(__dirname, "static", "htmx.js"), "utf-8")
}
}))

View file

@ -10,7 +10,7 @@ test("web server: can get home", async t => {
})
test("web server: can get htmx", async t => {
t.match(await router.test("get", "/static/htmx.min.js", {}), /htmx=/)
t.match(await router.test("get", "/static/htmx.js", {}), /htmx =/)
})
test("web server: can get css", async t => {

5261
src/web/static/htmx.js Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

View file

@ -1,13 +1,14 @@
BEGIN TRANSACTION;
INSERT INTO guild_space (guild_id, space_id, privacy_level) VALUES
('112760669178241024', '!jjWAGMeQdNrVZSSfvz:cadence.moe', 0);
INSERT INTO guild_active (guild_id, autocreate) VALUES
('112760669178241024', 1);
INSERT INTO guild_space (guild_id, space_id, privacy_level) VALUES
('112760669178241024', '!jjWAGMeQdNrVZSSfvz:cadence.moe', 0);
INSERT INTO channel_room (channel_id, room_id, name, nick, thread_parent, custom_avatar) VALUES
('112760669178241024', '!kLRqKKUQXcibIMtOpl:cadence.moe', 'heave', 'main', NULL, NULL),
('687028734322147344', '!fGgIymcYWOqjbSRUdV:cadence.moe', 'slow-news-day', NULL, NULL, NULL),
('497161350934560778', '!CzvdIdUQXgUjDVKxeU:cadence.moe', 'amanda-spam', NULL, NULL, NULL),
('160197704226439168', '!hYnGGlPHlbujVVfktC:cadence.moe', 'the-stanley-parable-channel', 'bots', NULL, NULL),
('1100319550446252084', '!BnKuBPCvyfOkhcUjEu:cadence.moe', 'worm-farm', NULL, NULL, NULL),
@ -18,25 +19,25 @@ INSERT INTO channel_room (channel_id, room_id, name, nick, thread_parent, custom
('489237891895768942', '!tnedrGVYKFNUdnegvf:tchncs.de', 'ex-room-doesnt-exist-any-more', NULL, NULL, NULL),
('1160894080998461480', '!TqlyQmifxGUggEmdBN:cadence.moe', 'ooyexperiment', NULL, NULL, NULL);
INSERT INTO sim (user_id, sim_name, localpart, mxid) VALUES
('0', 'bot', '_ooye_bot', '@_ooye_bot:cadence.moe'),
('820865262526005258', 'crunch_god', '_ooye_crunch_god', '@_ooye_crunch_god:cadence.moe'),
('771520384671416320', 'bojack_horseman', '_ooye_bojack_horseman', '@_ooye_bojack_horseman:cadence.moe'),
('112890272819507200', '.wing.', '_ooye_.wing.', '@_ooye_.wing.:cadence.moe'),
('114147806469554185', 'extremity', '_ooye_extremity', '@_ooye_extremity:cadence.moe'),
('111604486476181504', 'kyuugryphon', '_ooye_kyuugryphon', '@_ooye_kyuugryphon:cadence.moe'),
('1109360903096369153', 'amanda', '_ooye_amanda', '@_ooye_amanda:cadence.moe'),
('43d378d5-1183-47dc-ab3c-d14e21c3fe58', '_pk_zoego', '_ooye__pk_zoego', '@_ooye__pk_zoego:cadence.moe'),
('320067006521147393', 'papiophidian', '_ooye_papiophidian', '@_ooye_papiophidian:cadence.moe'),
('772659086046658620', 'cadence', '_ooye_cadence', '@_ooye_cadence:cadence.moe');
INSERT INTO sim_proxy (user_id, proxy_owner_id, displayname) VALUES
('43d378d5-1183-47dc-ab3c-d14e21c3fe58', '196188877885538304', 'Azalea &flwr; 🌺');
INSERT INTO sim (user_id, username, sim_name, mxid) VALUES
('0', 'Matrix Bridge', 'bot', '@_ooye_bot:cadence.moe'),
('820865262526005258', 'Crunch God', 'crunch_god', '@_ooye_crunch_god:cadence.moe'),
('771520384671416320', 'Bojack Horseman', 'bojack_horseman', '@_ooye_bojack_horseman:cadence.moe'),
('112890272819507200', 'wing', '.wing.', '@_ooye_.wing.:cadence.moe'),
('114147806469554185', 'extremity', 'extremity', '@_ooye_extremity:cadence.moe'),
('111604486476181504', 'kyuugryphon', 'kyuugryphon', '@_ooye_kyuugryphon:cadence.moe'),
('1109360903096369153', 'Amanda', 'amanda', '@_ooye_amanda:cadence.moe'),
('43d378d5-1183-47dc-ab3c-d14e21c3fe58', '_pk_zoego', '_pk_zoego', '@_ooye__pk_zoego:cadence.moe'),
('320067006521147393', 'papiophidian', 'papiophidian', '@_ooye_papiophidian:cadence.moe'),
('772659086046658620', 'cadence.worm', 'cadence', '@_ooye_cadence:cadence.moe');
INSERT INTO sim_member (mxid, room_id, hashed_profile_content) VALUES
('@_ooye_bojack_horseman:cadence.moe', '!hYnGGlPHlbujVVfktC:cadence.moe', NULL),
('@_ooye_cadence:cadence.moe', '!BnKuBPCvyfOkhcUjEu:cadence.moe', NULL);
INSERT INTO sim_proxy (user_id, proxy_owner_id, displayname) VALUES
('43d378d5-1183-47dc-ab3c-d14e21c3fe58', '196188877885538304', 'Azalea &flwr; 🌺');
INSERT INTO message_channel (message_id, channel_id) VALUES
('1106366167788044450', '122155380120748034'),
('1106366167788044451', '122155380120748034'),
@ -65,7 +66,8 @@ INSERT INTO message_channel (message_id, channel_id) VALUES
('1273743950028607530', '1100319550446252084'),
('1278002262400176128', '1100319550446252084'),
('1278001833876525057', '1100319550446252084'),
('1191567971970191490', '176333891320283136');
('1191567971970191490', '176333891320283136'),
('1144874214311067708', '687028734322147344');
INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part, reaction_part, source) VALUES
('$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg', 'm.room.message', 'm.text', '1126786462646550579', 0, 0, 1),
@ -140,19 +142,16 @@ INSERT INTO emoji (emoji_id, name, animated, mxc_url) VALUES
INSERT INTO member_cache (room_id, mxid, displayname, avatar_url, power_level) VALUES
('!kLRqKKUQXcibIMtOpl:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', NULL, 0),
('!BpMdOUkWWhFxmTrENV:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', 'malformed mxc', 0),
('!kLRqKKUQXcibIMtOpl:cadence.moe', '@test_auto_invite:example.org', NULL, NULL, 0),
('!fGgIymcYWOqjbSRUdV:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', 'mxc://cadence.moe/azCAhThKTojXSZJRoWwZmhvU', 0),
('!fGgIymcYWOqjbSRUdV:cadence.moe', '@rnl:cadence.moe', 'RNL', NULL, 0),
('!BnKuBPCvyfOkhcUjEu:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', 'mxc://cadence.moe/azCAhThKTojXSZJRoWwZmhvU', 0),
('!maggESguZBqGBZtSnr:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', 'mxc://cadence.moe/azCAhThKTojXSZJRoWwZmhvU', 0),
('!BnKuBPCvyfOkhcUjEu:cadence.moe', '@ami:the-apothecary.club', 'Ami (she/her)', NULL, 0),
('!CzvdIdUQXgUjDVKxeU:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', 'mxc://cadence.moe/azCAhThKTojXSZJRoWwZmhvU', 0),
('!cBxtVRxDlZvSVhJXVK:cadence.moe', '@Milan:tchncs.de', 'Milan', NULL, 0),
('!TqlyQmifxGUggEmdBN:cadence.moe', '@Milan:tchncs.de', 'Milan', NULL, 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', '@cadence:cadence.moe', 'cadence [they]', NULL, 0),
('!BnKuBPCvyfOkhcUjEu:cadence.moe', '@ami:the-apothecary.club', 'Ami (she/her)', NULL, 0),
('!kLRqKKUQXcibIMtOpl:cadence.moe', '@test_auto_invite:example.org', NULL, NULL, 0),
('!BpMdOUkWWhFxmTrENV:cadence.moe', '@test_auto_invite:example.org', NULL, NULL, 100);
('!TqlyQmifxGUggEmdBN:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', NULL, 0);
INSERT INTO reaction (hashed_event_id, message_id, encoded_emoji) VALUES
(5162930312280790092, '1141501302736695317', '%F0%9F%90%88');

View file

@ -141,6 +141,7 @@ file._actuallyUploadDiscordFileToMxc = function(url, res) { throw new Error(`Not
require("../src/d2m/converters/user-to-mxid.test")
require("../src/m2d/converters/diff-pins.test")
require("../src/m2d/converters/event-to-message.test")
require("../src/m2d/converters/emoji.test")
require("../src/m2d/converters/utils.test")
require("../src/m2d/converters/emoji-sheet.test")
require("../src/discord/interactions/invite.test")