diff --git a/docs/foreign-keys.md b/docs/foreign-keys.md deleted file mode 100644 index 1e5e21c..0000000 --- a/docs/foreign-keys.md +++ /dev/null @@ -1,98 +0,0 @@ -# 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.* diff --git a/docs/self-service-room-creation-rules.md b/docs/self-service-room-creation-rules.md index 4156f26..70892fc 100644 --- a/docs/self-service-room-creation-rules.md +++ b/docs/self-service-room-creation-rules.md @@ -69,9 +69,3 @@ 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. diff --git a/package-lock.json b/package-lock.json index 54b1fc9..1852245 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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.1", + "@cloudrac3r/mixin-deep": "^3.0.0", "@cloudrac3r/pngjs": "^7.0.3", "@cloudrac3r/pug": "^4.0.4", "@cloudrac3r/turndown": "^7.1.4", @@ -281,10 +281,9 @@ } }, "node_modules/@cloudrac3r/mixin-deep": { - "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", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@cloudrac3r/mixin-deep/-/mixin-deep-3.0.0.tgz", + "integrity": "sha512-yQz1wHSZbHfbKaGSjrV3wIG0e9MnElKlmekMKJPRdTn2jhF2Mt8wfMPX8U7v6rTyzR/7BTrX8CCUcrJMLgoQqw==", "engines": { "node": ">=6" } @@ -924,10 +923,9 @@ "dev": true }, "node_modules/@stackoverflow/stacks": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/@stackoverflow/stacks/-/stacks-2.7.0.tgz", - "integrity": "sha512-nn4tow6oTsYlpKwOcpPeKclFMvn0Py+rWCZppRWqcEVl9w2+U+nU7QyKsLzySvSFgXoo5hrBPWp5t7AlNVmF0A==", - "license": "MIT", + "version": "2.5.7", + "resolved": "https://registry.npmjs.org/@stackoverflow/stacks/-/stacks-2.5.7.tgz", + "integrity": "sha512-1ipTt7jqUszyd78Gn9TADT22PL0yXe14iEfgZyvJlDvrNrmyJLoGsFMRMwcduPol6/C/zkFt2dmfph/5vFDcYA==", "dependencies": { "@hotwired/stimulus": "^3.2.2", "@popperjs/core": "^2.11.8" @@ -3219,9 +3217,9 @@ "license": "MIT" }, "node_modules/undici": { - "version": "6.21.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.1.tgz", - "integrity": "sha512-q/1rj5D0/zayJB2FraXdaWxbhWiNKDvu8naDT2dl1yTlvJp4BLtOcp2a5BvgGNQpYYJzau7tf1WgKv3b+7mqpQ==", + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.19.8.tgz", + "integrity": "sha512-U8uCCl2x9TK3WANvmBavymRzxbfFYG+tAu+fgx3zxQy3qdagQqBLwJVrdyO1TBfUXvfKveMKJZhpvUYoOjM+4g==", "license": "MIT", "engines": { "node": ">=18.17" diff --git a/package.json b/package.json index 1d2e411..1705c02 100644 --- a/package.json +++ b/package.json @@ -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.1", + "@cloudrac3r/mixin-deep": "^3.0.0", "@cloudrac3r/pngjs": "^7.0.3", "@cloudrac3r/pug": "^4.0.4", "@cloudrac3r/turndown": "^7.1.4", diff --git a/readme.md b/readme.md index 1f01d01..6f2f477 100644 --- a/readme.md +++ b/readme.md @@ -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,8 +78,6 @@ 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). diff --git a/scripts/emoji-surrogates-statistics.js b/scripts/emoji-surrogates-statistics.js deleted file mode 100644 index 29abce3..0000000 --- a/scripts/emoji-surrogates-statistics.js +++ /dev/null @@ -1,77 +0,0 @@ -// @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} - */ -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}`) -} diff --git a/scripts/setup.js b/scripts/setup.js index 6ee8c19..3491106 100644 --- a/scripts/setup.js +++ b/scripts/setup.js @@ -319,8 +319,8 @@ function defineEchoHandler() { await migrate.migrate(db) // add initial rows to database, like adding the bot to sim... - 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) + 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) console.log("✅ Database is ready...") diff --git a/scripts/start-server.js b/scripts/start-server.js index 6c15037..f09c458 100755 --- a/scripts/start-server.js +++ b/scripts/start-server.js @@ -38,6 +38,4 @@ passthrough.select = orm.select await discord.cloud.connect() console.log("Discord gateway started") sync.require("../src/web/server") - - require("../src/stdin") })() diff --git a/src/d2m/actions/create-room.js b/src/d2m/actions/create-room.js index cf785ac..42d5714 100644 --- a/src/d2m/actions/create-room.js +++ b/src/d2m/actions/create-room.js @@ -6,19 +6,15 @@ const Ty = require("../../types") const {reg} = require("../../matrix/read-registration") const passthrough = require("../../passthrough") -const {discord, sync, db, select, from} = passthrough +const {discord, sync, db, select} = 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 dUtils = sync.require("../../discord/utils") -/** @type {import("../../m2d/converters/utils")} */ -const mUtils = sync.require("../../m2d/converters/utils") +const utils = sync.require("../../discord/utils") /** @type {import("./create-space")} */ const createSpace = sync.require("./create-space") @@ -89,10 +85,9 @@ async function channelToKState(channel, guild, di) { assert(typeof parentSpaceID === "string") } - const channelRow = select("channel_room", ["nick", "custom_avatar", "custom_topic"], {channel_id: channel.id}).get() + const channelRow = select("channel_room", ["nick", "custom_avatar"], {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 = {} @@ -119,8 +114,8 @@ async function channelToKState(channel, guild, di) { join_rules = {join_rule: PRIVACY_ENUMS.ROOM_JOIN_RULES[privacyLevel]} } - const everyonePermissions = dUtils.getPermissions([], guild.roles, undefined, channel.permission_overwrites) - const everyoneCanMentionEveryone = dUtils.hasAllPermissions(everyonePermissions, ["MentionEveryone"]) + const everyonePermissions = utils.getPermissions([], guild.roles, undefined, channel.permission_overwrites) + const everyoneCanMentionEveryone = utils.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), {}) @@ -168,8 +163,6 @@ async function channelToKState(channel, guild, di) { } } - if (hasCustomTopic) delete channelKState["m.room.topic/"] - return {spaceID: parentSpaceID, privacyLevel, channelKState} } @@ -399,7 +392,7 @@ function syncRoom(channelID) { return _syncRoom(channelID, true) } -async function unbridgeChannel(channelID) { +async function _unbridgeRoom(channelID) { /** @ts-ignore @type {DiscordTypes.APIGuildChannel} */ const channel = discord.channels.get(channelID) assert.ok(channel) @@ -414,80 +407,31 @@ async function unbridgeChannel(channelID) { async function unbridgeDeletedChannel(channel, guildID) { const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get() assert.ok(roomID) - const row = from("guild_space").join("guild_active", "guild_id").select("space_id", "autocreate").get() - assert.ok(row) + const spaceID = select("guild_space", "space_id", {guild_id: guildID}).pluck().get() + assert.ok(spaceID) - let botInRoom = true + // remove room from being a space member + await api.sendState(roomID, "m.space.parent", spaceID, {}) + await api.sendState(spaceID, "m.space.child", roomID, {}) // remove declaration that the room is bridged - try { - await api.sendState(roomID, "uk.half-shot.bridge", `moe.cadence.ooye://discord/${guildID}/${channel.id}`, {}) - } catch (e) { - if (String(e).includes("not in room")) { - botInRoom = false - } else { - throw e - } - } - - if (botInRoom && "topic" in channel) { + await api.sendState(roomID, "uk.half-shot.bridge", `moe.cadence.ooye://discord/${guildID}/${channel.id}`, {}) + if ("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) - // leave room - await api.leaveRoom(roomID) - } - - // 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) - } - } + // delete room from database + db.prepare("DELETE FROM channel_room WHERE room_id = ? AND channel_id = ?").run(roomID, channel.id) } /** @@ -536,7 +480,7 @@ module.exports.createAllForGuild = createAllForGuild module.exports.channelToKState = channelToKState module.exports.postApplyPowerLevels = postApplyPowerLevels module.exports._convertNameAndTopic = convertNameAndTopic -module.exports.unbridgeChannel = unbridgeChannel +module.exports._unbridgeRoom = _unbridgeRoom module.exports.unbridgeDeletedChannel = unbridgeDeletedChannel module.exports.existsOrAutocreatable = existsOrAutocreatable module.exports.assertExistsOrAutocreatable = assertExistsOrAutocreatable diff --git a/src/d2m/actions/create-room.test.js b/src/d2m/actions/create-room.test.js index 0376d65..a1766dd 100644 --- a/src/d2m/actions/create-room.test.js +++ b/src/d2m/actions/create-room.test.js @@ -94,26 +94,6 @@ 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"), diff --git a/src/d2m/actions/delete-message.js b/src/d2m/actions/delete-message.js index e9e0b08..bc8adfb 100644 --- a/src/d2m/actions/delete-message.js +++ b/src/d2m/actions/delete-message.js @@ -16,6 +16,7 @@ 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) @@ -34,6 +35,7 @@ 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) diff --git a/src/d2m/actions/edit-message.js b/src/d2m/actions/edit-message.js index 85b1a14..d85f925 100644 --- a/src/d2m/actions/edit-message.js +++ b/src/d2m/actions/edit-message.js @@ -61,7 +61,7 @@ async function editMessage(message, guild, row) { // 4. Send all the things. if (eventsToSend.length) { - db.prepare("INSERT OR IGNORE INTO message_channel (message_id, channel_id) VALUES (?, ?)").run(message.id, message.channel_id) + db.prepare("REPLACE INTO message_channel (message_id, channel_id) VALUES (?, ?)").run(message.id, message.channel_id) } for (const content of eventsToSend) { const eventType = content.$type diff --git a/src/d2m/actions/register-pk-user.js b/src/d2m/actions/register-pk-user.js index ce1665c..411fcf8 100644 --- a/src/d2m/actions/register-pk-user.js +++ b/src/d2m/actions/register-pk-user.js @@ -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, username, sim_name, mxid) VALUES (?, ?, ?, ?)").run(pkMessage.member.uuid, simName, simName, mxid) + db.prepare("INSERT INTO sim (user_id, sim_name, localpart, mxid) VALUES (?, ?, ?, ?)").run(pkMessage.member.uuid, simName, localpart, mxid) // Register matrix user with that name try { @@ -146,20 +146,15 @@ 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 - } + var res = await fetch(`https://api.pluralkit.me/v2/messages/${messageID}`) + if (res.ok) return res.json() // I think the backend needs some time to update. - await new Promise(resolve => setTimeout(resolve, 1500)) + await new Promise(resolve => setTimeout(resolve, 2000)) } while (++attempts < 3) - throw new Error(`PK API returned an error after ${attempts} tries: ${JSON.stringify(await errorGetter())}`) + const errorMessage = await res.json() + throw new Error(`PK API returned an error after ${attempts} tries: ${JSON.stringify(errorMessage)}`) } module.exports._memberToStateContent = memberToStateContent diff --git a/src/d2m/actions/register-user.js b/src/d2m/actions/register-user.js index c3bd16c..b914d41 100644 --- a/src/d2m/actions/register-user.js +++ b/src/d2m/actions/register-user.js @@ -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, username, sim_name, mxid) VALUES (?, ?, ?, ?)").run(user.id, user.username, simName, mxid) + db.prepare("INSERT INTO sim (user_id, sim_name, localpart, mxid) VALUES (?, ?, ?, ?)").run(user.id, simName, localpart, mxid) // Register matrix user with that name try { diff --git a/src/d2m/actions/remove-reaction.js b/src/d2m/actions/remove-reaction.js index 6f922cb..d991f08 100644 --- a/src/d2m/actions/remove-reaction.js +++ b/src/d2m/actions/remove-reaction.js @@ -53,7 +53,7 @@ async function removeReaction(data, reactions) { */ async function removeEmojiReaction(data, reactions) { const key = await emojiToKey.emojiToKey(data.emoji) - const discordPreferredEncoding = await emoji.encodeEmoji(key, undefined) + const discordPreferredEncoding = 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) diff --git a/src/d2m/actions/send-message.js b/src/d2m/actions/send-message.js index 743c15a..be785bb 100644 --- a/src/d2m/actions/send-message.js +++ b/src/d2m/actions/send-message.js @@ -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("INSERT OR IGNORE INTO message_channel (message_id, channel_id) VALUES (?, ?)").run(message.id, message.channel_id) + db.prepare("REPLACE 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) { diff --git a/src/d2m/converters/user-to-mxid.js b/src/d2m/converters/user-to-mxid.js index e0ab137..a619b36 100644 --- a/src/d2m/converters/user-to-mxid.js +++ b/src/d2m/converters/user-to-mxid.js @@ -64,7 +64,7 @@ function userToSimName(user) { } // 1. Is sim user already registered? - const existing = select("sim", "user_id", {user_id: user.id}).pluck().get() + const existing = select("sim", "sim_name", {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) diff --git a/src/d2m/discord-client.js b/src/d2m/discord-client.js index 37b3eac..bffb904 100644 --- a/src/d2m/discord-client.js +++ b/src/d2m/discord-client.js @@ -1,14 +1,10 @@ // @ts-check -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 { SnowTransfer } = require("snowtransfer") +const { Client: CloudStorm } = require("cloudstorm") const passthrough = require("../passthrough") -const {sync} = passthrough +const { sync } = passthrough /** @type {import("./discord-packets")} */ const discordPackets = sync.require("./discord-packets") diff --git a/src/db/migrate.js b/src/db/migrate.js index 46d0c14..7c1faf9 100644 --- a/src/db/migrate.js +++ b/src/db/migrate.js @@ -6,8 +6,7 @@ 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, PRIMARY KEY (filename)) WITHOUT ROWID").run() - /** @type {string} */ + db.prepare("CREATE TABLE IF NOT EXISTS migration (filename TEXT NOT NULL)").run() let progress = db.prepare("SELECT * FROM migration").pluck().get() if (!progress) { progress = "" @@ -38,8 +37,6 @@ async function migrate(db) { if (migrationRan) { console.log("Database migrations all done.") } - - db.pragma("foreign_keys = on") } module.exports.migrate = migrate diff --git a/src/db/migrations/0016-foreign-keys.sql b/src/db/migrations/0016-foreign-keys.sql deleted file mode 100644 index 7a2b26c..0000000 --- a/src/db/migrations/0016-foreign-keys.sql +++ /dev/null @@ -1,147 +0,0 @@ --- /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; diff --git a/src/db/migrations/0017-analyze.sql b/src/db/migrations/0017-analyze.sql deleted file mode 100644 index 802fca2..0000000 --- a/src/db/migrations/0017-analyze.sql +++ /dev/null @@ -1,225 +0,0 @@ --- 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; diff --git a/src/db/migrations/0018-add-custom-topic-to-channel-room.sql b/src/db/migrations/0018-add-custom-topic-to-channel-room.sql deleted file mode 100644 index c33d21c..0000000 --- a/src/db/migrations/0018-add-custom-topic-to-channel-room.sql +++ /dev/null @@ -1,5 +0,0 @@ -BEGIN TRANSACTION; - -ALTER TABLE channel_room ADD COLUMN custom_topic INTEGER DEFAULT 0; - -COMMIT; diff --git a/src/db/migrations/0019-add-invite.sql b/src/db/migrations/0019-add-invite.sql deleted file mode 100644 index 6ad03f9..0000000 --- a/src/db/migrations/0019-add-invite.sql +++ /dev/null @@ -1,13 +0,0 @@ -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; diff --git a/src/db/orm-defs.d.ts b/src/db/orm-defs.d.ts index bab3c80..c235e99 100644 --- a/src/db/orm-defs.d.ts +++ b/src/db/orm-defs.d.ts @@ -10,8 +10,6 @@ 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: { @@ -40,14 +38,6 @@ 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 diff --git a/src/discord/register-interactions.js b/src/discord/register-interactions.js index 0c5a0b0..7b1e52d 100644 --- a/src/discord/register-interactions.js +++ b/src/discord/register-interactions.js @@ -64,9 +64,7 @@ discord.snow.interaction.bulkOverwriteApplicationCommands(id, [{ }] } ] -}]).catch(e => { - console.error(e) -}) +}]) async function dispatchInteraction(interaction) { const interactionId = interaction.data.custom_id || interaction.data.name diff --git a/src/m2d/actions/add-reaction.js b/src/m2d/actions/add-reaction.js index d54bf77..cfd471b 100644 --- a/src/m2d/actions/add-reaction.js +++ b/src/m2d/actions/add-reaction.js @@ -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 = await emoji.encodeEmoji(key, event.content.shortcode) + const discordPreferredEncoding = emoji.encodeEmoji(key, event.content.shortcode) if (!discordPreferredEncoding) return await discord.snow.channel.createReaction(channelID, messageID, discordPreferredEncoding) // acting as the discord bot itself diff --git a/src/m2d/actions/redact.js b/src/m2d/actions/redact.js index 1f6cef8..5a12d5a 100644 --- a/src/m2d/actions/redact.js +++ b/src/m2d/actions/redact.js @@ -13,6 +13,7 @@ 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) diff --git a/src/m2d/actions/send-event.js b/src/m2d/actions/send-event.js index 35fcfda..0a270a0 100644 --- a/src/m2d/actions/send-event.js +++ b/src/m2d/actions/send-event.js @@ -102,13 +102,14 @@ 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("INSERT INTO message_channel (message_id, channel_id) VALUES (?, ?)").run(messageResponse.id, threadID || channelID) + db.prepare("REPLACE 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 diff --git a/src/m2d/converters/emoji.js b/src/m2d/converters/emoji.js index 63e53a9..66a690c 100644 --- a/src/m2d/converters/emoji.js +++ b/src/m2d/converters/emoji.js @@ -1,98 +1,58 @@ // @ts-check -const fsp = require("fs").promises -const {join} = require("path") -const emojisp = fsp.readFile(join(__dirname, "emojis.txt"), "utf8").then(content => content.split("\n")) +const assert = require("assert").strict +const Ty = require("../../types") const passthrough = require("../../passthrough") -const {select} = passthrough - +const {sync, select} = passthrough /** * @param {string} input * @param {string | null | undefined} shortcode * @returns {string?} */ -function encodeCustomEmoji(input, shortcode) { - // Custom emoji - let row = select("emoji", ["emoji_id", "name"], {mxc_url: input}).get() - if (!row && shortcode) { - // Use the name to try to find a known emoji with the same name. - const name = shortcode.replace(/^:|:$/g, "") - 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! - return null - } - return encodeURIComponent(`${row.name}:${row.emoji_id}`) -} - -/** - * @param {string} input - * @returns {Promise} URL encoded! - */ -async function encodeDefaultEmoji(input) { - // Default emoji - - // 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) - - // Best case scenario: they reacted with an exact replica of a valid emoji. - if (emojis.includes(input)) return encoded - - // 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} - */ -async function encodeEmoji(input, shortcode) { +function encodeEmoji(input, shortcode) { + let discordPreferredEncoding if (input.startsWith("mxc://")) { - return encodeCustomEmoji(input, shortcode) + // Custom emoji + let row = select("emoji", ["emoji_id", "name"], {mxc_url: input}).get() + if (!row && shortcode) { + // Use the name to try to find a known emoji with the same name. + const name = shortcode.replace(/^:|:$/g, "") + 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! + return null + } + // Cool, we got an exact or a candidate emoji. + discordPreferredEncoding = encodeURIComponent(`${row.name}:${row.emoji_id}`) } else { - return encodeDefaultEmoji(input) + // Default emoji + // https://github.com/discord/discord-api-docs/issues/2723#issuecomment-807022205 ???????????? + 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", // 🐟️ + ] + + discordPreferredEncoding = + ( forceTrimmedList.includes(encodedTrimmed) ? encodedTrimmed + : encodedTrimmed !== encoded && [...input].length === 2 ? encoded + : encodedTrimmed) + + console.log("add reaction from matrix:", input, encoded, encodedTrimmed, "chosen:", discordPreferredEncoding) } + return discordPreferredEncoding } module.exports.encodeEmoji = encodeEmoji diff --git a/src/m2d/converters/emoji.test.js b/src/m2d/converters/emoji.test.js deleted file mode 100644 index ad9846b..0000000 --- a/src/m2d/converters/emoji.test.js +++ /dev/null @@ -1,52 +0,0 @@ -// @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") -}) diff --git a/src/m2d/converters/emojis.txt b/src/m2d/converters/emojis.txt deleted file mode 100644 index 32c3f15..0000000 --- a/src/m2d/converters/emojis.txt +++ /dev/null @@ -1,3799 +0,0 @@ -😀 -😃 -😄 -😁 -😆 -🥹 -😅 -😂 -🤣 -🥲 -☺️ -😊 -😇 -🙂 -🙃 -😉 -😌 -😍 -🥰 -😘 -😗 -😙 -😚 -😋 -😛 -😝 -😜 -🤪 -🤨 -🧐 -🤓 -😎 -🥸 -🤩 -🥳 -😏 -😒 -😞 -😔 -😟 -😕 -🙁 -☹️ -😣 -😖 -😫 -😩 -🥺 -😢 -😭 -😤 -😠 -😡 -🤬 -🤯 -😳 -🥵 -🥶 -😶‍🌫️ -😱 -😨 -😰 -😥 -😓 -🤗 -🤔 -🫣 -🤭 -🫢 -🫡 -🤫 -🫠 -🤥 -😶 -🫥 -😐 -🫤 -😑 -🫨 -🙂‍↔️ -🙂‍↕️ -😬 -🙄 -😯 -😦 -😧 -😮 -😲 -🥱 -😴 -🤤 -😪 -😮‍💨 -😵 -😵‍💫 -🤐 -🥴 -🤢 -🤮 -🤧 -😷 -🤒 -🤕 -🤑 -🤠 -😈 -👿 -👹 -👺 -🤡 -💩 -👻 -💀 -☠️ -👽 -👾 -🤖 -🎃 -😺 -😸 -😹 -😻 -😼 -😽 -🙀 -😿 -😾 -🤝🏻 -🫱🏻‍🫲🏼 -🫱🏻‍🫲🏽 -🫱🏻‍🫲🏾 -🫱🏻‍🫲🏿 -🫱🏼‍🫲🏻 -🤝🏼 -🫱🏼‍🫲🏽 -🫱🏼‍🫲🏾 -🫱🏼‍🫲🏿 -🫱🏽‍🫲🏻 -🫱🏽‍🫲🏼 -🤝🏽 -🫱🏽‍🫲🏾 -🫱🏽‍🫲🏿 -🫱🏾‍🫲🏻 -🫱🏾‍🫲🏼 -🫱🏾‍🫲🏽 -🤝🏾 -🫱🏾‍🫲🏿 -🫱🏿‍🫲🏻 -🫱🏿‍🫲🏼 -🫱🏿‍🫲🏽 -🫱🏿‍🫲🏾 -🤝🏿 -🤝 -🫶🏻 -🫶🏼 -🫶🏽 -🫶🏾 -🫶🏿 -🫶 -🤲🏻 -🤲🏼 -🤲🏽 -🤲🏾 -🤲🏿 -🤲 -👐🏻 -👐🏼 -👐🏽 -👐🏾 -👐🏿 -👐 -🙌🏻 -🙌🏼 -🙌🏽 -🙌🏾 -🙌🏿 -🙌 -👏🏻 -👏🏼 -👏🏽 -👏🏾 -👏🏿 -👏 -👍🏻 -👍🏼 -👍🏽 -👍🏾 -👍🏿 -👍 -👎🏻 -👎🏼 -👎🏽 -👎🏾 -👎🏿 -👎 -👊🏻 -👊🏼 -👊🏽 -👊🏾 -👊🏿 -👊 -✊🏻 -✊🏼 -✊🏽 -✊🏾 -✊🏿 -✊ -🤛🏻 -🤛🏼 -🤛🏽 -🤛🏾 -🤛🏿 -🤛 -🤜🏻 -🤜🏼 -🤜🏽 -🤜🏾 -🤜🏿 -🤜 -🫷🏻 -🫷🏼 -🫷🏽 -🫷🏾 -🫷🏿 -🫷 -🫸🏻 -🫸🏼 -🫸🏽 -🫸🏾 -🫸🏿 -🫸 -🤞🏻 -🤞🏼 -🤞🏽 -🤞🏾 -🤞🏿 -🤞 -✌🏻 -✌🏼 -✌🏽 -✌🏾 -✌🏿 -✌️ -🫰🏻 -🫰🏼 -🫰🏽 -🫰🏾 -🫰🏿 -🫰 -🤟🏻 -🤟🏼 -🤟🏽 -🤟🏾 -🤟🏿 -🤟 -🤘🏻 -🤘🏼 -🤘🏽 -🤘🏾 -🤘🏿 -🤘 -👌🏻 -👌🏼 -👌🏽 -👌🏾 -👌🏿 -👌 -🤌🏼 -🤌🏻 -🤌🏽 -🤌🏾 -🤌🏿 -🤌 -🤏🏻 -🤏🏼 -🤏🏽 -🤏🏾 -🤏🏿 -🤏 -🫳🏻 -🫳🏼 -🫳🏽 -🫳🏾 -🫳🏿 -🫳 -🫴🏻 -🫴🏼 -🫴🏽 -🫴🏾 -🫴🏿 -🫴 -👈🏻 -👈🏼 -👈🏽 -👈🏾 -👈🏿 -👈 -👉🏻 -👉🏼 -👉🏽 -👉🏾 -👉🏿 -👉 -👆🏻 -👆🏼 -👆🏽 -👆🏾 -👆🏿 -👆 -👇🏻 -👇🏼 -👇🏽 -👇🏾 -👇🏿 -👇 -☝🏻 -☝🏼 -☝🏽 -☝🏾 -☝🏿 -☝️ -✋🏻 -✋🏼 -✋🏽 -✋🏾 -✋🏿 -✋ -🤚🏻 -🤚🏼 -🤚🏽 -🤚🏾 -🤚🏿 -🤚 -🖐🏻 -🖐🏼 -🖐🏽 -🖐🏾 -🖐🏿 -🖐️ -🖖🏻 -🖖🏼 -🖖🏽 -🖖🏾 -🖖🏿 -🖖 -👋🏻 -👋🏼 -👋🏽 -👋🏾 -👋🏿 -👋 -🤙🏻 -🤙🏼 -🤙🏽 -🤙🏾 -🤙🏿 -🤙 -🫲🏻 -🫲🏼 -🫲🏽 -🫲🏾 -🫲🏿 -🫲 -🫱🏻 -🫱🏼 -🫱🏽 -🫱🏾 -🫱🏿 -🫱 -💪🏻 -💪🏼 -💪🏽 -💪🏾 -💪🏿 -💪 -🦾 -🖕🏻 -🖕🏼 -🖕🏽 -🖕🏾 -🖕🏿 -🖕 -✍🏻 -✍🏼 -✍🏽 -✍🏾 -✍🏿 -✍️ -🙏🏻 -🙏🏼 -🙏🏽 -🙏🏾 -🙏🏿 -🙏 -🫵🏻 -🫵🏼 -🫵🏽 -🫵🏾 -🫵🏿 -🫵 -🦶🏻 -🦶🏼 -🦶🏽 -🦶🏾 -🦶🏿 -🦶 -🦵🏻 -🦵🏼 -🦵🏽 -🦵🏾 -🦵🏿 -🦵 -🦿 -💄 -💋 -👄 -🫦 -🦷 -👅 -👂🏻 -👂🏼 -👂🏽 -👂🏾 -👂🏿 -👂 -🦻🏻 -🦻🏼 -🦻🏽 -🦻🏾 -🦻🏿 -🦻 -👃🏻 -👃🏼 -👃🏽 -👃🏾 -👃🏿 -👃 -👣 -👁️ -👀 -🫀 -🫁 -🧠 -🗣️ -👤 -👥 -🫂 -👶🏻 -👶🏼 -👶🏽 -👶🏾 -👶🏿 -👶 -🧒🏻 -🧒🏼 -🧒🏽 -🧒🏾 -🧒🏿 -🧒 -👧🏻 -👧🏼 -👧🏽 -👧🏾 -👧🏿 -👧 -👦🏻 -👦🏼 -👦🏽 -👦🏾 -👦🏿 -👦 -🧑🏻 -🧑🏼 -🧑🏽 -🧑🏾 -🧑🏿 -🧑 -👩🏻 -👩🏼 -👩🏽 -👩🏾 -👩🏿 -👩 -👨🏻 -👨🏼 -👨🏽 -👨🏾 -👨🏿 -👨 -🧑🏻‍🦱 -🧑🏼‍🦱 -🧑🏽‍🦱 -🧑🏾‍🦱 -🧑🏿‍🦱 -🧑‍🦱 -👩🏻‍🦱 -👩🏼‍🦱 -👩🏽‍🦱 -👩🏾‍🦱 -👩🏿‍🦱 -👩‍🦱 -👨🏻‍🦱 -👨🏼‍🦱 -👨🏽‍🦱 -👨🏾‍🦱 -👨🏿‍🦱 -👨‍🦱 -🧑🏻‍🦰 -🧑🏼‍🦰 -🧑🏽‍🦰 -🧑🏾‍🦰 -🧑🏿‍🦰 -🧑‍🦰 -👩🏻‍🦰 -👩🏼‍🦰 -👩🏽‍🦰 -👩🏾‍🦰 -👩🏿‍🦰 -👩‍🦰 -👨🏻‍🦰 -👨🏼‍🦰 -👨🏽‍🦰 -👨🏾‍🦰 -👨🏿‍🦰 -👨‍🦰 -👱🏻 -👱🏼 -👱🏽 -👱🏾 -👱🏿 -👱 -👱🏻‍♀️ -👱🏼‍♀️ -👱🏽‍♀️ -👱🏾‍♀️ -👱🏿‍♀️ -👱‍♀️ -👱🏻‍♂️ -👱🏼‍♂️ -👱🏽‍♂️ -👱🏾‍♂️ -👱🏿‍♂️ -👱‍♂️ -🧑🏻‍🦳 -🧑🏼‍🦳 -🧑🏽‍🦳 -🧑🏾‍🦳 -🧑🏿‍🦳 -🧑‍🦳 -👩🏻‍🦳 -👩🏼‍🦳 -👩🏽‍🦳 -👩🏾‍🦳 -👩🏿‍🦳 -👩‍🦳 -👨🏻‍🦳 -👨🏼‍🦳 -👨🏽‍🦳 -👨🏾‍🦳 -👨🏿‍🦳 -👨‍🦳 -🧑🏻‍🦲 -🧑🏼‍🦲 -🧑🏽‍🦲 -🧑🏾‍🦲 -🧑🏿‍🦲 -🧑‍🦲 -👩🏻‍🦲 -👩🏼‍🦲 -👩🏽‍🦲 -👩🏾‍🦲 -👩🏿‍🦲 -👩‍🦲 -👨🏻‍🦲 -👨🏼‍🦲 -👨🏽‍🦲 -👨🏾‍🦲 -👨🏿‍🦲 -👨‍🦲 -🧔🏻 -🧔🏼 -🧔🏽 -🧔🏾 -🧔🏿 -🧔 -🧔🏻‍♀️ -🧔🏼‍♀️ -🧔🏽‍♀️ -🧔🏾‍♀️ -🧔🏿‍♀️ -🧔‍♀️ -🧔🏻‍♂️ -🧔🏼‍♂️ -🧔🏽‍♂️ -🧔🏾‍♂️ -🧔🏿‍♂️ -🧔‍♂️ -🧓🏻 -🧓🏼 -🧓🏽 -🧓🏾 -🧓🏿 -🧓 -👵🏻 -👵🏼 -👵🏽 -👵🏾 -👵🏿 -👵 -👴🏻 -👴🏼 -👴🏽 -👴🏾 -👴🏿 -👴 -👲🏻 -👲🏼 -👲🏽 -👲🏾 -👲🏿 -👲 -👳🏻 -👳🏼 -👳🏽 -👳🏾 -👳🏿 -👳 -👳🏻‍♀️ -👳🏼‍♀️ -👳🏽‍♀️ -👳🏾‍♀️ -👳🏿‍♀️ -👳‍♀️ -👳🏻‍♂️ -👳🏼‍♂️ -👳🏽‍♂️ -👳🏾‍♂️ -👳🏿‍♂️ -👳‍♂️ -🧕🏻 -🧕🏼 -🧕🏽 -🧕🏾 -🧕🏿 -🧕 -👮🏻 -👮🏼 -👮🏽 -👮🏾 -👮🏿 -👮 -👮🏻‍♀️ -👮🏼‍♀️ -👮🏽‍♀️ -👮🏾‍♀️ -👮🏿‍♀️ -👮‍♀️ -👮🏻‍♂️ -👮🏼‍♂️ -👮🏽‍♂️ -👮🏾‍♂️ -👮🏿‍♂️ -👮‍♂️ -👷🏻 -👷🏼 -👷🏽 -👷🏾 -👷🏿 -👷 -👷🏻‍♀️ -👷🏼‍♀️ -👷🏽‍♀️ -👷🏾‍♀️ -👷🏿‍♀️ -👷‍♀️ -👷🏻‍♂️ -👷🏼‍♂️ -👷🏽‍♂️ -👷🏾‍♂️ -👷🏿‍♂️ -👷‍♂️ -💂🏻 -💂🏼 -💂🏽 -💂🏾 -💂🏿 -💂 -💂🏻‍♀️ -💂🏼‍♀️ -💂🏽‍♀️ -💂🏾‍♀️ -💂🏿‍♀️ -💂‍♀️ -💂🏻‍♂️ -💂🏼‍♂️ -💂🏽‍♂️ -💂🏾‍♂️ -💂🏿‍♂️ -💂‍♂️ -🕵🏻 -🕵🏼 -🕵🏽 -🕵🏾 -🕵🏿 -🕵️ -🕵🏻‍♀️ -🕵🏼‍♀️ -🕵🏽‍♀️ -🕵🏾‍♀️ -🕵🏿‍♀️ -🕵️‍♀️ -🕵🏻‍♂️ -🕵🏼‍♂️ -🕵🏽‍♂️ -🕵🏾‍♂️ -🕵🏿‍♂️ -🕵️‍♂️ -🧑🏻‍⚕️ -🧑🏼‍⚕️ -🧑🏽‍⚕️ -🧑🏾‍⚕️ -🧑🏿‍⚕️ -🧑‍⚕️ -👩🏻‍⚕️ -👩🏼‍⚕️ -👩🏽‍⚕️ -👩🏾‍⚕️ -👩🏿‍⚕️ -👩‍⚕️ -👨🏻‍⚕️ -👨🏼‍⚕️ -👨🏽‍⚕️ -👨🏾‍⚕️ -👨🏿‍⚕️ -👨‍⚕️ -🧑🏻‍🌾 -🧑🏼‍🌾 -🧑🏽‍🌾 -🧑🏾‍🌾 -🧑🏿‍🌾 -🧑‍🌾 -👩🏻‍🌾 -👩🏼‍🌾 -👩🏽‍🌾 -👩🏾‍🌾 -👩🏿‍🌾 -👩‍🌾 -👨🏻‍🌾 -👨🏼‍🌾 -👨🏽‍🌾 -👨🏾‍🌾 -👨🏿‍🌾 -👨‍🌾 -🧑🏻‍🍳 -🧑🏼‍🍳 -🧑🏽‍🍳 -🧑🏾‍🍳 -🧑🏿‍🍳 -🧑‍🍳 -👩🏻‍🍳 -👩🏼‍🍳 -👩🏽‍🍳 -👩🏾‍🍳 -👩🏿‍🍳 -👩‍🍳 -👨🏻‍🍳 -👨🏼‍🍳 -👨🏽‍🍳 -👨🏾‍🍳 -👨🏿‍🍳 -👨‍🍳 -🧑🏻‍🎓 -🧑🏼‍🎓 -🧑🏽‍🎓 -🧑🏾‍🎓 -🧑🏿‍🎓 -🧑‍🎓 -👩🏻‍🎓 -👩🏼‍🎓 -👩🏽‍🎓 -👩🏾‍🎓 -👩🏿‍🎓 -👩‍🎓 -👨🏻‍🎓 -👨🏼‍🎓 -👨🏽‍🎓 -👨🏾‍🎓 -👨🏿‍🎓 -👨‍🎓 -🧑🏻‍🎤 -🧑🏼‍🎤 -🧑🏽‍🎤 -🧑🏾‍🎤 -🧑🏿‍🎤 -🧑‍🎤 -👩🏻‍🎤 -👩🏼‍🎤 -👩🏽‍🎤 -👩🏾‍🎤 -👩🏿‍🎤 -👩‍🎤 -👨🏻‍🎤 -👨🏼‍🎤 -👨🏽‍🎤 -👨🏾‍🎤 -👨🏿‍🎤 -👨‍🎤 -🧑🏻‍🏫 -🧑🏼‍🏫 -🧑🏽‍🏫 -🧑🏾‍🏫 -🧑🏿‍🏫 -🧑‍🏫 -👩🏻‍🏫 -👩🏼‍🏫 -👩🏽‍🏫 -👩🏾‍🏫 -👩🏿‍🏫 -👩‍🏫 -👨🏻‍🏫 -👨🏼‍🏫 -👨🏽‍🏫 -👨🏾‍🏫 -👨🏿‍🏫 -👨‍🏫 -🧑🏻‍🏭 -🧑🏼‍🏭 -🧑🏽‍🏭 -🧑🏾‍🏭 -🧑🏿‍🏭 -🧑‍🏭 -👩🏻‍🏭 -👩🏼‍🏭 -👩🏽‍🏭 -👩🏾‍🏭 -👩🏿‍🏭 -👩‍🏭 -👨🏻‍🏭 -👨🏼‍🏭 -👨🏽‍🏭 -👨🏾‍🏭 -👨🏿‍🏭 -👨‍🏭 -🧑🏻‍💻 -🧑🏼‍💻 -🧑🏽‍💻 -🧑🏾‍💻 -🧑🏿‍💻 -🧑‍💻 -👩🏻‍💻 -👩🏼‍💻 -👩🏽‍💻 -👩🏾‍💻 -👩🏿‍💻 -👩‍💻 -👨🏻‍💻 -👨🏼‍💻 -👨🏽‍💻 -👨🏾‍💻 -👨🏿‍💻 -👨‍💻 -🧑🏻‍💼 -🧑🏼‍💼 -🧑🏽‍💼 -🧑🏾‍💼 -🧑🏿‍💼 -🧑‍💼 -👩🏻‍💼 -👩🏼‍💼 -👩🏽‍💼 -👩🏾‍💼 -👩🏿‍💼 -👩‍💼 -👨🏻‍💼 -👨🏼‍💼 -👨🏽‍💼 -👨🏾‍💼 -👨🏿‍💼 -👨‍💼 -🧑🏻‍🔧 -🧑🏼‍🔧 -🧑🏽‍🔧 -🧑🏾‍🔧 -🧑🏿‍🔧 -🧑‍🔧 -👩🏻‍🔧 -👩🏼‍🔧 -👩🏽‍🔧 -👩🏾‍🔧 -👩🏿‍🔧 -👩‍🔧 -👨🏻‍🔧 -👨🏼‍🔧 -👨🏽‍🔧 -👨🏾‍🔧 -👨🏿‍🔧 -👨‍🔧 -🧑🏻‍🔬 -🧑🏼‍🔬 -🧑🏽‍🔬 -🧑🏾‍🔬 -🧑🏿‍🔬 -🧑‍🔬 -👩🏻‍🔬 -👩🏼‍🔬 -👩🏽‍🔬 -👩🏾‍🔬 -👩🏿‍🔬 -👩‍🔬 -👨🏻‍🔬 -👨🏼‍🔬 -👨🏽‍🔬 -👨🏾‍🔬 -👨🏿‍🔬 -👨‍🔬 -🧑🏻‍🎨 -🧑🏼‍🎨 -🧑🏽‍🎨 -🧑🏾‍🎨 -🧑🏿‍🎨 -🧑‍🎨 -👩🏻‍🎨 -👩🏼‍🎨 -👩🏽‍🎨 -👩🏾‍🎨 -👩🏿‍🎨 -👩‍🎨 -👨🏻‍🎨 -👨🏼‍🎨 -👨🏽‍🎨 -👨🏾‍🎨 -👨🏿‍🎨 -👨‍🎨 -🧑🏻‍🚒 -🧑🏼‍🚒 -🧑🏽‍🚒 -🧑🏾‍🚒 -🧑🏿‍🚒 -🧑‍🚒 -👩🏻‍🚒 -👩🏼‍🚒 -👩🏽‍🚒 -👩🏾‍🚒 -👩🏿‍🚒 -👩‍🚒 -👨🏻‍🚒 -👨🏼‍🚒 -👨🏽‍🚒 -👨🏾‍🚒 -👨🏿‍🚒 -👨‍🚒 -🧑🏻‍✈️ -🧑🏼‍✈️ -🧑🏽‍✈️ -🧑🏾‍✈️ -🧑🏿‍✈️ -🧑‍✈️ -👩🏻‍✈️ -👩🏼‍✈️ -👩🏽‍✈️ -👩🏾‍✈️ -👩🏿‍✈️ -👩‍✈️ -👨🏻‍✈️ -👨🏼‍✈️ -👨🏽‍✈️ -👨🏾‍✈️ -👨🏿‍✈️ -👨‍✈️ -🧑🏻‍🚀 -🧑🏼‍🚀 -🧑🏽‍🚀 -🧑🏾‍🚀 -🧑🏿‍🚀 -🧑‍🚀 -👩🏻‍🚀 -👩🏼‍🚀 -👩🏽‍🚀 -👩🏾‍🚀 -👩🏿‍🚀 -👩‍🚀 -👨🏻‍🚀 -👨🏼‍🚀 -👨🏽‍🚀 -👨🏾‍🚀 -👨🏿‍🚀 -👨‍🚀 -🧑🏻‍⚖️ -🧑🏼‍⚖️ -🧑🏽‍⚖️ -🧑🏾‍⚖️ -🧑🏿‍⚖️ -🧑‍⚖️ -👩🏻‍⚖️ -👩🏼‍⚖️ -👩🏽‍⚖️ -👩🏾‍⚖️ -👩🏿‍⚖️ -👩‍⚖️ -👨🏻‍⚖️ -👨🏼‍⚖️ -👨🏽‍⚖️ -👨🏾‍⚖️ -👨🏿‍⚖️ -👨‍⚖️ -👰🏻 -👰🏼 -👰🏽 -👰🏾 -👰🏿 -👰 -👰🏻‍♀️ -👰🏼‍♀️ -👰🏽‍♀️ -👰🏾‍♀️ -👰🏿‍♀️ -👰‍♀️ -👰🏻‍♂️ -👰🏼‍♂️ -👰🏽‍♂️ -👰🏾‍♂️ -👰🏿‍♂️ -👰‍♂️ -🤵🏻 -🤵🏼 -🤵🏽 -🤵🏾 -🤵🏿 -🤵 -🤵🏻‍♀️ -🤵🏼‍♀️ -🤵🏽‍♀️ -🤵🏾‍♀️ -🤵🏿‍♀️ -🤵‍♀️ -🤵🏻‍♂️ -🤵🏼‍♂️ -🤵🏽‍♂️ -🤵🏾‍♂️ -🤵🏿‍♂️ -🤵‍♂️ -🫅🏻 -🫅🏼 -🫅🏽 -🫅🏾 -🫅🏿 -🫅 -👸🏻 -👸🏼 -👸🏽 -👸🏾 -👸🏿 -👸 -🤴🏻 -🤴🏼 -🤴🏽 -🤴🏾 -🤴🏿 -🤴 -🦸🏻 -🦸🏼 -🦸🏽 -🦸🏾 -🦸🏿 -🦸 -🦸🏻‍♀️ -🦸🏼‍♀️ -🦸🏽‍♀️ -🦸🏾‍♀️ -🦸🏿‍♀️ -🦸‍♀️ -🦸🏻‍♂️ -🦸🏼‍♂️ -🦸🏽‍♂️ -🦸🏾‍♂️ -🦸🏿‍♂️ -🦸‍♂️ -🦹🏻 -🦹🏼 -🦹🏽 -🦹🏾 -🦹🏿 -🦹 -🦹🏻‍♀️ -🦹🏼‍♀️ -🦹🏽‍♀️ -🦹🏾‍♀️ -🦹🏿‍♀️ -🦹‍♀️ -🦹🏻‍♂️ -🦹🏼‍♂️ -🦹🏽‍♂️ -🦹🏾‍♂️ -🦹🏿‍♂️ -🦹‍♂️ -🥷🏻 -🥷🏼 -🥷🏽 -🥷🏾 -🥷🏿 -🥷 -🧑🏻‍🎄 -🧑🏼‍🎄 -🧑🏽‍🎄 -🧑🏾‍🎄 -🧑🏿‍🎄 -🧑‍🎄 -🤶🏻 -🤶🏼 -🤶🏽 -🤶🏾 -🤶🏿 -🤶 -🎅🏻 -🎅🏼 -🎅🏽 -🎅🏾 -🎅🏿 -🎅 -🧙🏻 -🧙🏼 -🧙🏽 -🧙🏾 -🧙🏿 -🧙 -🧙🏻‍♀️ -🧙🏼‍♀️ -🧙🏽‍♀️ -🧙🏾‍♀️ -🧙🏿‍♀️ -🧙‍♀️ -🧙🏻‍♂️ -🧙🏼‍♂️ -🧙🏽‍♂️ -🧙🏾‍♂️ -🧙🏿‍♂️ -🧙‍♂️ -🧝🏻 -🧝🏼 -🧝🏽 -🧝🏾 -🧝🏿 -🧝 -🧝🏻‍♀️ -🧝🏼‍♀️ -🧝🏽‍♀️ -🧝🏾‍♀️ -🧝🏿‍♀️ -🧝‍♀️ -🧝🏻‍♂️ -🧝🏼‍♂️ -🧝🏽‍♂️ -🧝🏾‍♂️ -🧝🏿‍♂️ -🧝‍♂️ -🧌 -🧛🏻 -🧛🏼 -🧛🏽 -🧛🏾 -🧛🏿 -🧛 -🧛🏻‍♀️ -🧛🏼‍♀️ -🧛🏽‍♀️ -🧛🏾‍♀️ -🧛🏿‍♀️ -🧛‍♀️ -🧛🏻‍♂️ -🧛🏼‍♂️ -🧛🏽‍♂️ -🧛🏾‍♂️ -🧛🏿‍♂️ -🧛‍♂️ -🧟 -🧟‍♀️ -🧟‍♂️ -🧞 -🧞‍♀️ -🧞‍♂️ -🧜🏻 -🧜🏼 -🧜🏽 -🧜🏾 -🧜🏿 -🧜 -🧜🏻‍♀️ -🧜🏼‍♀️ -🧜🏽‍♀️ -🧜🏾‍♀️ -🧜🏿‍♀️ -🧜‍♀️ -🧜🏻‍♂️ -🧜🏼‍♂️ -🧜🏽‍♂️ -🧜🏾‍♂️ -🧜🏿‍♂️ -🧜‍♂️ -🧚🏻 -🧚🏼 -🧚🏽 -🧚🏾 -🧚🏿 -🧚 -🧚🏻‍♀️ -🧚🏼‍♀️ -🧚🏽‍♀️ -🧚🏾‍♀️ -🧚🏿‍♀️ -🧚‍♀️ -🧚🏻‍♂️ -🧚🏼‍♂️ -🧚🏽‍♂️ -🧚🏾‍♂️ -🧚🏿‍♂️ -🧚‍♂️ -👼🏻 -👼🏼 -👼🏽 -👼🏾 -👼🏿 -👼 -🫄🏻 -🫄🏼 -🫄🏽 -🫄🏾 -🫄🏿 -🫄 -🤰🏻 -🤰🏼 -🤰🏽 -🤰🏾 -🤰🏿 -🤰 -🫃🏻 -🫃🏼 -🫃🏽 -🫃🏾 -🫃🏿 -🫃 -🤱🏻 -🤱🏼 -🤱🏽 -🤱🏾 -🤱🏿 -🤱 -🧑🏻‍🍼 -🧑🏼‍🍼 -🧑🏽‍🍼 -🧑🏾‍🍼 -🧑🏿‍🍼 -🧑‍🍼 -👩🏻‍🍼 -👩🏼‍🍼 -👩🏽‍🍼 -👩🏾‍🍼 -👩🏿‍🍼 -👩‍🍼 -👨🏻‍🍼 -👨🏼‍🍼 -👨🏽‍🍼 -👨🏾‍🍼 -👨🏿‍🍼 -👨‍🍼 -🙇🏻 -🙇🏼 -🙇🏽 -🙇🏾 -🙇🏿 -🙇 -🙇🏻‍♀️ -🙇🏼‍♀️ -🙇🏽‍♀️ -🙇🏾‍♀️ -🙇🏿‍♀️ -🙇‍♀️ -🙇🏻‍♂️ -🙇🏼‍♂️ -🙇🏽‍♂️ -🙇🏾‍♂️ -🙇🏿‍♂️ -🙇‍♂️ -💁🏻 -💁🏼 -💁🏽 -💁🏾 -💁🏿 -💁 -💁🏻‍♀️ -💁🏼‍♀️ -💁🏽‍♀️ -💁🏾‍♀️ -💁🏿‍♀️ -💁‍♀️ -💁🏻‍♂️ -💁🏼‍♂️ -💁🏽‍♂️ -💁🏾‍♂️ -💁🏿‍♂️ -💁‍♂️ -🙅🏻 -🙅🏼 -🙅🏽 -🙅🏾 -🙅🏿 -🙅 -🙅🏻‍♀️ -🙅🏼‍♀️ -🙅🏽‍♀️ -🙅🏾‍♀️ -🙅🏿‍♀️ -🙅‍♀️ -🙅🏻‍♂️ -🙅🏼‍♂️ -🙅🏽‍♂️ -🙅🏾‍♂️ -🙅🏿‍♂️ -🙅‍♂️ -🙆🏻 -🙆🏼 -🙆🏽 -🙆🏾 -🙆🏿 -🙆 -🙆🏻‍♀️ -🙆🏼‍♀️ -🙆🏽‍♀️ -🙆🏾‍♀️ -🙆🏿‍♀️ -🙆‍♀️ -🙆🏻‍♂️ -🙆🏼‍♂️ -🙆🏽‍♂️ -🙆🏾‍♂️ -🙆🏿‍♂️ -🙆‍♂️ -🙋🏻 -🙋🏼 -🙋🏽 -🙋🏾 -🙋🏿 -🙋 -🙋🏻‍♀️ -🙋🏼‍♀️ -🙋🏽‍♀️ -🙋🏾‍♀️ -🙋🏿‍♀️ -🙋‍♀️ -🙋🏻‍♂️ -🙋🏼‍♂️ -🙋🏽‍♂️ -🙋🏾‍♂️ -🙋🏿‍♂️ -🙋‍♂️ -🧏🏻 -🧏🏼 -🧏🏽 -🧏🏾 -🧏🏿 -🧏 -🧏🏻‍♀️ -🧏🏼‍♀️ -🧏🏽‍♀️ -🧏🏾‍♀️ -🧏🏿‍♀️ -🧏‍♀️ -🧏🏻‍♂️ -🧏🏼‍♂️ -🧏🏽‍♂️ -🧏🏾‍♂️ -🧏🏿‍♂️ -🧏‍♂️ -🤦🏻 -🤦🏼 -🤦🏽 -🤦🏾 -🤦🏿 -🤦 -🤦🏻‍♀️ -🤦🏼‍♀️ -🤦🏽‍♀️ -🤦🏾‍♀️ -🤦🏿‍♀️ -🤦‍♀️ -🤦🏻‍♂️ -🤦🏼‍♂️ -🤦🏽‍♂️ -🤦🏾‍♂️ -🤦🏿‍♂️ -🤦‍♂️ -🤷🏻 -🤷🏼 -🤷🏽 -🤷🏾 -🤷🏿 -🤷 -🤷🏻‍♀️ -🤷🏼‍♀️ -🤷🏽‍♀️ -🤷🏾‍♀️ -🤷🏿‍♀️ -🤷‍♀️ -🤷🏻‍♂️ -🤷🏼‍♂️ -🤷🏽‍♂️ -🤷🏾‍♂️ -🤷🏿‍♂️ -🤷‍♂️ -🙎🏻 -🙎🏼 -🙎🏽 -🙎🏾 -🙎🏿 -🙎 -🙎🏻‍♀️ -🙎🏼‍♀️ -🙎🏽‍♀️ -🙎🏾‍♀️ -🙎🏿‍♀️ -🙎‍♀️ -🙎🏻‍♂️ -🙎🏼‍♂️ -🙎🏽‍♂️ -🙎🏾‍♂️ -🙎🏿‍♂️ -🙎‍♂️ -🙍🏻 -🙍🏼 -🙍🏽 -🙍🏾 -🙍🏿 -🙍 -🙍🏻‍♀️ -🙍🏼‍♀️ -🙍🏽‍♀️ -🙍🏾‍♀️ -🙍🏿‍♀️ -🙍‍♀️ -🙍🏻‍♂️ -🙍🏼‍♂️ -🙍🏽‍♂️ -🙍🏾‍♂️ -🙍🏿‍♂️ -🙍‍♂️ -💇🏻 -💇🏼 -💇🏽 -💇🏾 -💇🏿 -💇 -💇🏻‍♀️ -💇🏼‍♀️ -💇🏽‍♀️ -💇🏾‍♀️ -💇🏿‍♀️ -💇‍♀️ -💇🏻‍♂️ -💇🏼‍♂️ -💇🏽‍♂️ -💇🏾‍♂️ -💇🏿‍♂️ -💇‍♂️ -💆🏻 -💆🏼 -💆🏽 -💆🏾 -💆🏿 -💆 -💆🏻‍♀️ -💆🏼‍♀️ -💆🏽‍♀️ -💆🏾‍♀️ -💆🏿‍♀️ -💆‍♀️ -💆🏻‍♂️ -💆🏼‍♂️ -💆🏽‍♂️ -💆🏾‍♂️ -💆🏿‍♂️ -💆‍♂️ -🧖🏻 -🧖🏼 -🧖🏽 -🧖🏾 -🧖🏿 -🧖 -🧖🏻‍♀️ -🧖🏼‍♀️ -🧖🏽‍♀️ -🧖🏾‍♀️ -🧖🏿‍♀️ -🧖‍♀️ -🧖🏻‍♂️ -🧖🏼‍♂️ -🧖🏽‍♂️ -🧖🏾‍♂️ -🧖🏿‍♂️ -🧖‍♂️ -💅🏻 -💅🏼 -💅🏽 -💅🏾 -💅🏿 -💅 -🤳🏻 -🤳🏼 -🤳🏽 -🤳🏾 -🤳🏿 -🤳 -💃🏻 -💃🏼 -💃🏽 -💃🏾 -💃🏿 -💃 -🕺🏻 -🕺🏼 -🕺🏽 -🕺🏿 -🕺🏾 -🕺 -👯 -👯‍♀️ -👯‍♂️ -🕴🏻 -🕴🏼 -🕴🏽 -🕴🏾 -🕴🏿 -🕴️ -🧑🏻‍🦽 -🧑🏼‍🦽 -🧑🏽‍🦽 -🧑🏾‍🦽 -🧑🏿‍🦽 -🧑‍🦽 -👩🏻‍🦽 -👩🏼‍🦽 -👩🏽‍🦽 -👩🏾‍🦽 -👩🏿‍🦽 -👩‍🦽 -👨🏻‍🦽 -👨🏼‍🦽 -👨🏽‍🦽 -👨🏾‍🦽 -👨🏿‍🦽 -👨‍🦽 -🧑🏻‍🦽‍➡️ -🧑🏼‍🦽‍➡️ -🧑🏽‍🦽‍➡️ -🧑🏾‍🦽‍➡️ -🧑🏿‍🦽‍➡️ -🧑‍🦽‍➡️ -👨🏼‍🦽‍➡️ -👨🏻‍🦽‍➡️ -👨🏽‍🦽‍➡️ -👨🏾‍🦽‍➡️ -👨🏿‍🦽‍➡️ -👨‍🦽‍➡️ -👩🏻‍🦽‍➡️ -👩🏼‍🦽‍➡️ -👩🏽‍🦽‍➡️ -👩🏾‍🦽‍➡️ -👩🏿‍🦽‍➡️ -👩‍🦽‍➡️ -🧑🏻‍🦼 -🧑🏼‍🦼 -🧑🏽‍🦼 -🧑🏾‍🦼 -🧑🏿‍🦼 -🧑‍🦼 -👩🏻‍🦼 -👩🏼‍🦼 -👩🏽‍🦼 -👩🏾‍🦼 -👩🏿‍🦼 -👩‍🦼 -👨🏻‍🦼 -👨🏼‍🦼 -👨🏽‍🦼 -👨🏾‍🦼 -👨🏿‍🦼 -👨‍🦼 -🧑🏻‍🦼‍➡️ -🧑🏼‍🦼‍➡️ -🧑🏽‍🦼‍➡️ -🧑🏾‍🦼‍➡️ -🧑🏿‍🦼‍➡️ -🧑‍🦼‍➡️ -👨🏻‍🦼‍➡️ -👨🏼‍🦼‍➡️ -👨🏽‍🦼‍➡️ -👨🏾‍🦼‍➡️ -👨🏿‍🦼‍➡️ -👨‍🦼‍➡️ -👩🏻‍🦼‍➡️ -👩🏼‍🦼‍➡️ -👩🏽‍🦼‍➡️ -👩🏾‍🦼‍➡️ -👩🏿‍🦼‍➡️ -👩‍🦼‍➡️ -🚶🏻 -🚶🏼 -🚶🏽 -🚶🏾 -🚶🏿 -🚶 -🚶🏻‍♀️ -🚶🏼‍♀️ -🚶🏽‍♀️ -🚶🏾‍♀️ -🚶🏿‍♀️ -🚶‍♀️ -🚶🏻‍♂️ -🚶🏼‍♂️ -🚶🏽‍♂️ -🚶🏾‍♂️ -🚶🏿‍♂️ -🚶‍♂️ -🚶🏻‍➡️ -🚶🏼‍➡️ -🚶🏽‍➡️ -🚶🏾‍➡️ -🚶🏿‍➡️ -🚶‍➡️ -🚶🏻‍♀️‍➡️ -🚶🏼‍♀️‍➡️ -🚶🏽‍♀️‍➡️ -🚶🏾‍♀️‍➡️ -🚶🏿‍♀️‍➡️ -🚶‍♀️‍➡️ -🚶🏻‍♂️‍➡️ -🚶🏼‍♂️‍➡️ -🚶🏽‍♂️‍➡️ -🚶🏾‍♂️‍➡️ -🚶🏿‍♂️‍➡️ -🚶‍♂️‍➡️ -🧑🏻‍🦯 -🧑🏼‍🦯 -🧑🏽‍🦯 -🧑🏾‍🦯 -🧑🏿‍🦯 -🧑‍🦯 -👩🏻‍🦯 -👩🏼‍🦯 -👩🏽‍🦯 -👩🏾‍🦯 -👩🏿‍🦯 -👩‍🦯 -👨🏻‍🦯 -👨🏼‍🦯 -👨🏽‍🦯 -👨🏾‍🦯 -👨🏿‍🦯 -👨‍🦯 -🧑🏻‍🦯‍➡️ -🧑🏼‍🦯‍➡️ -🧑🏽‍🦯‍➡️ -🧑🏾‍🦯‍➡️ -🧑🏿‍🦯‍➡️ -🧑‍🦯‍➡️ -👨🏻‍🦯‍➡️ -👨🏼‍🦯‍➡️ -👨🏽‍🦯‍➡️ -👨🏾‍🦯‍➡️ -👨🏿‍🦯‍➡️ -👨‍🦯‍➡️ -👩🏻‍🦯‍➡️ -👩🏼‍🦯‍➡️ -👩🏽‍🦯‍➡️ -👩🏾‍🦯‍➡️ -👩🏿‍🦯‍➡️ -👩‍🦯‍➡️ -🧎🏻 -🧎🏼 -🧎🏽 -🧎🏾 -🧎🏿 -🧎 -🧎🏻‍♀️ -🧎🏼‍♀️ -🧎🏽‍♀️ -🧎🏾‍♀️ -🧎🏿‍♀️ -🧎‍♀️ -🧎🏻‍♂️ -🧎🏼‍♂️ -🧎🏽‍♂️ -🧎🏾‍♂️ -🧎🏿‍♂️ -🧎‍♂️ -🧎🏻‍➡️ -🧎🏼‍➡️ -🧎🏽‍➡️ -🧎🏾‍➡️ -🧎🏿‍➡️ -🧎‍➡️ -🧎🏻‍♀️‍➡️ -🧎🏼‍♀️‍➡️ -🧎🏽‍♀️‍➡️ -🧎🏾‍♀️‍➡️ -🧎🏿‍♀️‍➡️ -🧎‍♀️‍➡️ -🧎🏻‍♂️‍➡️ -🧎🏼‍♂️‍➡️ -🧎🏽‍♂️‍➡️ -🧎🏾‍♂️‍➡️ -🧎🏿‍♂️‍➡️ -🧎‍♂️‍➡️ -🏃🏻 -🏃🏼 -🏃🏽 -🏃🏾 -🏃🏿 -🏃 -🏃🏻‍♀️ -🏃🏼‍♀️ -🏃🏽‍♀️ -🏃🏾‍♀️ -🏃🏿‍♀️ -🏃‍♀️ -🏃🏻‍♂️ -🏃🏼‍♂️ -🏃🏽‍♂️ -🏃🏾‍♂️ -🏃🏿‍♂️ -🏃‍♂️ -🏃🏻‍➡️ -🏃🏼‍➡️ -🏃🏽‍➡️ -🏃🏾‍➡️ -🏃🏿‍➡️ -🏃‍➡️ -🏃🏻‍♀️‍➡️ -🏃🏼‍♀️‍➡️ -🏃🏽‍♀️‍➡️ -🏃🏾‍♀️‍➡️ -🏃🏿‍♀️‍➡️ -🏃‍♀️‍➡️ -🏃🏻‍♂️‍➡️ -🏃🏼‍♂️‍➡️ -🏃🏽‍♂️‍➡️ -🏃🏾‍♂️‍➡️ -🏃🏿‍♂️‍➡️ -🏃‍♂️‍➡️ -🧍🏻 -🧍🏼 -🧍🏽 -🧍🏾 -🧍🏿 -🧍 -🧍🏻‍♀️ -🧍🏼‍♀️ -🧍🏽‍♀️ -🧍🏾‍♀️ -🧍🏿‍♀️ -🧍‍♀️ -🧍🏻‍♂️ -🧍🏼‍♂️ -🧍🏽‍♂️ -🧍🏾‍♂️ -🧍🏿‍♂️ -🧍‍♂️ -🧑🏻‍🤝‍🧑🏻 -🧑🏻‍🤝‍🧑🏼 -🧑🏻‍🤝‍🧑🏽 -🧑🏻‍🤝‍🧑🏾 -🧑🏻‍🤝‍🧑🏿 -🧑🏼‍🤝‍🧑🏻 -🧑🏼‍🤝‍🧑🏼 -🧑🏼‍🤝‍🧑🏽 -🧑🏼‍🤝‍🧑🏾 -🧑🏼‍🤝‍🧑🏿 -🧑🏽‍🤝‍🧑🏻 -🧑🏽‍🤝‍🧑🏼 -🧑🏽‍🤝‍🧑🏽 -🧑🏽‍🤝‍🧑🏾 -🧑🏽‍🤝‍🧑🏿 -🧑🏾‍🤝‍🧑🏻 -🧑🏾‍🤝‍🧑🏼 -🧑🏾‍🤝‍🧑🏽 -🧑🏾‍🤝‍🧑🏾 -🧑🏾‍🤝‍🧑🏿 -🧑🏿‍🤝‍🧑🏻 -🧑🏿‍🤝‍🧑🏼 -🧑🏿‍🤝‍🧑🏽 -🧑🏿‍🤝‍🧑🏾 -🧑🏿‍🤝‍🧑🏿 -🧑‍🤝‍🧑 -👫🏻 -👩🏻‍🤝‍👨🏼 -👩🏻‍🤝‍👨🏽 -👩🏻‍🤝‍👨🏾 -👩🏻‍🤝‍👨🏿 -👩🏼‍🤝‍👨🏻 -👫🏼 -👩🏼‍🤝‍👨🏽 -👩🏼‍🤝‍👨🏾 -👩🏼‍🤝‍👨🏿 -👩🏽‍🤝‍👨🏻 -👩🏽‍🤝‍👨🏼 -👫🏽 -👩🏽‍🤝‍👨🏾 -👩🏽‍🤝‍👨🏿 -👩🏾‍🤝‍👨🏻 -👩🏾‍🤝‍👨🏼 -👩🏾‍🤝‍👨🏽 -👫🏾 -👩🏾‍🤝‍👨🏿 -👩🏿‍🤝‍👨🏻 -👩🏿‍🤝‍👨🏼 -👩🏿‍🤝‍👨🏽 -👩🏿‍🤝‍👨🏾 -👫🏿 -👫 -👭🏻 -👩🏻‍🤝‍👩🏼 -👩🏻‍🤝‍👩🏽 -👩🏻‍🤝‍👩🏾 -👩🏻‍🤝‍👩🏿 -👩🏼‍🤝‍👩🏻 -👭🏼 -👩🏼‍🤝‍👩🏽 -👩🏼‍🤝‍👩🏾 -👩🏼‍🤝‍👩🏿 -👩🏽‍🤝‍👩🏻 -👩🏽‍🤝‍👩🏼 -👭🏽 -👩🏽‍🤝‍👩🏾 -👩🏽‍🤝‍👩🏿 -👩🏾‍🤝‍👩🏻 -👩🏾‍🤝‍👩🏼 -👩🏾‍🤝‍👩🏽 -👭🏾 -👩🏾‍🤝‍👩🏿 -👩🏿‍🤝‍👩🏻 -👩🏿‍🤝‍👩🏼 -👩🏿‍🤝‍👩🏽 -👩🏿‍🤝‍👩🏾 -👭🏿 -👭 -👬🏻 -👨🏻‍🤝‍👨🏼 -👨🏻‍🤝‍👨🏽 -👨🏻‍🤝‍👨🏾 -👨🏻‍🤝‍👨🏿 -👨🏼‍🤝‍👨🏻 -👬🏼 -👨🏼‍🤝‍👨🏽 -👨🏼‍🤝‍👨🏾 -👨🏼‍🤝‍👨🏿 -👨🏽‍🤝‍👨🏻 -👨🏽‍🤝‍👨🏼 -👬🏽 -👨🏽‍🤝‍👨🏾 -👨🏽‍🤝‍👨🏿 -👨🏾‍🤝‍👨🏻 -👨🏾‍🤝‍👨🏼 -👨🏾‍🤝‍👨🏽 -👬🏾 -👨🏾‍🤝‍👨🏿 -👨🏿‍🤝‍👨🏻 -👨🏿‍🤝‍👨🏼 -👨🏿‍🤝‍👨🏽 -👨🏿‍🤝‍👨🏾 -👬🏿 -👬 -💑🏻 -🧑🏻‍❤️‍🧑🏼 -🧑🏻‍❤️‍🧑🏽 -🧑🏻‍❤️‍🧑🏾 -🧑🏻‍❤️‍🧑🏿 -🧑🏼‍❤️‍🧑🏻 -💑🏼 -🧑🏼‍❤️‍🧑🏽 -🧑🏼‍❤️‍🧑🏾 -🧑🏼‍❤️‍🧑🏿 -🧑🏽‍❤️‍🧑🏻 -🧑🏽‍❤️‍🧑🏼 -💑🏽 -🧑🏽‍❤️‍🧑🏾 -🧑🏽‍❤️‍🧑🏿 -🧑🏾‍❤️‍🧑🏻 -🧑🏾‍❤️‍🧑🏼 -🧑🏾‍❤️‍🧑🏽 -💑🏾 -🧑🏾‍❤️‍🧑🏿 -🧑🏿‍❤️‍🧑🏻 -🧑🏿‍❤️‍🧑🏼 -🧑🏿‍❤️‍🧑🏽 -🧑🏿‍❤️‍🧑🏾 -💑🏿 -💑 -👩🏻‍❤️‍👨🏻 -👩🏻‍❤️‍👨🏼 -👩🏻‍❤️‍👨🏽 -👩🏻‍❤️‍👨🏾 -👩🏻‍❤️‍👨🏿 -👩🏼‍❤️‍👨🏻 -👩🏼‍❤️‍👨🏼 -👩🏼‍❤️‍👨🏽 -👩🏼‍❤️‍👨🏾 -👩🏼‍❤️‍👨🏿 -👩🏽‍❤️‍👨🏻 -👩🏽‍❤️‍👨🏼 -👩🏽‍❤️‍👨🏽 -👩🏽‍❤️‍👨🏾 -👩🏽‍❤️‍👨🏿 -👩🏾‍❤️‍👨🏻 -👩🏾‍❤️‍👨🏼 -👩🏾‍❤️‍👨🏽 -👩🏾‍❤️‍👨🏾 -👩🏾‍❤️‍👨🏿 -👩🏿‍❤️‍👨🏻 -👩🏿‍❤️‍👨🏼 -👩🏿‍❤️‍👨🏽 -👩🏿‍❤️‍👨🏾 -👩🏿‍❤️‍👨🏿 -👩‍❤️‍👨 -👩🏻‍❤️‍👩🏻 -👩🏻‍❤️‍👩🏼 -👩🏻‍❤️‍👩🏽 -👩🏻‍❤️‍👩🏾 -👩🏻‍❤️‍👩🏿 -👩🏼‍❤️‍👩🏻 -👩🏼‍❤️‍👩🏼 -👩🏼‍❤️‍👩🏽 -👩🏼‍❤️‍👩🏾 -👩🏼‍❤️‍👩🏿 -👩🏽‍❤️‍👩🏻 -👩🏽‍❤️‍👩🏼 -👩🏽‍❤️‍👩🏽 -👩🏽‍❤️‍👩🏾 -👩🏽‍❤️‍👩🏿 -👩🏾‍❤️‍👩🏻 -👩🏾‍❤️‍👩🏼 -👩🏾‍❤️‍👩🏽 -👩🏾‍❤️‍👩🏾 -👩🏾‍❤️‍👩🏿 -👩🏿‍❤️‍👩🏻 -👩🏿‍❤️‍👩🏼 -👩🏿‍❤️‍👩🏽 -👩🏿‍❤️‍👩🏾 -👩🏿‍❤️‍👩🏿 -👩‍❤️‍👩 -👨🏻‍❤️‍👨🏻 -👨🏻‍❤️‍👨🏼 -👨🏻‍❤️‍👨🏽 -👨🏻‍❤️‍👨🏾 -👨🏻‍❤️‍👨🏿 -👨🏼‍❤️‍👨🏻 -👨🏼‍❤️‍👨🏼 -👨🏼‍❤️‍👨🏽 -👨🏼‍❤️‍👨🏾 -👨🏼‍❤️‍👨🏿 -👨🏽‍❤️‍👨🏻 -👨🏽‍❤️‍👨🏼 -👨🏽‍❤️‍👨🏽 -👨🏽‍❤️‍👨🏾 -👨🏽‍❤️‍👨🏿 -👨🏾‍❤️‍👨🏻 -👨🏾‍❤️‍👨🏼 -👨🏾‍❤️‍👨🏽 -👨🏾‍❤️‍👨🏾 -👨🏾‍❤️‍👨🏿 -👨🏿‍❤️‍👨🏻 -👨🏿‍❤️‍👨🏼 -👨🏿‍❤️‍👨🏽 -👨🏿‍❤️‍👨🏾 -👨🏿‍❤️‍👨🏿 -👨‍❤️‍👨 -💏🏻 -🧑🏻‍❤️‍💋‍🧑🏼 -🧑🏻‍❤️‍💋‍🧑🏽 -🧑🏻‍❤️‍💋‍🧑🏾 -🧑🏻‍❤️‍💋‍🧑🏿 -🧑🏼‍❤️‍💋‍🧑🏻 -💏🏼 -🧑🏼‍❤️‍💋‍🧑🏽 -🧑🏼‍❤️‍💋‍🧑🏾 -🧑🏼‍❤️‍💋‍🧑🏿 -🧑🏽‍❤️‍💋‍🧑🏻 -🧑🏽‍❤️‍💋‍🧑🏼 -💏🏽 -🧑🏽‍❤️‍💋‍🧑🏾 -🧑🏽‍❤️‍💋‍🧑🏿 -🧑🏾‍❤️‍💋‍🧑🏻 -🧑🏾‍❤️‍💋‍🧑🏼 -🧑🏾‍❤️‍💋‍🧑🏽 -💏🏾 -🧑🏾‍❤️‍💋‍🧑🏿 -🧑🏿‍❤️‍💋‍🧑🏻 -🧑🏿‍❤️‍💋‍🧑🏼 -🧑🏿‍❤️‍💋‍🧑🏽 -🧑🏿‍❤️‍💋‍🧑🏾 -💏🏿 -💏 -👩🏻‍❤️‍💋‍👨🏻 -👩🏻‍❤️‍💋‍👨🏼 -👩🏻‍❤️‍💋‍👨🏽 -👩🏻‍❤️‍💋‍👨🏾 -👩🏻‍❤️‍💋‍👨🏿 -👩🏼‍❤️‍💋‍👨🏻 -👩🏼‍❤️‍💋‍👨🏼 -👩🏼‍❤️‍💋‍👨🏽 -👩🏼‍❤️‍💋‍👨🏾 -👩🏼‍❤️‍💋‍👨🏿 -👩🏽‍❤️‍💋‍👨🏻 -👩🏽‍❤️‍💋‍👨🏼 -👩🏽‍❤️‍💋‍👨🏽 -👩🏽‍❤️‍💋‍👨🏾 -👩🏽‍❤️‍💋‍👨🏿 -👩🏾‍❤️‍💋‍👨🏻 -👩🏾‍❤️‍💋‍👨🏼 -👩🏾‍❤️‍💋‍👨🏽 -👩🏾‍❤️‍💋‍👨🏾 -👩🏾‍❤️‍💋‍👨🏿 -👩🏿‍❤️‍💋‍👨🏻 -👩🏿‍❤️‍💋‍👨🏼 -👩🏿‍❤️‍💋‍👨🏽 -👩🏿‍❤️‍💋‍👨🏾 -👩🏿‍❤️‍💋‍👨🏿 -👩‍❤️‍💋‍👨 -👩🏻‍❤️‍💋‍👩🏻 -👩🏻‍❤️‍💋‍👩🏼 -👩🏻‍❤️‍💋‍👩🏽 -👩🏻‍❤️‍💋‍👩🏾 -👩🏻‍❤️‍💋‍👩🏿 -👩🏼‍❤️‍💋‍👩🏻 -👩🏼‍❤️‍💋‍👩🏼 -👩🏼‍❤️‍💋‍👩🏽 -👩🏼‍❤️‍💋‍👩🏾 -👩🏼‍❤️‍💋‍👩🏿 -👩🏽‍❤️‍💋‍👩🏻 -👩🏽‍❤️‍💋‍👩🏼 -👩🏽‍❤️‍💋‍👩🏽 -👩🏽‍❤️‍💋‍👩🏾 -👩🏽‍❤️‍💋‍👩🏿 -👩🏾‍❤️‍💋‍👩🏻 -👩🏾‍❤️‍💋‍👩🏼 -👩🏾‍❤️‍💋‍👩🏽 -👩🏾‍❤️‍💋‍👩🏾 -👩🏾‍❤️‍💋‍👩🏿 -👩🏿‍❤️‍💋‍👩🏻 -👩🏿‍❤️‍💋‍👩🏼 -👩🏿‍❤️‍💋‍👩🏽 -👩🏿‍❤️‍💋‍👩🏾 -👩🏿‍❤️‍💋‍👩🏿 -👩‍❤️‍💋‍👩 -👨🏻‍❤️‍💋‍👨🏻 -👨🏻‍❤️‍💋‍👨🏼 -👨🏻‍❤️‍💋‍👨🏽 -👨🏻‍❤️‍💋‍👨🏾 -👨🏻‍❤️‍💋‍👨🏿 -👨🏼‍❤️‍💋‍👨🏻 -👨🏼‍❤️‍💋‍👨🏼 -👨🏼‍❤️‍💋‍👨🏽 -👨🏼‍❤️‍💋‍👨🏾 -👨🏼‍❤️‍💋‍👨🏿 -👨🏽‍❤️‍💋‍👨🏻 -👨🏽‍❤️‍💋‍👨🏼 -👨🏽‍❤️‍💋‍👨🏽 -👨🏽‍❤️‍💋‍👨🏾 -👨🏽‍❤️‍💋‍👨🏿 -👨🏾‍❤️‍💋‍👨🏻 -👨🏾‍❤️‍💋‍👨🏼 -👨🏾‍❤️‍💋‍👨🏽 -👨🏾‍❤️‍💋‍👨🏾 -👨🏾‍❤️‍💋‍👨🏿 -👨🏿‍❤️‍💋‍👨🏻 -👨🏿‍❤️‍💋‍👨🏼 -👨🏿‍❤️‍💋‍👨🏽 -👨🏿‍❤️‍💋‍👨🏾 -👨🏿‍❤️‍💋‍👨🏿 -👨‍❤️‍💋‍👨 -🧑‍🧑‍🧒‍🧒 -🧑‍🧑‍🧒 -🧑‍🧒‍🧒 -🧑‍🧒 -👪 -👨‍👩‍👦 -👨‍👩‍👧 -👨‍👩‍👧‍👦 -👨‍👩‍👦‍👦 -👨‍👩‍👧‍👧 -👩‍👩‍👦 -👩‍👩‍👧 -👩‍👩‍👧‍👦 -👩‍👩‍👦‍👦 -👩‍👩‍👧‍👧 -👨‍👨‍👦 -👨‍👨‍👧 -👨‍👨‍👧‍👦 -👨‍👨‍👦‍👦 -👨‍👨‍👧‍👧 -👩‍👦 -👩‍👧 -👩‍👧‍👦 -👩‍👦‍👦 -👩‍👧‍👧 -👨‍👦 -👨‍👧 -👨‍👧‍👦 -👨‍👦‍👦 -👨‍👧‍👧 -🪢 -🧶 -🧵 -🪡 -🧥 -🥼 -🦺 -👚 -👕 -👖 -🩲 -🩳 -👔 -👗 -👙 -🩱 -👘 -🥻 -🩴 -🥿 -👠 -👡 -👢 -👞 -👟 -🥾 -🧦 -🧤 -🧣 -🎩 -🧢 -👒 -🎓 -⛑️ -🪖 -👑 -💍 -👝 -👛 -👜 -💼 -🎒 -🧳 -👓 -🕶️ -🥽 -🌂 -🐶 -🐱 -🐭 -🐹 -🐰 -🦊 -🐻 -🐼 -🐻‍❄️ -🐨 -🐯 -🦁 -🐮 -🐷 -🐽 -🐸 -🐵 -🙈 -🙉 -🙊 -🐒 -🐔 -🐧 -🐦 -🐤 -🐣 -🐥 -🪿 -🦆 -🐦‍⬛ -🦅 -🦉 -🦇 -🐺 -🐗 -🐴 -🦄 -🫎 -🐝 -🪱 -🐛 -🦋 -🐌 -🐞 -🐜 -🪰 -🪲 -🪳 -🦟 -🦗 -🕷️ -🕸️ -🦂 -🐢 -🐍 -🦎 -🦖 -🦕 -🐙 -🦑 -🪼 -🦐 -🦞 -🦀 -🐡 -🐠 -🐟 -🐬 -🐳 -🐋 -🦈 -🦭 -🐊 -🐅 -🐆 -🦓 -🦍 -🦧 -🦣 -🐘 -🦛 -🦏 -🐪 -🐫 -🦒 -🦘 -🦬 -🐃 -🐂 -🐄 -🫏 -🐎 -🐖 -🐏 -🐑 -🦙 -🐐 -🦌 -🐕 -🐩 -🦮 -🐕‍🦺 -🐈 -🐈‍⬛ -🪶 -🪽 -🐓 -🦃 -🦤 -🦚 -🦜 -🦢 -🦩 -🕊️ -🐇 -🦝 -🦨 -🦡 -🦫 -🦦 -🦥 -🐁 -🐀 -🐿️ -🦔 -🐾 -🐉 -🐲 -🐦‍🔥 -🌵 -🎄 -🌲 -🌳 -🌴 -🪵 -🌱 -🌿 -☘️ -🍀 -🎍 -🪴 -🎋 -🍃 -🍂 -🍁 -🪺 -🪹 -🍄 -🍄‍🟫 -🐚 -🪸 -🪨 -🌾 -💐 -🌷 -🌹 -🥀 -🪻 -🪷 -🌺 -🌸 -🌼 -🌻 -🌞 -🌝 -🌛 -🌜 -🌚 -🌕 -🌖 -🌗 -🌘 -🌑 -🌒 -🌓 -🌔 -🌙 -🌎 -🌍 -🌏 -🪐 -💫 -⭐ -🌟 -✨ -⚡ -☄️ -💥 -🔥 -🌪️ -🌈 -☀️ -🌤️ -⛅ -🌥️ -☁️ -🌦️ -🌧️ -⛈️ -🌩️ -🌨️ -❄️ -☃️ -⛄ -🌬️ -💨 -💧 -💦 -🫧 -☔ -☂️ -🌊 -🌫️ -🍏 -🍎 -🍐 -🍊 -🍋 -🍋‍🟩 -🍌 -🍉 -🍇 -🍓 -🫐 -🍈 -🍒 -🍑 -🥭 -🍍 -🥥 -🥝 -🍅 -🍆 -🥑 -🫛 -🥦 -🥬 -🥒 -🌶️ -🫑 -🌽 -🥕 -🫒 -🧄 -🧅 -🥔 -🍠 -🫚 -🥐 -🥯 -🍞 -🥖 -🥨 -🧀 -🥚 -🍳 -🧈 -🥞 -🧇 -🥓 -🥩 -🍗 -🍖 -🦴 -🌭 -🍔 -🍟 -🍕 -🫓 -🥪 -🥙 -🧆 -🌮 -🌯 -🫔 -🥗 -🥘 -🫕 -🥫 -🫙 -🍝 -🍜 -🍲 -🍛 -🍣 -🍱 -🥟 -🦪 -🍤 -🍙 -🍚 -🍘 -🍥 -🥠 -🥮 -🍢 -🍡 -🍧 -🍨 -🍦 -🥧 -🧁 -🍰 -🎂 -🍮 -🍭 -🍬 -🍫 -🍿 -🍩 -🍪 -🌰 -🥜 -🫘 -🍯 -🥛 -🫗 -🍼 -🫖 -☕ -🍵 -🧉 -🧃 -🥤 -🧋 -🍶 -🍺 -🍻 -🥂 -🍷 -🥃 -🍸 -🍹 -🍾 -🧊 -🥄 -🍴 -🍽️ -🥣 -🥡 -🥢 -🧂 -⚽ -🏀 -🏈 -⚾ -🥎 -🎾 -🏐 -🏉 -🥏 -🎱 -🪀 -🏓 -🏸 -🏒 -🏑 -🥍 -🏏 -🪃 -🥅 -⛳ -🪁 -🛝 -🏹 -🎣 -🤿 -🥊 -🥋 -🎽 -🛹 -🛼 -🛷 -⛸️ -🥌 -🎿 -⛷️ -🏂🏻 -🏂🏼 -🏂🏽 -🏂🏾 -🏂🏿 -🏂 -🪂 -🏋🏻 -🏋🏼 -🏋🏽 -🏋🏾 -🏋🏿 -🏋️ -🏋🏻‍♀️ -🏋🏼‍♀️ -🏋🏽‍♀️ -🏋🏾‍♀️ -🏋🏿‍♀️ -🏋️‍♀️ -🏋🏻‍♂️ -🏋🏼‍♂️ -🏋🏽‍♂️ -🏋🏾‍♂️ -🏋🏿‍♂️ -🏋️‍♂️ -🤼 -🤼‍♀️ -🤼‍♂️ -🤸🏻 -🤸🏼 -🤸🏽 -🤸🏾 -🤸🏿 -🤸 -🤸🏻‍♀️ -🤸🏼‍♀️ -🤸🏽‍♀️ -🤸🏾‍♀️ -🤸🏿‍♀️ -🤸‍♀️ -🤸🏻‍♂️ -🤸🏼‍♂️ -🤸🏽‍♂️ -🤸🏾‍♂️ -🤸🏿‍♂️ -🤸‍♂️ -⛹🏻 -⛹🏼 -⛹🏽 -⛹🏾 -⛹🏿 -⛹️ -⛹🏻‍♀️ -⛹🏼‍♀️ -⛹🏽‍♀️ -⛹🏾‍♀️ -⛹🏿‍♀️ -⛹️‍♀️ -⛹🏻‍♂️ -⛹🏼‍♂️ -⛹🏽‍♂️ -⛹🏾‍♂️ -⛹🏿‍♂️ -⛹️‍♂️ -🤺 -🤾🏻 -🤾🏼 -🤾🏽 -🤾🏾 -🤾🏿 -🤾 -🤾🏻‍♀️ -🤾🏼‍♀️ -🤾🏽‍♀️ -🤾🏾‍♀️ -🤾🏿‍♀️ -🤾‍♀️ -🤾🏻‍♂️ -🤾🏼‍♂️ -🤾🏽‍♂️ -🤾🏾‍♂️ -🤾🏿‍♂️ -🤾‍♂️ -🏌🏻 -🏌🏼 -🏌🏽 -🏌🏾 -🏌🏿 -🏌️ -🏌🏻‍♀️ -🏌🏼‍♀️ -🏌🏽‍♀️ -🏌🏾‍♀️ -🏌🏿‍♀️ -🏌️‍♀️ -🏌🏻‍♂️ -🏌🏼‍♂️ -🏌🏽‍♂️ -🏌🏾‍♂️ -🏌🏿‍♂️ -🏌️‍♂️ -🏇🏻 -🏇🏼 -🏇🏽 -🏇🏾 -🏇🏿 -🏇 -🧘🏻 -🧘🏼 -🧘🏽 -🧘🏾 -🧘🏿 -🧘 -🧘🏻‍♀️ -🧘🏼‍♀️ -🧘🏽‍♀️ -🧘🏾‍♀️ -🧘🏿‍♀️ -🧘‍♀️ -🧘🏻‍♂️ -🧘🏼‍♂️ -🧘🏽‍♂️ -🧘🏾‍♂️ -🧘🏿‍♂️ -🧘‍♂️ -🏄🏻 -🏄🏼 -🏄🏽 -🏄🏾 -🏄🏿 -🏄 -🏄🏻‍♀️ -🏄🏼‍♀️ -🏄🏽‍♀️ -🏄🏾‍♀️ -🏄🏿‍♀️ -🏄‍♀️ -🏄🏻‍♂️ -🏄🏼‍♂️ -🏄🏽‍♂️ -🏄🏾‍♂️ -🏄🏿‍♂️ -🏄‍♂️ -🏊🏻 -🏊🏼 -🏊🏽 -🏊🏾 -🏊🏿 -🏊 -🏊🏻‍♀️ -🏊🏼‍♀️ -🏊🏽‍♀️ -🏊🏾‍♀️ -🏊🏿‍♀️ -🏊‍♀️ -🏊🏻‍♂️ -🏊🏼‍♂️ -🏊🏽‍♂️ -🏊🏾‍♂️ -🏊🏿‍♂️ -🏊‍♂️ -🤽🏻 -🤽🏼 -🤽🏽 -🤽🏾 -🤽🏿 -🤽 -🤽🏻‍♀️ -🤽🏼‍♀️ -🤽🏽‍♀️ -🤽🏾‍♀️ -🤽🏿‍♀️ -🤽‍♀️ -🤽🏻‍♂️ -🤽🏼‍♂️ -🤽🏽‍♂️ -🤽🏾‍♂️ -🤽🏿‍♂️ -🤽‍♂️ -🚣🏻 -🚣🏼 -🚣🏽 -🚣🏾 -🚣🏿 -🚣 -🚣🏻‍♀️ -🚣🏼‍♀️ -🚣🏽‍♀️ -🚣🏾‍♀️ -🚣🏿‍♀️ -🚣‍♀️ -🚣🏻‍♂️ -🚣🏼‍♂️ -🚣🏽‍♂️ -🚣🏾‍♂️ -🚣🏿‍♂️ -🚣‍♂️ -🧗🏻 -🧗🏼 -🧗🏽 -🧗🏾 -🧗🏿 -🧗 -🧗🏻‍♀️ -🧗🏼‍♀️ -🧗🏽‍♀️ -🧗🏾‍♀️ -🧗🏿‍♀️ -🧗‍♀️ -🧗🏻‍♂️ -🧗🏼‍♂️ -🧗🏽‍♂️ -🧗🏾‍♂️ -🧗🏿‍♂️ -🧗‍♂️ -🚵🏻 -🚵🏼 -🚵🏽 -🚵🏾 -🚵🏿 -🚵 -🚵🏻‍♀️ -🚵🏼‍♀️ -🚵🏽‍♀️ -🚵🏾‍♀️ -🚵🏿‍♀️ -🚵‍♀️ -🚵🏻‍♂️ -🚵🏼‍♂️ -🚵🏽‍♂️ -🚵🏾‍♂️ -🚵🏿‍♂️ -🚵‍♂️ -🚴🏻 -🚴🏼 -🚴🏽 -🚴🏾 -🚴🏿 -🚴 -🚴🏻‍♀️ -🚴🏼‍♀️ -🚴🏽‍♀️ -🚴🏾‍♀️ -🚴🏿‍♀️ -🚴‍♀️ -🚴🏻‍♂️ -🚴🏼‍♂️ -🚴🏽‍♂️ -🚴🏾‍♂️ -🚴🏿‍♂️ -🚴‍♂️ -🏆 -🥇 -🥈 -🥉 -🏅 -🎖️ -🏵️ -🎗️ -🎫 -🎟️ -🎪 -🤹🏻 -🤹🏼 -🤹🏽 -🤹🏾 -🤹🏿 -🤹 -🤹🏻‍♀️ -🤹🏼‍♀️ -🤹🏽‍♀️ -🤹🏾‍♀️ -🤹🏿‍♀️ -🤹‍♀️ -🤹🏻‍♂️ -🤹🏼‍♂️ -🤹🏽‍♂️ -🤹🏾‍♂️ -🤹🏿‍♂️ -🤹‍♂️ -🎭 -🩰 -🎨 -🎬 -🎤 -🎧 -🎼 -🎹 -🪇 -🥁 -🪘 -🎷 -🎺 -🪗 -🎸 -🪕 -🎻 -🪈 -🎲 -♟️ -🎯 -🎳 -🎮 -🎰 -🧩 -🚗 -🚕 -🚙 -🛻 -🚐 -🚌 -🚎 -🏎️ -🚓 -🚑 -🚒 -🚚 -🚛 -🚜 -🦯 -🦽 -🦼 -🩼 -🛴 -🚲 -🛵 -🏍️ -🛺 -🛞 -🚨 -🚔 -🚍 -🚘 -🚖 -🚡 -🚠 -🚟 -🚃 -🚋 -🚞 -🚝 -🚄 -🚅 -🚈 -🚂 -🚆 -🚇 -🚊 -🚉 -✈️ -🛫 -🛬 -🛩️ -💺 -🛰️ -🚀 -🛸 -🚁 -🛶 -⛵ -🚤 -🛥️ -🛳️ -⛴️ -🚢 -🛟 -⚓ -🪝 -⛽ -🚧 -🚦 -🚥 -🚏 -🗺️ -🗿 -🗽 -🗼 -🏰 -🏯 -🏟️ -🎡 -🎢 -🎠 -⛲ -⛱️ -🏖️ -🏝️ -🏜️ -🌋 -⛰️ -🏔️ -🗻 -🏕️ -⛺ -🏠 -🏡 -🏘️ -🏚️ -🛖 -🏗️ -🏭 -🏢 -🏬 -🏣 -🏤 -🏥 -🏦 -🏨 -🏪 -🏫 -🏩 -💒 -🏛️ -⛪ -🕌 -🕍 -🛕 -🕋 -⛩️ -🛤️ -🛣️ -🗾 -🎑 -🏞️ -🌅 -🌄 -🌠 -🎇 -🎆 -🌇 -🌆 -🏙️ -🌃 -🌌 -🌉 -🌁 -⌚ -📱 -📲 -💻 -⌨️ -🖥️ -🖨️ -🖱️ -🖲️ -🕹️ -🗜️ -💽 -💾 -💿 -📀 -📼 -📷 -📸 -📹 -🎥 -📽️ -🎞️ -📞 -☎️ -📟 -📠 -📺 -📻 -🎙️ -🎚️ -🎛️ -🧭 -⏱️ -⏲️ -⏰ -🕰️ -⌛ -⏳ -📡 -🔋 -🪫 -🔌 -💡 -🔦 -🕯️ -🪔 -🧯 -🛢️ -💸 -💵 -💴 -💶 -💷 -🪙 -💰 -💳 -🪪 -💎 -⚖️ -🪜 -🧰 -🪛 -🔧 -🔨 -⚒️ -🛠️ -⛏️ -🪚 -🔩 -⚙️ -🪤 -🧱 -⛓️ -🔗 -⛓️‍💥 -🧲 -🔫 -💣 -🧨 -🪓 -🔪 -🗡️ -⚔️ -🛡️ -🚬 -⚰️ -🪦 -⚱️ -🏺 -🔮 -📿 -🧿 -🪬 -💈 -⚗️ -🔭 -🔬 -🕳️ -🩻 -🩹 -🩺 -💊 -💉 -🩸 -🧬 -🦠 -🧫 -🧪 -🌡️ -🧹 -🪠 -🧺 -🧻 -🚽 -🚰 -🚿 -🛁 -🛀🏻 -🛀🏼 -🛀🏽 -🛀🏾 -🛀🏿 -🛀 -🧼 -🪥 -🪒 -🪮 -🧽 -🪣 -🧴 -🛎️ -🔑 -🗝️ -🚪 -🪑 -🛋️ -🛏️ -🛌🏻 -🛌🏼 -🛌🏽 -🛌🏾 -🛌🏿 -🛌 -🧸 -🪆 -🖼️ -🪞 -🪟 -🛍️ -🛒 -🎁 -🎈 -🎏 -🎀 -🪄 -🪅 -🎊 -🎉 -🎎 -🪭 -🏮 -🎐 -🪩 -🧧 -✉️ -📩 -📨 -📧 -💌 -📥 -📤 -📦 -🏷️ -🪧 -📪 -📫 -📬 -📭 -📮 -📯 -📜 -📃 -📄 -📑 -🧾 -📊 -📈 -📉 -🗒️ -🗓️ -📆 -📅 -🗑️ -📇 -🗃️ -🗳️ -🗄️ -📋 -📁 -📂 -🗂️ -🗞️ -📰 -📓 -📔 -📒 -📕 -📗 -📘 -📙 -📚 -📖 -🔖 -🧷 -📎 -🖇️ -📐 -📏 -🧮 -📌 -📍 -✂️ -🖊️ -🖋️ -✒️ -🖌️ -🖍️ -📝 -✏️ -🔍 -🔎 -🔏 -🔐 -🔒 -🔓 -🩷 -❤️ -🧡 -💛 -💚 -🩵 -💙 -💜 -🖤 -🩶 -🤍 -🤎 -💔 -❣️ -💕 -💞 -💓 -💗 -💖 -💘 -💝 -❤️‍🩹 -❤️‍🔥 -💟 -☮️ -✝️ -☪️ -🕉️ -☸️ -🪯 -✡️ -🔯 -🕎 -☯️ -☦️ -🛐 -⛎ -♈ -♉ -♊ -♋ -♌ -♍ -♎ -♏ -♐ -♑ -♒ -♓ -🆔 -⚛️ -🉑 -☢️ -☣️ -📴 -📳 -🈶 -🈚 -🈸 -🈺 -🈷️ -✴️ -🆚 -💮 -🉐 -㊙️ -㊗️ -🈴 -🈵 -🈹 -🈲 -🅰️ -🅱️ -🆎 -🆑 -🅾️ -🆘 -❌ -⭕ -🛑 -⛔ -📛 -🚫 -💯 -💢 -♨️ -🚷 -🚯 -🚳 -🚱 -🔞 -📵 -🚭 -❗ -❕ -❓ -❔ -‼️ -⁉️ -🔅 -🔆 -〽️ -⚠️ -🚸 -🔱 -⚜️ -🔰 -♻️ -✅ -🈯 -💹 -❇️ -✳️ -❎ -🌐 -💠 -Ⓜ️ -🌀 -💤 -🏧 -🚾 -♿ -🅿️ -🛗 -🈳 -🈂️ -🛂 -🛃 -🛄 -🛅 -🛜 -🚹 -🚺 -🚼 -🚻 -🚮 -🎦 -📶 -🈁 -🔣 -ℹ️ -🔤 -🔡 -🔠 -🆖 -🆗 -🆙 -🆒 -🆕 -🆓 -0️⃣ -1️⃣ -2️⃣ -3️⃣ -4️⃣ -5️⃣ -6️⃣ -7️⃣ -8️⃣ -9️⃣ -🔟 -🔢 -#️⃣ -*️⃣ -⏏️ -▶️ -⏸️ -⏯️ -⏹️ -⏺️ -⏭️ -⏮️ -⏩ -⏪ -⏫ -⏬ -◀️ -🔼 -🔽 -➡️ -⬅️ -⬆️ -⬇️ -↗️ -↘️ -↙️ -↖️ -↕️ -↔️ -↪️ -↩️ -⤴️ -⤵️ -🔀 -🔁 -🔂 -🔄 -🔃 -🎵 -🎶 -➕ -➖ -➗ -✖️ -🟰 -♾️ -💲 -💱 -™️ -©️ -®️ -〰️ -➰ -➿ -🔚 -🔙 -🔛 -🔝 -🔜 -✔️ -☑️ -🔘 -⚪ -⚫ -🔴 -🔵 -🟤 -🟣 -🟢 -🟡 -🟠 -🔺 -🔻 -🔸 -🔹 -🔶 -🔷 -🔳 -🔲 -▪️ -▫️ -◾ -◽ -◼️ -◻️ -⬛ -⬜ -🟧 -🟦 -🟥 -🟫 -🟪 -🟩 -🟨 -🔈 -🔇 -🔉 -🔊 -🔔 -🔕 -📣 -📢 -🗨️ -👁‍🗨 -💬 -💭 -🗯️ -♠️ -♣️ -♥️ -♦️ -🃏 -🎴 -🀄 -🕐 -🕑 -🕒 -🕓 -🕔 -🕕 -🕖 -🕗 -🕘 -🕙 -🕚 -🕛 -🕜 -🕝 -🕞 -🕟 -🕠 -🕡 -🕢 -🕣 -🕤 -🕥 -🕦 -🕧 -♀️ -♂️ -⚧ -⚕️ -🇿 -🇾 -🇽 -🇼 -🇻 -🇺 -🇹 -🇸 -🇷 -🇶 -🇵 -🇴 -🇳 -🇲 -🇱 -🇰 -🇯 -🇮 -🇭 -🇬 -🇫 -🇪 -🇩 -🇨 -🇧 -🇦 -🏳️ -🏴 -🏴‍☠️ -🏁 -🚩 -🏳️‍🌈 -🏳️‍⚧️ -🇺🇳 -🇦🇫 -🇦🇽 -🇦🇱 -🇩🇿 -🇦🇸 -🇦🇩 -🇦🇴 -🇦🇮 -🇦🇶 -🇦🇬 -🇦🇷 -🇦🇲 -🇦🇼 -🇦🇺 -🇦🇹 -🇦🇿 -🇧🇸 -🇧🇭 -🇧🇩 -🇧🇧 -🇧🇾 -🇧🇪 -🇧🇿 -🇧🇯 -🇧🇲 -🇧🇹 -🇧🇴 -🇧🇦 -🇧🇼 -🇧🇷 -🇮🇴 -🇻🇬 -🇧🇳 -🇧🇬 -🇧🇫 -🇧🇮 -🇰🇭 -🇨🇲 -🇨🇦 -🇮🇨 -🇨🇻 -🇧🇶 -🇰🇾 -🇨🇫 -🇹🇩 -🇨🇱 -🇨🇳 -🇨🇽 -🇨🇨 -🇨🇴 -🇰🇲 -🇨🇬 -🇨🇩 -🇨🇰 -🇨🇷 -🇨🇮 -🇭🇷 -🇨🇺 -🇨🇼 -🇨🇾 -🇨🇿 -🇩🇰 -🇩🇯 -🇩🇲 -🇩🇴 -🇪🇨 -🇪🇬 -🇸🇻 -🇬🇶 -🇪🇷 -🇪🇪 -🇪🇹 -🇪🇺 -🇫🇰 -🇫🇴 -🇫🇯 -🇫🇮 -🇫🇷 -🇬🇫 -🇵🇫 -🇹🇫 -🇬🇦 -🇬🇲 -🇬🇪 -🇩🇪 -🇬🇭 -🇬🇮 -🇬🇷 -🇬🇱 -🇬🇩 -🇬🇵 -🇬🇺 -🇬🇹 -🇬🇬 -🇬🇳 -🇬🇼 -🇬🇾 -🇭🇹 -🇭🇳 -🇭🇰 -🇭🇺 -🇮🇸 -🇮🇳 -🇮🇩 -🇮🇷 -🇮🇶 -🇮🇪 -🇮🇲 -🇮🇱 -🇮🇹 -🇯🇲 -🇯🇵 -🎌 -🇯🇪 -🇯🇴 -🇰🇿 -🇰🇪 -🇰🇮 -🇽🇰 -🇰🇼 -🇰🇬 -🇱🇦 -🇱🇻 -🇱🇧 -🇱🇸 -🇱🇷 -🇱🇾 -🇱🇮 -🇱🇹 -🇱🇺 -🇲🇴 -🇲🇰 -🇲🇬 -🇲🇼 -🇲🇾 -🇲🇻 -🇲🇱 -🇲🇹 -🇲🇭 -🇲🇶 -🇲🇷 -🇲🇺 -🇾🇹 -🇲🇽 -🇫🇲 -🇲🇩 -🇲🇨 -🇲🇳 -🇲🇪 -🇲🇸 -🇲🇦 -🇲🇿 -🇲🇲 -🇳🇦 -🇳🇷 -🇳🇵 -🇳🇱 -🇳🇨 -🇳🇿 -🇳🇮 -🇳🇪 -🇳🇬 -🇳🇺 -🇳🇫 -🇰🇵 -🇲🇵 -🇳🇴 -🇴🇲 -🇵🇰 -🇵🇼 -🇵🇸 -🇵🇦 -🇵🇬 -🇵🇾 -🇵🇪 -🇵🇭 -🇵🇳 -🇵🇱 -🇵🇹 -🇵🇷 -🇶🇦 -🇷🇪 -🇷🇴 -🇷🇺 -🇷🇼 -🇼🇸 -🇸🇲 -🇸🇹 -🇸🇦 -🇸🇳 -🇷🇸 -🇸🇨 -🇸🇱 -🇸🇬 -🇸🇽 -🇸🇰 -🇸🇮 -🇬🇸 -🇸🇧 -🇸🇴 -🇿🇦 -🇰🇷 -🇸🇸 -🇪🇸 -🇱🇰 -🇧🇱 -🇸🇭 -🇰🇳 -🇱🇨 -🇵🇲 -🇻🇨 -🇸🇩 -🇸🇷 -🇸🇿 -🇸🇪 -🇨🇭 -🇸🇾 -🇹🇼 -🇹🇯 -🇹🇿 -🇹🇭 -🇹🇱 -🇹🇬 -🇹🇰 -🇹🇴 -🇹🇹 -🇹🇳 -🇹🇷 -🇹🇲 -🇹🇨 -🇻🇮 -🇹🇻 -🇺🇬 -🇺🇦 -🇦🇪 -🇬🇧 -🏴󠁧󠁢󠁥󠁮󠁧󠁿 -🏴󠁧󠁢󠁳󠁣󠁴󠁿 -🏴󠁧󠁢󠁷󠁬󠁳󠁿 -🇺🇸 -🇺🇾 -🇺🇿 -🇻🇺 -🇻🇦 -🇻🇪 -🇻🇳 -🇼🇫 -🇪🇭 -🇾🇪 -🇿🇲 -🇿🇼 -🇦🇨 -🇧🇻 -🇨🇵 -🇪🇦 -🇩🇬 -🇭🇲 -🇲🇫 -🇸🇯 -🇹🇦 -🇺🇲 \ No newline at end of file diff --git a/src/m2d/converters/event-to-message.js b/src/m2d/converters/event-to-message.js index 9a17817..b52717d 100644 --- a/src/m2d/converters/event-to-message.js +++ b/src/m2d/converters/event-to-message.js @@ -258,18 +258,7 @@ 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 => { - 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 - ) - } + db.prepare("REPLACE INTO member_cache (room_id, mxid, displayname, avatar_url) VALUES (?, ?, ?, ?)").run(roomID, mxid, event?.displayname || null, event?.avatar_url || null) return event }).catch(() => { return {displayname: null, avatar_url: null} diff --git a/src/m2d/converters/event-to-message.test.js b/src/m2d/converters/event-to-message.test.js index cc3d19a..145e9ec 100644 --- a/src/m2d/converters/event-to-message.test.js +++ b/src/m2d/converters/event-to-message.test.js @@ -559,7 +559,7 @@ test("event2message: lists are bridged correctly", async t => { "transaction_id": "m1692967313951.441" }, "event_id": "$l-xQPY5vNJo3SNxU9d8aOWNVD1glMslMyrp4M_JEF70", - "room_id": "!kLRqKKUQXcibIMtOpl:cadence.moe" + "room_id": "!BpMdOUkWWhFxmTrENV:cadence.moe" }), { ensureJoined: [], @@ -662,7 +662,7 @@ test("event2message: code block contents are formatted correctly and not escaped formatted_body: "
input = input.replace(/(<\\/?([^ >]+)[^>]*>)?\\n(<\\/?([^ >]+)[^>]*>)?/g,\n_input_ = input = input.replace(/(<\\/?([^ >]+)[^>]*>)?\\n(<\\/?([^ >]+)[^>]*>)?/g,\n
\n

input = input.replace(/(<\\/?([^ >]+)[^>]*>)?\\n(<\\/?([^ >]+)[^>]*>)?/g,

\n" }, event_id: "$pGkWQuGVmrPNByrFELxhzI6MCBgJecr5I2J3z88Gc2s", - room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" + room_id: "!BpMdOUkWWhFxmTrENV:cadence.moe" }), { ensureJoined: [], @@ -692,7 +692,7 @@ test("event2message: code blocks use double backtick as delimiter when necessary formatted_body: "backtick in ` the middle, backtick at the edge`" }, event_id: "$pGkWQuGVmrPNByrFELxhzI6MCBgJecr5I2J3z88Gc2s", - room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" + room_id: "!BpMdOUkWWhFxmTrENV:cadence.moe" }), { ensureJoined: [], @@ -722,7 +722,7 @@ test("event2message: inline code is converted to code block if it contains both formatted_body: "` one two ``" }, event_id: "$pGkWQuGVmrPNByrFELxhzI6MCBgJecr5I2J3z88Gc2s", - room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" + room_id: "!BpMdOUkWWhFxmTrENV: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
System.out.println("```");
it should print a markdown formatted code block' }, event_id: "$pGkWQuGVmrPNByrFELxhzI6MCBgJecr5I2J3z88Gc2s", - room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" + room_id: "!BpMdOUkWWhFxmTrENV: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
System.out.println("```");
it should print a markdown formatted code block' }, event_id: "$pGkWQuGVmrPNByrFELxhzI6MCBgJecr5I2J3z88Gc2s", - room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" + room_id: "!BpMdOUkWWhFxmTrENV:cadence.moe" }), { ensureJoined: [], @@ -821,7 +821,7 @@ test("event2message: characters are encoded properly in code blocks", async t => + '\n' }, event_id: "$pGkWQuGVmrPNByrFELxhzI6MCBgJecr5I2J3z88Gc2s", - room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" + room_id: "!BpMdOUkWWhFxmTrENV:cadence.moe" }), { ensureJoined: [], @@ -902,7 +902,7 @@ test("event2message: lists have appropriate line breaks", async t => { 'm.mentions': {}, msgtype: 'm.text' }, - room_id: '!TqlyQmifxGUggEmdBN:cadence.moe', + room_id: '!cBxtVRxDlZvSVhJXVK: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: '!TqlyQmifxGUggEmdBN:cadence.moe', + room_id: '!cBxtVRxDlZvSVhJXVK: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: "
In reply to @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?
will try later (tomorrow if I don't forgor)", + formatted_body: "
In reply to @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?
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": "
In reply to @solonovamax:matrix.org

multipart messages will be deleted if the message is edited to require less space

\n

steps to reproduce:

\n
    \n
  1. send a message that is longer than 2000 characters (discord character limit)
  2. \n
\n
    \n
  • bot will split message into two messages on discord
  • \n
\n
    \n
  1. edit message to be under 2000 characters (discord character limit)
  2. \n
\n
    \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
\n
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?", + "formatted_body": "
In reply to @solonovamax:matrix.org

multipart messages will be deleted if the message is edited to require less space

\n

steps to reproduce:

\n
    \n
  1. send a message that is longer than 2000 characters (discord character limit)
  2. \n
\n
    \n
  • bot will split message into two messages on discord
  • \n
\n
    \n
  1. edit message to be under 2000 characters (discord character limit)
  2. \n
\n
    \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
\n
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?", "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": "!TqlyQmifxGUggEmdBN:cadence.moe" + "room_id": "!cBxtVRxDlZvSVhJXVK: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: "!qzDBLKlildpzrrOnFZ:cadence.moe", + room_id: "!should_be_newly_cached: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, "!qzDBLKlildpzrrOnFZ:cadence.moe") + t.equal(roomID, "!should_be_newly_cached:cadence.moe") t.equal(type, "m.room.member") t.equal(stateKey, "@should_be_newly_cached:cadence.moe") return { @@ -3519,60 +3519,12 @@ test("event2message: caches the member if the member is not known", async t => { } ) - t.deepEqual(select("member_cache", ["avatar_url", "displayname", "mxid"], {room_id: "!qzDBLKlildpzrrOnFZ:cadence.moe"}).all(), [ + t.deepEqual(select("member_cache", ["avatar_url", "displayname", "mxid"], {room_id: "!should_be_newly_cached: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( @@ -3628,7 +3580,7 @@ test("event2message: overly long usernames are shifted into the message content" }, event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", origin_server_ts: 1688301929913, - room_id: "!cqeGDbPiMFAhLsqqqq:cadence.moe", + room_id: "!should_be_newly_cached_2:cadence.moe", sender: "@should_be_newly_cached_2:cadence.moe", type: "m.room.message", unsigned: { @@ -3638,7 +3590,7 @@ test("event2message: overly long usernames are shifted into the message content" api: { getStateEvent: async (roomID, type, stateKey) => { called++ - t.equal(roomID, "!cqeGDbPiMFAhLsqqqq:cadence.moe") + t.equal(roomID, "!should_be_newly_cached_2:cadence.moe") t.equal(type, "m.room.member") t.equal(stateKey, "@should_be_newly_cached_2:cadence.moe") return { @@ -3661,7 +3613,7 @@ test("event2message: overly long usernames are shifted into the message content" }] } ) - t.deepEqual(select("member_cache", ["avatar_url", "displayname", "mxid"], {room_id: "!cqeGDbPiMFAhLsqqqq:cadence.moe"}).all(), [ + t.deepEqual(select("member_cache", ["avatar_url", "displayname", "mxid"], {room_id: "!should_be_newly_cached_2: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") @@ -3676,7 +3628,7 @@ test("event2message: overly long usernames are not treated specially when the ms }, event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", origin_server_ts: 1688301929913, - room_id: "!cqeGDbPiMFAhLsqqqq:cadence.moe", + room_id: "!should_be_newly_cached_2:cadence.moe", sender: "@should_be_newly_cached_2:cadence.moe", type: "m.room.message", unsigned: { @@ -4525,7 +4477,7 @@ slow()("event2message: all unknown chess emojis are reuploaded as a sprite sheet formatted_body: "testing \":chess_good_move:\"\":chess_incorrect:\"\":chess_blund:\"\":chess_brilliant_move:\"\":chess_blundest:\"\":chess_draw_black:\"\":chess_good_move:\"\":chess_incorrect:\"\":chess_blund:\"\":chess_brilliant_move:\"\":chess_blundest:\"\":chess_draw_black:\"" }, event_id: "$Me6iE8C8CZyrDEOYYrXKSYRuuh_25Jj9kZaNrf7LKr4", - room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" + room_id: "!maggESguZBqGBZtSnr:cadence.moe" }, {}, {mxcDownloader: mockGetAndConvertEmoji}) const testResult = { content: messages.messagesToSend[0].content, diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index 8aa38cf..a270293 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -22,8 +22,6 @@ 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 @@ -166,20 +164,6 @@ 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} 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} event @@ -208,69 +192,25 @@ 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} 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} 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 - return db.prepare("DELETE FROM member_cache WHERE room_id = ? and mxid = ?").run(event.room_id, event.state_key) + db.prepare("DELETE FROM member_cache WHERE room_id = ? and mxid = ?").run(event.room_id, event.state_key) + } 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 + ) } - - 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", diff --git a/src/matrix/api.js b/src/matrix/api.js index d1d9516..c2b2384 100644 --- a/src/matrix/api.js +++ b/src/matrix/api.js @@ -82,16 +82,6 @@ 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 @@ -387,34 +377,12 @@ 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 @@ -438,5 +406,3 @@ module.exports.getMedia = getMedia module.exports.sendReadReceipt = sendReadReceipt module.exports.ackEvent = ackEvent module.exports.getAlias = getAlias -module.exports.getAccountData = getAccountData -module.exports.setAccountData = setAccountData diff --git a/src/types.d.ts b/src/types.d.ts index 62adf27..3298c40 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -28,8 +28,6 @@ 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 @@ -243,10 +241,6 @@ export namespace Event { name?: string } - export type M_Room_Topic = { - topic?: string - } - export type M_Room_PinnedEvents = { pinned: string[] } @@ -281,11 +275,6 @@ 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" diff --git a/src/web/pug-sync.js b/src/web/pug-sync.js index fb83caa..057fd0a 100644 --- a/src/web/pug-sync.js +++ b/src/web/pug-sync.js @@ -35,13 +35,12 @@ 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, managed} // These are assigned last so they overwrite everything else. It would be catastrophically bad if they can't be trusted. + {session, event, rel} // These are assigned last so they overwrite everything else. It would be catastrophically bad if they can't be trusted. )) }) /* c8 ignore start */ diff --git a/src/web/pug/guild.pug b/src/web/pug/guild.pug index 5a0d2fc..34178bf 100644 --- a/src/web/pug/guild.pug +++ b/src/web/pug/guild.pug @@ -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" hx-post="/api/invite" hx-indicator="#invite-button") + form.d-grid.g-af-column.gy4.gx8.jc-start(method="post" action="/api/invite" style="grid-template-rows: repeat(2, auto)") 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,28 +67,28 @@ block body option(value="admin") Admin input(type="hidden" name="guild_id" value=guild_id) .grid--row-start2 - button.s-btn.s-btn__filled#invite-button Invite + button.s-btn.s-btn__filled.htmx-indicator Invite div != svg - if space_id - h2.mt48.fs-headline1 Matrix setup + h2.mt48.fs-headline1 Moderation - h3.mt32.fs-category Linked channels + h2.mt48.fs-headline1 Matrix setup - .s-card.bs-sm.p0 - 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(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 - td(colspan="3") - .s-empty-state No channels linked between Discord and Matrix yet... + h3.mt32.fs-category Linked channels + + .s-card.bs-sm.p0 + .s-table-container + 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: +matrix(row) + else + tr + td(colspan="3") + .s-empty-state No channels linked between Discord and Matrix yet... h3.mt32.fs-category Auto-create .s-card @@ -98,96 +98,58 @@ 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 autocomplete="off") - #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) + .is-loading#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="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#privacy-level-loading - .s-toggle-switch.s-toggle-switch__multiple.s-toggle-switch__incremental.d-grid.gx16.ai-center(style="grid-template-columns: auto 1fr") - input(type="radio" name="level" value="directory" id="privacy-level-directory" checked=(privacy_level === 2)) - label.d-flex.gx8.jc-center.grid--row-start3(for="privacy-level-directory") - != icons.Icons.IconPlusSm - != icons.Icons.IconInternationalSm - .fl-grow1 Directory - - input(type="radio" name="level" value="link" id="privacy-level-link" checked=(privacy_level === 1)) - label.d-flex.gx8.jc-center.grid--row-start2(for="privacy-level-link") - != icons.Icons.IconPlusSm - != icons.Icons.IconLinkSm - .fl-grow1 Link - - input(type="radio" name="level" value="invite" id="privacy-level-invite" checked=(privacy_level === 0)) - label.d-flex.gx8.jc-center.grid--row-start1(for="privacy-level-invite") - svg.svg-icon(width="14" height="14" viewBox="0 0 14 14") - != icons.Icons.IconLockSm - .fl-grow1 Invite - - p.s-description.m0 In-app direct invite from another user - p.s-description.m0 Shareable invite links, like Discord - p.s-description.m0 Publicly listed in directory, like Discord server discovery - - h3.mt32.fs-category Manually link channels - 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" 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" 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. + h3.mt32.fs-category Privacy level + .s-card + form(hx-post="/api/privacy-level" hx-trigger="change" hx-indicator="#privacy-level-loading" hx-disabled-elt="this") input(type="hidden" name="guild_id" value=guild_id) - div - button.s-btn.s-btn__icon.s-btn__filled#link-button - != icons.Icons.IconMerge - = ` Link` + .d-flex.ai-center.mb4 + label.s-label.fl-grow1 + | How people can join on Matrix + span.is-loading#privacy-level-loading + .s-toggle-switch.s-toggle-switch__multiple.s-toggle-switch__incremental.d-grid.gx16.ai-center(style="grid-template-columns: auto 1fr") + input(type="radio" name="level" value="directory" id="privacy-level-directory" checked=(privacy_level === 2)) + label.d-flex.gx8.jc-center.grid--row-start3(for="privacy-level-directory") + != icons.Icons.IconPlusSm + != icons.Icons.IconInternationalSm + .fl-grow1 Directory - 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 + input(type="radio" name="level" value="link" id="privacy-level-link" checked=(privacy_level === 1)) + label.d-flex.gx8.jc-center.grid--row-start2(for="privacy-level-link") + != icons.Icons.IconPlusSm + != icons.Icons.IconLinkSm + .fl-grow1 Link + + input(type="radio" name="level" value="invite" id="privacy-level-invite" checked=(privacy_level === 0)) + label.d-flex.gx8.jc-center.grid--row-start1(for="privacy-level-invite") + svg.svg-icon(width="14" height="14" viewBox="0 0 14 14") + != icons.Icons.IconLockSm + .fl-grow1 Invite + + p.s-description.m0 In-app direct invite from another user + p.s-description.m0 Shareable invite links, like Discord + p.s-description.m0 Publicly listed in directory, like Discord server discovery + + h3.mt32.fs-category Manually link channels + form.d-flex.g16.ai-start(hx-post="/api/link" hx-trigger="submit" hx-disabled-elt="this") + .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) + 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) + 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 + != icons.Icons.IconMerge + = ` Link` diff --git a/src/web/pug/guild_access_denied.pug b/src/web/pug/guild_access_denied.pug index 319d4de..2b47e08 100644 --- a/src/web/pug/guild_access_denied.pug +++ b/src/web/pug/guild_access_denied.pug @@ -1,16 +1,13 @@ extends includes/template.pug block body - if !session.data.user_id + if !session.data.managedGuilds .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__featured.s-btn__filled(href=rel("/oauth")) + a.s-btn.s-btn__icon.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 @@ -18,7 +15,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) || !managed.has(guild_id) + else if !discord.guilds.has(guild_id) || !session.data.managedGuilds.includes(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. diff --git a/src/web/pug/guild_not_linked.pug b/src/web/pug/guild_not_linked.pug deleted file mode 100644 index 2eade34..0000000 --- a/src/web/pug/guild_not_linked.pug +++ /dev/null @@ -1,52 +0,0 @@ -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 diff --git a/src/web/pug/includes/template.pug b/src/web/pug/includes/template.pug index 9c51d6c..6a0fb76 100644 --- a/src/web/pug/includes/template.pug +++ b/src/web/pug/includes/template.pug @@ -1,120 +1,71 @@ -mixin guild(guild) - span.s-avatar.s-avatar__32.s-user-card--avatar - if guild.icon - img.s-avatar--image(src=`https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png?size=32`) - else - .s-avatar--letter.bg-silver-400.bar-md(aria-hidden="true")= guild.name[0] - .s-user-card--info.ai-start - strong= guild.name - ul.s-user-card--awards - li #{discord.guildChannelMap.get(guild.id).filter(c => [0, 5, 15, 16].includes(discord.channels.get(c).type)).length} channels - -doctype html -html(lang="en") - head - title Out Of Your Element - - link(rel="stylesheet" type="text/css" href=rel("/static/stacks.min.css")) - - meta(name="htmx-config" content='{"requestClass":"is-loading"}') - style. - .themed { - --theme-base-primary-color-h: 266; - --theme-base-primary-color-s: 53%; - --theme-base-primary-color-l: 63%; - --theme-dark-primary-color-h: 266; - --theme-dark-primary-color-s: 53%; - --theme-dark-primary-color-l: 63%; - } - .s-toggle-switch.s-toggle-switch__multiple.s-toggle-switch__incremental input[type="radio"]:checked ~ label:not(.s-toggle-switch--label-off) { - --_ts-multiple-bg: var(--green-400); - --_ts-multiple-fc: var(--white); - } - .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 - .s-topbar--container.wmx9 - a.s-topbar--logo(href=rel("/")) - img.s-avatar.s-avatar__32(src=rel("/icon.png")) - nav.s-topbar--navigation - ul.s-topbar--content - 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 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 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 [...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 }` - document.styleSheets[0].insertRule(t, document.styleSheets[0].cssRules.length) - }) - }) - 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 - }) +mixin guild(guild) + span.s-avatar.s-avatar__32.s-user-card--avatar + if guild.icon + img.s-avatar--image(src=`https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png?size=32`) + else + .s-avatar--letter.bg-silver-400.bar-md(aria-hidden="true")= guild.name[0] + .s-user-card--info.ai-start + strong= guild.name + ul.s-user-card--awards + li #{discord.guildChannelMap.get(guild.id).filter(c => [0, 5, 15, 16].includes(discord.channels.get(c).type)).length} channels + +doctype html +html(lang="en") + head + title Out Of Your Element + + link(rel="stylesheet" type="text/css" href=rel("/static/stacks.min.css")) + + meta(name="htmx-config" content='{"indicatorClass":"is-loading"}') + style. + .themed { + --theme-base-primary-color-h: 266; + --theme-base-primary-color-s: 53%; + --theme-base-primary-color-l: 63%; + --theme-dark-primary-color-h: 266; + --theme-dark-primary-color-s: 53%; + --theme-dark-primary-color-l: 63%; + } + .s-toggle-switch.s-toggle-switch__multiple.s-toggle-switch__incremental input[type="radio"]:checked ~ label:not(.s-toggle-switch--label-off) { + --_ts-multiple-bg: var(--green-400); + --_ts-multiple-fc: var(--white); + } + body.themed.theme-system + header.s-topbar + .s-topbar--skip-link(href="#content") Skip to main content + .s-topbar--container.wmx9 + a.s-topbar--logo(href=rel("/")) + 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")) + != 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") + +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") + | 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) + li(role="menuitem") + a.s-topbar--item.s-user-card.d-flex.p4(href=rel(`/guild?guild_id=${guild.id}`)) + +guild(guild) + .mx-auto.w100.wmx9.py24.px8.fs-body1#content + block body + 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) + }) + }) + script(src=rel("/static/htmx.min.js")) diff --git a/src/web/pug/invite.pug b/src/web/pug/invite.pug index 8cb977b..7f7ff2e 100644 --- a/src/web/pug/invite.pug +++ b/src/web/pug/invite.pug @@ -13,11 +13,11 @@ block body .s-page-title.mb24 h1.s-page-title--header= guild.name - .d-flex.g16#form-container + .d-flex.g16 .fl-grow1 h2.fs-headline1 Invite a Matrix user - 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") + form.d-flex.gy16.fd-column(method="post" action="/api/invite" style="grid-template-rows: repeat(2, auto)") .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#invite-button Invite + button.s-btn.s-btn__filled.htmx-indicator Invite diff --git a/src/web/pug/log-in-with-matrix.pug b/src/web/pug/log-in-with-matrix.pug deleted file mode 100644 index 6fe2947..0000000 --- a/src/web/pug/log-in-with-matrix.pug +++ /dev/null @@ -1,14 +0,0 @@ -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 diff --git a/src/web/pug/ok.pug b/src/web/pug/ok.pug index bf32e8d..9aed737 100644 --- a/src/web/pug/ok.pug +++ b/src/web/pug/ok.pug @@ -1,6 +1,6 @@ extends includes/template.pug block body - .ta-center.wmx5.p48.mx-auto#ok - != spot ? icons.Spots[spot] : icons.Spots.SpotApproveXL + .ta-center.wmx5.p48.mx-auto + != icons.Spots.SpotApproveXL p.mt24.fs-body2= msg diff --git a/src/web/routes/guild-settings.js b/src/web/routes/guild-settings.js index 31bd10f..1c33854 100644 --- a/src/web/routes/guild-settings.js +++ b/src/web/routes/guild-settings.js @@ -2,9 +2,9 @@ const assert = require("assert/strict") const {z} = require("zod") -const {defineEventHandler, useSession, createError, readValidatedBody, getRequestHeader, setResponseHeader, sendRedirect} = require("h3") +const {defineEventHandler, sendRedirect, useSession, createError, readValidatedBody} = require("h3") -const {as, db, sync, select} = require("../../passthrough") +const {as, db, sync} = require("../../passthrough") const {reg} = require("../../matrix/read-registration") /** @type {import("../../d2m/actions/create-space")} */ @@ -26,27 +26,16 @@ 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 || []).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"}) + if (!(session.data.managedGuilds || []).includes(parsedBody.guild_id)) throw createError({status: 403, message: "Forbidden", data: "Can't change settings for a guild you don't have Manage Server permissions in"}) db.prepare("UPDATE guild_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 || []).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"}) + if (!(session.data.managedGuilds || []).includes(parsedBody.guild_id)) throw createError({status: 403, message: "Forbidden", data: "Can't change settings for a guild you don't have Manage Server permissions in"}) const i = levels.indexOf(parsedBody.level) assert.notEqual(i, -1) diff --git a/src/web/routes/guild.js b/src/web/routes/guild.js index ff645a8..5944131 100644 --- a/src/web/routes/guild.js +++ b/src/web/routes/guild.js @@ -2,13 +2,13 @@ const assert = require("assert/strict") const {z} = require("zod") -const {H3Event, defineEventHandler, sendRedirect, useSession, createError, getValidatedQuery, readValidatedBody, setResponseHeader} = require("h3") +const {H3Event, defineEventHandler, sendRedirect, useSession, createError, getValidatedQuery, readValidatedBody} = require("h3") const {randomUUID} = require("crypto") const {LRUCache} = require("lru-cache") const Ty = require("../../types") const uqr = require("uqr") -const {discord, as, sync, select, from, db} = require("../../passthrough") +const {discord, as, sync, select} = require("../../passthrough") /** @type {import("../pug-sync")} */ const pugSync = sync.require("../pug-sync") /** @type {import("../../d2m/actions/create-space")} */ @@ -42,26 +42,6 @@ function getAPI(event) { /** @type {LRUCache} 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 @@ -82,48 +62,35 @@ 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})) - let removedUncachedChannels = filterTo(linkedChannelsWithDetails, c => c.channel) + let linkedChannelsWithDetails = linkedChannels.map(c => ({channel: discord.channels.get(c.channel_id), ...c})).filter(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)) - let removedWrongTypeChannels = filterTo(unlinkedChannels, c => c && [0, 5].includes(c.type)) + let unlinkedChannels = unlinkedChannelIDs.map(c => discord.channels.get(c)).filter(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] - let removedLinkedRooms = filterTo(unlinkedRooms, r => !linkedRoomIDs.includes(r.room_id)) - let removedWrongTypeRooms = filterTo(unlinkedRooms, r => !r.room_type) + let unlinkedRooms = rooms.filter(r => !linkedRoomIDs.includes(r.room_id) && !r.room_type) // https://discord.com/developers/docs/topics/threads#active-archived-threads // need to filter out linked archived threads from unlinkedRooms, will just do that by comparing against the name - let removedArchivedThreadRooms = filterTo(unlinkedRooms, r => r.name && !r.name.match(/^\[(🔒)?⛓️\]/)) + unlinkedRooms = unlinkedRooms.filter(r => r.name && !r.name.match(/^\[(🔒)?⛓️\]/)) - return { - linkedChannelsWithDetails, unlinkedChannels, unlinkedRooms, - removedUncachedChannels, removedWrongTypeChannels, removedLinkedRooms, removedWrongTypeRooms, removedArchivedThreadRooms - } + return {linkedChannelsWithDetails, unlinkedChannels, unlinkedRooms} } 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 = from("guild_active").join("guild_space", "guild_id", "left").select("space_id", "privacy_level", "autocreate").where({guild_id}).get() + const row = select("guild_space", ["space_id", "privacy_level"], {guild_id}).get() // @ts-ignore const guild = discord.guilds.get(guild_id) // Permission problems - if (!guild_id || !guild || !(session.data.managedGuilds || []).concat(session.data.matrixGuilds || []).includes(guild_id) || !row) { + if (!guild_id || !guild || !session.data.managedGuilds || !session.data.managedGuilds.includes(guild_id)) { 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) @@ -134,10 +101,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) - // Easy mode guild that hasn't been linked yet - need to remove elements that would require an existing space - if (!row.space_id) { + // Unlinked guild + if (!row) { const links = getChannelRoomsLinks(guild_id, []) - return pugSync.render(event, "guild.pug", {guild, guild_id, svg, ...links, ...row}) + return pugSync.render(event, "guild.pug", {guild, guild_id, svg, ...links}) } // Linked guild @@ -165,7 +132,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 || []).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"}) + 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"}) } 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) @@ -197,10 +164,9 @@ as.router.post("/api/invite", defineEventHandler(async event => { ( parsedBody.permissions === "admin" ? 100 : parsedBody.permissions === "moderator" ? 50 : 0) - if (powerLevel) await api.setUserPowerCascade(spaceID, parsedBody.mxid, 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) diff --git a/src/web/routes/guild.test.js b/src/web/routes/guild.test.js index 3ef177e..3952d14 100644 --- a/src/web/routes/guild.test.js +++ b/src/web/routes/guild.test.js @@ -18,7 +18,6 @@ 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: [] }, }) @@ -28,7 +27,6 @@ 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: [] }, }) @@ -45,7 +43,6 @@ 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: { @@ -180,12 +177,16 @@ 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, 2) + t.equal(called, 3) }) test("api invite: does not reinvite joined users", async t => { @@ -204,10 +205,14 @@ 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, 1) + t.equal(called, 2) }) diff --git a/src/web/routes/link.js b/src/web/routes/link.js index 1e6a150..e63c01b 100644 --- a/src/web/routes/link.js +++ b/src/web/routes/link.js @@ -1,9 +1,8 @@ // @ts-check const {z} = require("zod") -const {defineEventHandler, useSession, createError, readValidatedBody, setResponseHeader} = require("h3") +const {defineEventHandler, useSession, createError, readValidatedBody} = 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")} */ @@ -16,70 +15,20 @@ 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 || []).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"}) + if (!(session.data.managedGuilds || []).includes(guildID)) throw createError({status: 403, message: "Forbidden", data: "Can't edit a guild you don't have Manage Server permissions in"}) // Check guild is bridged const guild = discord.guilds.get(guildID) @@ -110,37 +59,5 @@ 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 })) diff --git a/src/web/routes/log-in-with-matrix.js b/src/web/routes/log-in-with-matrix.js deleted file mode 100644 index cfab928..0000000 --- a/src/web/routes/log-in-with-matrix.js +++ /dev/null @@ -1,126 +0,0 @@ -// @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} 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) -})) diff --git a/src/web/routes/oauth.js b/src/web/routes/oauth.js index 38d2cc9..12c991d 100644 --- a/src/web/routes/oauth.js +++ b/src/web/routes/oauth.js @@ -2,7 +2,7 @@ const {z} = require("zod") const {randomUUID} = require("crypto") -const {defineEventHandler, getValidatedQuery, sendRedirect, useSession, createError} = require("h3") +const {defineEventHandler, getValidatedQuery, sendRedirect, getQuery, useSession, createError} = require("h3") const {SnowTransfer} = require("snowtransfer") const DiscordTypes = require("discord-api-types/v10") const fetch = require("node-fetch") @@ -75,12 +75,11 @@ 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, userID, state: undefined}) + await session.update({managedGuilds}) } catch (e) { throw createError({status: 502, message: "API call failed", data: e.message}) } @@ -88,8 +87,7 @@ as.router.get("/oauth", defineEventHandler(async event => { // Set auto-create for the guild // @ts-ignore if (managedGuilds.includes(parsedQuery.data.guild_id)) { - 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) + db.prepare("REPLACE INTO guild_active (guild_id, autocreate) VALUES (?, ?)").run(parsedQuery.data.guild_id, +!session.data.selfService) } if (parsedQuery.data.guild_id) { diff --git a/src/web/server.js b/src/web/server.js index 6c2d087..9373947 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -7,18 +7,15 @@ 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, mUtils, icons, reg: reg.reg}) +pugSync.addGlobals({id, h3, discord, select, DiscordTypes, dUtils, icons}) pugSync.createRoute(as.router, "/", "home.pug") pugSync.createRoute(as.router, "/ok", "ok.pug") @@ -30,7 +27,6 @@ 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 @@ -53,12 +49,12 @@ as.router.get("/static/stacks.min.css", defineEventHandler({ } })) -as.router.get("/static/htmx.js", defineEventHandler({ +as.router.get("/static/htmx.min.js", defineEventHandler({ onBeforeResponse: compressResponse, handler: async event => { handleCacheHeaders(event, {maxAge: 86400}) defaultContentType(event, "text/javascript") - return fs.promises.readFile(join(__dirname, "static", "htmx.js"), "utf-8") + return fs.promises.readFile(join(__dirname, "static", "htmx.min.js"), "utf-8") } })) diff --git a/src/web/server.test.js b/src/web/server.test.js index 13091cf..f02a7a6 100644 --- a/src/web/server.test.js +++ b/src/web/server.test.js @@ -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.js", {}), /htmx =/) + t.match(await router.test("get", "/static/htmx.min.js", {}), /htmx=/) }) test("web server: can get css", async t => { diff --git a/src/web/static/htmx.js b/src/web/static/htmx.js deleted file mode 100644 index 370cc0f..0000000 --- a/src/web/static/htmx.js +++ /dev/null @@ -1,5261 +0,0 @@ -var htmx = (function() { - 'use strict' - - // Public API - const htmx = { - // Tsc madness here, assigning the functions directly results in an invalid TypeScript output, but reassigning is fine - /* Event processing */ - /** @type {typeof onLoadHelper} */ - onLoad: null, - /** @type {typeof processNode} */ - process: null, - /** @type {typeof addEventListenerImpl} */ - on: null, - /** @type {typeof removeEventListenerImpl} */ - off: null, - /** @type {typeof triggerEvent} */ - trigger: null, - /** @type {typeof ajaxHelper} */ - ajax: null, - /* DOM querying helpers */ - /** @type {typeof find} */ - find: null, - /** @type {typeof findAll} */ - findAll: null, - /** @type {typeof closest} */ - closest: null, - /** - * Returns the input values that would resolve for a given element via the htmx value resolution mechanism - * - * @see https://htmx.org/api/#values - * - * @param {Element} elt the element to resolve values on - * @param {HttpVerb} type the request type (e.g. **get** or **post**) non-GET's will include the enclosing form of the element. Defaults to **post** - * @returns {Object} - */ - values: function(elt, type) { - const inputValues = getInputValues(elt, type || 'post') - return inputValues.values - }, - /* DOM manipulation helpers */ - /** @type {typeof removeElement} */ - remove: null, - /** @type {typeof addClassToElement} */ - addClass: null, - /** @type {typeof removeClassFromElement} */ - removeClass: null, - /** @type {typeof toggleClassOnElement} */ - toggleClass: null, - /** @type {typeof takeClassForElement} */ - takeClass: null, - /** @type {typeof swap} */ - swap: null, - /* Extension entrypoints */ - /** @type {typeof defineExtension} */ - defineExtension: null, - /** @type {typeof removeExtension} */ - removeExtension: null, - /* Debugging */ - /** @type {typeof logAll} */ - logAll: null, - /** @type {typeof logNone} */ - logNone: null, - /* Debugging */ - /** - * The logger htmx uses to log with - * - * @see https://htmx.org/api/#logger - */ - logger: null, - /** - * A property holding the configuration htmx uses at runtime. - * - * Note that using a [meta tag](https://htmx.org/docs/#config) is the preferred mechanism for setting these properties. - * - * @see https://htmx.org/api/#config - */ - config: { - /** - * Whether to use history. - * @type boolean - * @default true - */ - historyEnabled: true, - /** - * The number of pages to keep in **localStorage** for history support. - * @type number - * @default 10 - */ - historyCacheSize: 10, - /** - * @type boolean - * @default false - */ - refreshOnHistoryMiss: false, - /** - * The default swap style to use if **[hx-swap](https://htmx.org/attributes/hx-swap)** is omitted. - * @type HtmxSwapStyle - * @default 'innerHTML' - */ - defaultSwapStyle: 'innerHTML', - /** - * The default delay between receiving a response from the server and doing the swap. - * @type number - * @default 0 - */ - defaultSwapDelay: 0, - /** - * The default delay between completing the content swap and settling attributes. - * @type number - * @default 20 - */ - defaultSettleDelay: 20, - /** - * If true, htmx will inject a small amount of CSS into the page to make indicators invisible unless the **htmx-indicator** class is present. - * @type boolean - * @default true - */ - includeIndicatorStyles: true, - /** - * The class to place on indicators when a request is in flight. - * @type string - * @default 'htmx-indicator' - */ - indicatorClass: 'htmx-indicator', - /** - * The class to place on triggering elements when a request is in flight. - * @type string - * @default 'htmx-request' - */ - requestClass: 'htmx-request', - /** - * The class to temporarily place on elements that htmx has added to the DOM. - * @type string - * @default 'htmx-added' - */ - addedClass: 'htmx-added', - /** - * The class to place on target elements when htmx is in the settling phase. - * @type string - * @default 'htmx-settling' - */ - settlingClass: 'htmx-settling', - /** - * The class to place on target elements when htmx is in the swapping phase. - * @type string - * @default 'htmx-swapping' - */ - swappingClass: 'htmx-swapping', - /** - * Allows the use of eval-like functionality in htmx, to enable **hx-vars**, trigger conditions & script tag evaluation. Can be set to **false** for CSP compatibility. - * @type boolean - * @default true - */ - allowEval: true, - /** - * If set to false, disables the interpretation of script tags. - * @type boolean - * @default true - */ - allowScriptTags: true, - /** - * If set, the nonce will be added to inline scripts. - * @type string - * @default '' - */ - inlineScriptNonce: '', - /** - * If set, the nonce will be added to inline styles. - * @type string - * @default '' - */ - inlineStyleNonce: '', - /** - * The attributes to settle during the settling phase. - * @type string[] - * @default ['class', 'style', 'width', 'height'] - */ - attributesToSettle: ['class', 'style', 'width', 'height'], - /** - * Allow cross-site Access-Control requests using credentials such as cookies, authorization headers or TLS client certificates. - * @type boolean - * @default false - */ - withCredentials: false, - /** - * @type number - * @default 0 - */ - timeout: 0, - /** - * The default implementation of **getWebSocketReconnectDelay** for reconnecting after unexpected connection loss by the event code **Abnormal Closure**, **Service Restart** or **Try Again Later**. - * @type {'full-jitter' | ((retryCount:number) => number)} - * @default "full-jitter" - */ - wsReconnectDelay: 'full-jitter', - /** - * The type of binary data being received over the WebSocket connection - * @type BinaryType - * @default 'blob' - */ - wsBinaryType: 'blob', - /** - * @type string - * @default '[hx-disable], [data-hx-disable]' - */ - disableSelector: '[hx-disable], [data-hx-disable]', - /** - * @type {'auto' | 'instant' | 'smooth'} - * @default 'instant' - */ - scrollBehavior: 'instant', - /** - * If the focused element should be scrolled into view. - * @type boolean - * @default false - */ - defaultFocusScroll: false, - /** - * If set to true htmx will include a cache-busting parameter in GET requests to avoid caching partial responses by the browser - * @type boolean - * @default false - */ - getCacheBusterParam: false, - /** - * If set to true, htmx will use the View Transition API when swapping in new content. - * @type boolean - * @default false - */ - globalViewTransitions: false, - /** - * htmx will format requests with these methods by encoding their parameters in the URL, not the request body - * @type {(HttpVerb)[]} - * @default ['get', 'delete'] - */ - methodsThatUseUrlParams: ['get', 'delete'], - /** - * If set to true, disables htmx-based requests to non-origin hosts. - * @type boolean - * @default false - */ - selfRequestsOnly: true, - /** - * If set to true htmx will not update the title of the document when a title tag is found in new content - * @type boolean - * @default false - */ - ignoreTitle: false, - /** - * Whether the target of a boosted element is scrolled into the viewport. - * @type boolean - * @default true - */ - scrollIntoViewOnBoost: true, - /** - * The cache to store evaluated trigger specifications into. - * You may define a simple object to use a never-clearing cache, or implement your own system using a [proxy object](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Proxy) - * @type {Object|null} - * @default null - */ - triggerSpecsCache: null, - /** @type boolean */ - disableInheritance: false, - /** @type HtmxResponseHandlingConfig[] */ - responseHandling: [ - { code: '204', swap: false }, - { code: '[23]..', swap: true }, - { code: '[45]..', swap: false, error: true } - ], - /** - * Whether to process OOB swaps on elements that are nested within the main response element. - * @type boolean - * @default true - */ - allowNestedOobSwaps: true - }, - /** @type {typeof parseInterval} */ - parseInterval: null, - /** @type {typeof internalEval} */ - _: null, - version: '2.0.4' - } - // Tsc madness part 2 - htmx.onLoad = onLoadHelper - htmx.process = processNode - htmx.on = addEventListenerImpl - htmx.off = removeEventListenerImpl - htmx.trigger = triggerEvent - htmx.ajax = ajaxHelper - htmx.find = find - htmx.findAll = findAll - htmx.closest = closest - htmx.remove = removeElement - htmx.addClass = addClassToElement - htmx.removeClass = removeClassFromElement - htmx.toggleClass = toggleClassOnElement - htmx.takeClass = takeClassForElement - htmx.swap = swap - htmx.defineExtension = defineExtension - htmx.removeExtension = removeExtension - htmx.logAll = logAll - htmx.logNone = logNone - htmx.parseInterval = parseInterval - htmx._ = internalEval - - const internalAPI = { - addTriggerHandler, - bodyContains, - canAccessLocalStorage, - findThisElement, - filterValues, - swap, - hasAttribute, - getAttributeValue, - getClosestAttributeValue, - getClosestMatch, - getExpressionVars, - getHeaders, - getInputValues, - getInternalData, - getSwapSpecification, - getTriggerSpecs, - getTarget, - makeFragment, - mergeObjects, - makeSettleInfo, - oobSwap, - querySelectorExt, - settleImmediately, - shouldCancel, - triggerEvent, - triggerErrorEvent, - withExtensions - } - - const VERBS = ['get', 'post', 'put', 'delete', 'patch'] - const VERB_SELECTOR = VERBS.map(function(verb) { - return '[hx-' + verb + '], [data-hx-' + verb + ']' - }).join(', ') - - //= =================================================================== - // Utilities - //= =================================================================== - - /** - * Parses an interval string consistent with the way htmx does. Useful for plugins that have timing-related attributes. - * - * Caution: Accepts an int followed by either **s** or **ms**. All other values use **parseFloat** - * - * @see https://htmx.org/api/#parseInterval - * - * @param {string} str timing string - * @returns {number|undefined} - */ - function parseInterval(str) { - if (str == undefined) { - return undefined - } - - let interval = NaN - if (str.slice(-2) == 'ms') { - interval = parseFloat(str.slice(0, -2)) - } else if (str.slice(-1) == 's') { - interval = parseFloat(str.slice(0, -1)) * 1000 - } else if (str.slice(-1) == 'm') { - interval = parseFloat(str.slice(0, -1)) * 1000 * 60 - } else { - interval = parseFloat(str) - } - return isNaN(interval) ? undefined : interval - } - - /** - * @param {Node} elt - * @param {string} name - * @returns {(string | null)} - */ - function getRawAttribute(elt, name) { - return elt instanceof Element && elt.getAttribute(name) - } - - /** - * @param {Element} elt - * @param {string} qualifiedName - * @returns {boolean} - */ - // resolve with both hx and data-hx prefixes - function hasAttribute(elt, qualifiedName) { - return !!elt.hasAttribute && (elt.hasAttribute(qualifiedName) || - elt.hasAttribute('data-' + qualifiedName)) - } - - /** - * - * @param {Node} elt - * @param {string} qualifiedName - * @returns {(string | null)} - */ - function getAttributeValue(elt, qualifiedName) { - return getRawAttribute(elt, qualifiedName) || getRawAttribute(elt, 'data-' + qualifiedName) - } - - /** - * @param {Node} elt - * @returns {Node | null} - */ - function parentElt(elt) { - const parent = elt.parentElement - if (!parent && elt.parentNode instanceof ShadowRoot) return elt.parentNode - return parent - } - - /** - * @returns {Document} - */ - function getDocument() { - return document - } - - /** - * @param {Node} elt - * @param {boolean} global - * @returns {Node|Document} - */ - function getRootNode(elt, global) { - return elt.getRootNode ? elt.getRootNode({ composed: global }) : getDocument() - } - - /** - * @param {Node} elt - * @param {(e:Node) => boolean} condition - * @returns {Node | null} - */ - function getClosestMatch(elt, condition) { - while (elt && !condition(elt)) { - elt = parentElt(elt) - } - - return elt || null - } - - /** - * @param {Element} initialElement - * @param {Element} ancestor - * @param {string} attributeName - * @returns {string|null} - */ - function getAttributeValueWithDisinheritance(initialElement, ancestor, attributeName) { - const attributeValue = getAttributeValue(ancestor, attributeName) - const disinherit = getAttributeValue(ancestor, 'hx-disinherit') - var inherit = getAttributeValue(ancestor, 'hx-inherit') - if (initialElement !== ancestor) { - if (htmx.config.disableInheritance) { - if (inherit && (inherit === '*' || inherit.split(' ').indexOf(attributeName) >= 0)) { - return attributeValue - } else { - return null - } - } - if (disinherit && (disinherit === '*' || disinherit.split(' ').indexOf(attributeName) >= 0)) { - return 'unset' - } - } - return attributeValue - } - - /** - * @param {Element} elt - * @param {string} attributeName - * @returns {string | null} - */ - function getClosestAttributeValue(elt, attributeName) { - let closestAttr = null - getClosestMatch(elt, function(e) { - return !!(closestAttr = getAttributeValueWithDisinheritance(elt, asElement(e), attributeName)) - }) - if (closestAttr !== 'unset') { - return closestAttr - } - } - - /** - * @param {Node} elt - * @param {string} selector - * @returns {boolean} - */ - function matches(elt, selector) { - // @ts-ignore: non-standard properties for browser compatibility - // noinspection JSUnresolvedVariable - const matchesFunction = elt instanceof Element && (elt.matches || elt.matchesSelector || elt.msMatchesSelector || elt.mozMatchesSelector || elt.webkitMatchesSelector || elt.oMatchesSelector) - return !!matchesFunction && matchesFunction.call(elt, selector) - } - - /** - * @param {string} str - * @returns {string} - */ - function getStartTag(str) { - const tagMatcher = /<([a-z][^\/\0>\x20\t\r\n\f]*)/i - const match = tagMatcher.exec(str) - if (match) { - return match[1].toLowerCase() - } else { - return '' - } - } - - /** - * @param {string} resp - * @returns {Document} - */ - function parseHTML(resp) { - const parser = new DOMParser() - return parser.parseFromString(resp, 'text/html') - } - - /** - * @param {DocumentFragment} fragment - * @param {Node} elt - */ - function takeChildrenFor(fragment, elt) { - while (elt.childNodes.length > 0) { - fragment.append(elt.childNodes[0]) - } - } - - /** - * @param {HTMLScriptElement} script - * @returns {HTMLScriptElement} - */ - function duplicateScript(script) { - const newScript = getDocument().createElement('script') - forEach(script.attributes, function(attr) { - newScript.setAttribute(attr.name, attr.value) - }) - newScript.textContent = script.textContent - newScript.async = false - if (htmx.config.inlineScriptNonce) { - newScript.nonce = htmx.config.inlineScriptNonce - } - return newScript - } - - /** - * @param {HTMLScriptElement} script - * @returns {boolean} - */ - function isJavaScriptScriptNode(script) { - return script.matches('script') && (script.type === 'text/javascript' || script.type === 'module' || script.type === '') - } - - /** - * we have to make new copies of script tags that we are going to insert because - * SOME browsers (not saying who, but it involves an element and an animal) don't - * execute scripts created in