From 8ad299b04c3a926c6372ff82f77a6cbb9c1837c3 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Fri, 17 Jan 2025 11:33:29 +1300 Subject: [PATCH 001/148] Add foreign keys to database --- docs/foreign-keys.md | 98 ++++++++++++ src/d2m/actions/create-room.js | 7 + src/d2m/actions/delete-message.js | 2 - src/d2m/actions/edit-message.js | 2 +- src/d2m/actions/send-message.js | 2 +- src/db/migrate.js | 5 +- src/db/migrations/0016-foreign-keys.sql | 164 ++++++++++++++++++++ src/m2d/actions/redact.js | 1 - src/m2d/actions/send-event.js | 3 +- src/m2d/converters/event-to-message.js | 8 +- src/m2d/converters/event-to-message.test.js | 90 ++++++++--- src/m2d/event-dispatcher.js | 29 ++-- src/web/routes/oauth.js | 3 +- test/ooye-test-data.sql | 49 +++--- 14 files changed, 398 insertions(+), 65 deletions(-) create mode 100644 docs/foreign-keys.md create mode 100644 src/db/migrations/0016-foreign-keys.sql diff --git a/docs/foreign-keys.md b/docs/foreign-keys.md new file mode 100644 index 0000000..9940ed0 --- /dev/null +++ b/docs/foreign-keys.md @@ -0,0 +1,98 @@ +# Foreign keys in the Out Of Your Element database + +Historically, Out Of Your Element did not use foreign keys in the database, but since I found a need for them, I have decided to add them. Referential integrity is probably valuable as well. + +The need is that unlinking a channel and room using the web interface should clear up all related entries from `message_channel`, `event_message`, `reaction`, etc. Without foreign keys, this requires multiple DELETEs with tricky queries. With foreign keys and ON DELETE CASCADE, this just works. + +## Quirks + +* **REPLACE INTO** internally causes a DELETE followed by an INSERT, and the DELETE part **will trigger any ON DELETE CASCADE** foreign key conditions on the table, even when the primary key being replaced is the same. + * ```sql + CREATE TABLE discord_channel (channel_id TEXT NOT NULL, name TEXT NOT NULL, PRIMARY KEY (channel_id)); + CREATE TABLE discord_message (message_id TEXT NOT NULL, channel_id TEXT NOT NULL, PRIMARY KEY (message_id), + FOREIGN KEY (channel_id) REFERENCES discord_channel (channel_id) ON DELETE CASCADE); + INSERT INTO discord_channel (channel_id, name) VALUES ("c_1", "place"); + INSERT INTO discord_message (message_id, channel_id) VALUES ("m_2", "c_1"); -- i love my message + REPLACE INTO discord_channel (channel_id, name) VALUES ("c_1", "new place"); -- replace into time + -- i love my message + SELECT * FROM discord_message; -- where is my message + ``` +* In SQLite, `pragma foreign_keys = on` must be set **for each connection** after it's established. I've added this at the start of `migrate.js`, which is called by all database connections. + * Pragma? Pragma keys +* Whenever a child row is inserted, SQLite will look up a row from the parent table to ensure referential integrity. This means **the parent table should be sufficiently keyed or indexed on columns referenced by foreign keys**, or SQLite won't let you do it, with a cryptic error message later on during DML. Due to normal forms, foreign keys naturally tend to reference the parent table's primary key, which is indexed, so that's okay. But still keep this in mind, since many of OOYE's tables effectively have two primary keys, for the Discord and Matrix IDs. A composite primary key doesn't count, even when it's the first column. A unique index counts. + +## Where keys + +Here are some tables that could potentially have foreign keys added between them, and my thought process of whether foreign keys would be a good idea: + +* `guild_active` <--(PK guild_id FK)-- `channel_room` ✅ + * Could be good for referential integrity. + * Linking to guild_space would be pretty scary in case the guild was being relinked to a different space - since rooms aren't tied to a space, this wouldn't actually disturb anything. So I pick guild_active instead. +* `channel_room` <--(PK channel_id FK)-- `message_channel` ✅ + * Seems useful as we want message records to be deleted when a channel is unlinked. +* `message_channel` <--(PK message_id PK)-- `event_message` ✅ + * Seems useful as we want event information to be deleted when a channel is unlinked. +* `guild_active` <--(PK guild_id PK)-- `guild_space` ✅ + * All bridged guilds should have a corresponding guild_active entry, so referential integrity would be useful here to make sure we haven't got any weird states. +* `channel_room` <--(**C** room_id PK)-- `member_cache` ✅ + * Seems useful as we want to clear the member cache when a channel is unlinked. + * There is no index on `channel_room.room_id` right now. It would be good to create this index. Will just make it UNIQUE in the table definition. +* `message_channel` <--(PK message_id FK)-- `reaction` ✅ + * Seems useful as we want to clear the reactions cache when a channel is unlinked. +* `sim` <--(**C** mxid FK)-- `sim_member` + * OOYE inner joins on this. + * Sims are never deleted so if this was added it would only be used for enforcing referential integrity. + * The storage cost of the additional index on `sim` would not be worth the benefits. +* `channel_room` <--(**C** room_id PK)-- `sim_member` + * If a room is being permanently unlinked, it may be useful to see a populated member list. If it's about to be relinked to another channel, we want to keep the sims in the room for more speed and to avoid spamming state events into the timeline. + * Either way, the sims should 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/src/d2m/actions/create-room.js b/src/d2m/actions/create-room.js index 42d5714..64e2b68 100644 --- a/src/d2m/actions/create-room.js +++ b/src/d2m/actions/create-room.js @@ -430,6 +430,13 @@ async function unbridgeDeletedChannel(channel, guildID) { // leave room await api.leaveRoom(roomID) + // 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 channel_room WHERE room_id = ? AND channel_id = ?").run(roomID, channel.id) } diff --git a/src/d2m/actions/delete-message.js b/src/d2m/actions/delete-message.js index bc8adfb..e9e0b08 100644 --- a/src/d2m/actions/delete-message.js +++ b/src/d2m/actions/delete-message.js @@ -16,7 +16,6 @@ async function deleteMessage(data) { const eventsToRedact = select("event_message", "event_id", {message_id: data.id}).pluck().all() db.prepare("DELETE FROM message_channel WHERE message_id = ?").run(data.id) - db.prepare("DELETE FROM event_message WHERE message_id = ?").run(data.id) for (const eventID of eventsToRedact) { // Unfortunately, we can't specify a sender to do the redaction as, unless we find out that info via the audit logs await api.redactEvent(row.room_id, eventID) @@ -35,7 +34,6 @@ async function deleteMessageBulk(data) { const sids = JSON.stringify(data.ids) const eventsToRedact = from("event_message").pluck("event_id").and("WHERE message_id IN (SELECT value FROM json_each(?))").all(sids) db.prepare("DELETE FROM message_channel WHERE message_id IN (SELECT value FROM json_each(?))").run(sids) - db.prepare("DELETE FROM event_message WHERE message_id IN (SELECT value FROM json_each(?))").run(sids) for (const eventID of eventsToRedact) { // Awaiting will make it go slower, but since this could be a long-running operation either way, we want to leave rate limit capacity for other operations await api.redactEvent(roomID, eventID) diff --git a/src/d2m/actions/edit-message.js b/src/d2m/actions/edit-message.js index d85f925..85b1a14 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("REPLACE INTO message_channel (message_id, channel_id) VALUES (?, ?)").run(message.id, message.channel_id) + db.prepare("INSERT OR IGNORE INTO message_channel (message_id, channel_id) VALUES (?, ?)").run(message.id, message.channel_id) } for (const content of eventsToSend) { const eventType = content.$type diff --git a/src/d2m/actions/send-message.js b/src/d2m/actions/send-message.js index be785bb..743c15a 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("REPLACE INTO message_channel (message_id, channel_id) VALUES (?, ?)").run(message.id, message.channel_id) + db.prepare("INSERT OR IGNORE INTO message_channel (message_id, channel_id) VALUES (?, ?)").run(message.id, message.channel_id) if (senderMxid) api.sendTyping(roomID, false, senderMxid).catch(() => {}) } for (const event of events) { diff --git a/src/db/migrate.js b/src/db/migrate.js index 7c1faf9..46d0c14 100644 --- a/src/db/migrate.js +++ b/src/db/migrate.js @@ -6,7 +6,8 @@ const {join} = require("path") async function migrate(db) { let files = fs.readdirSync(join(__dirname, "migrations")) files = files.sort() - db.prepare("CREATE TABLE IF NOT EXISTS migration (filename TEXT NOT NULL)").run() + db.prepare("CREATE TABLE IF NOT EXISTS migration (filename TEXT NOT NULL, PRIMARY KEY (filename)) WITHOUT ROWID").run() + /** @type {string} */ let progress = db.prepare("SELECT * FROM migration").pluck().get() if (!progress) { progress = "" @@ -37,6 +38,8 @@ async function migrate(db) { if (migrationRan) { console.log("Database migrations all done.") } + + db.pragma("foreign_keys = on") } module.exports.migrate = migrate diff --git a/src/db/migrations/0016-foreign-keys.sql b/src/db/migrations/0016-foreign-keys.sql new file mode 100644 index 0000000..c41f329 --- /dev/null +++ b/src/db/migrations/0016-foreign-keys.sql @@ -0,0 +1,164 @@ +-- /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; + +-- *** member_cache *** + +-- 4 +CREATE TABLE "new_member_cache" ( + "room_id" TEXT NOT NULL, + "mxid" TEXT NOT NULL, + "displayname" TEXT, + "avatar_url" TEXT, power_level INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY("room_id","mxid"), + FOREIGN KEY("room_id") REFERENCES "channel_room"("room_id") ON DELETE CASCADE +) WITHOUT ROWID; +-- 5 +INSERT INTO new_member_cache (room_id, mxid, displayname, avatar_url) SELECT room_id, mxid, displayname, avatar_url FROM member_cache WHERE room_id IN (SELECT room_id FROM channel_room); +-- 6 +DROP TABLE member_cache; +-- 7 +ALTER TABLE new_member_cache RENAME TO member_cache; + +-- *** 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 drop the localpart column. no foreign keys needed +CREATE TABLE "new_sim" ( + "user_id" 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, sim_name, mxid) SELECT user_id, 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/m2d/actions/redact.js b/src/m2d/actions/redact.js index 5a12d5a..1f6cef8 100644 --- a/src/m2d/actions/redact.js +++ b/src/m2d/actions/redact.js @@ -13,7 +13,6 @@ const utils = sync.require("../converters/utils") */ async function deleteMessage(event) { const rows = from("event_message").join("message_channel", "message_id").select("channel_id", "message_id").where({event_id: event.redacts}).all() - db.prepare("DELETE FROM event_message WHERE event_id = ?").run(event.redacts) for (const row of rows) { db.prepare("DELETE FROM message_channel WHERE message_id = ?").run(row.message_id) await discord.snow.channel.deleteMessage(row.channel_id, row.message_id, event.content.reason) diff --git a/src/m2d/actions/send-event.js b/src/m2d/actions/send-event.js index 0a270a0..35fcfda 100644 --- a/src/m2d/actions/send-event.js +++ b/src/m2d/actions/send-event.js @@ -102,14 +102,13 @@ async function sendEvent(event) { for (const id of messagesToDelete) { db.prepare("DELETE FROM message_channel WHERE message_id = ?").run(id) - db.prepare("DELETE FROM event_message WHERE message_id = ?").run(id) await channelWebhook.deleteMessageWithWebhook(channelID, id, threadID) } for (const message of messagesToSend) { const reactionPart = messagesToEdit.length === 0 && message === messagesToSend[messagesToSend.length - 1] ? 0 : 1 const messageResponse = await channelWebhook.sendMessageWithWebhook(channelID, message, threadID) - db.prepare("REPLACE INTO message_channel (message_id, channel_id) VALUES (?, ?)").run(messageResponse.id, threadID || channelID) + db.prepare("INSERT INTO message_channel (message_id, channel_id) VALUES (?, ?)").run(messageResponse.id, threadID || channelID) db.prepare("INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part, reaction_part, source) VALUES (?, ?, ?, ?, ?, ?, 0)").run(event.event_id, event.type, event.content["msgtype"] || null, messageResponse.id, eventPart, reactionPart) // source 0 = matrix eventPart = 1 diff --git a/src/m2d/converters/event-to-message.js b/src/m2d/converters/event-to-message.js index b52717d..c498f1e 100644 --- a/src/m2d/converters/event-to-message.js +++ b/src/m2d/converters/event-to-message.js @@ -258,7 +258,13 @@ async function getMemberFromCacheOrHomeserver(roomID, mxid, api) { const row = select("member_cache", ["displayname", "avatar_url"], {room_id: roomID, mxid}).get() if (row) return row return api.getStateEvent(roomID, "m.room.member", mxid).then(event => { - db.prepare("REPLACE INTO member_cache (room_id, mxid, displayname, avatar_url) VALUES (?, ?, ?, ?)").run(roomID, mxid, event?.displayname || null, event?.avatar_url || null) + const displayname = event?.displayname || null + const avatar_url = event?.avatar_url || null + db.prepare("INSERT INTO member_cache (room_id, mxid, displayname, avatar_url) VALUES (?, ?, ?, ?) ON CONFLICT DO UPDATE SET displayname = ?, avatar_url = ?").run( + roomID, mxid, + displayname, avatar_url, + displayname, avatar_url + ) return event }).catch(() => { return {displayname: null, avatar_url: null} diff --git a/src/m2d/converters/event-to-message.test.js b/src/m2d/converters/event-to-message.test.js index 145e9ec..0dc9110 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": "!BpMdOUkWWhFxmTrENV:cadence.moe" + "room_id": "!kLRqKKUQXcibIMtOpl:cadence.moe" }), { ensureJoined: [], @@ -662,7 +662,7 @@ test("event2message: code block contents are formatted correctly and not escaped formatted_body: "
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: "!BpMdOUkWWhFxmTrENV:cadence.moe" + room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" }), { ensureJoined: [], @@ -692,7 +692,7 @@ test("event2message: code blocks use double backtick as delimiter when necessary formatted_body: "backtick in ` the middle, backtick at the edge`" }, event_id: "$pGkWQuGVmrPNByrFELxhzI6MCBgJecr5I2J3z88Gc2s", - room_id: "!BpMdOUkWWhFxmTrENV:cadence.moe" + room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" }), { ensureJoined: [], @@ -722,7 +722,7 @@ test("event2message: inline code is converted to code block if it contains both formatted_body: "` one two ``" }, event_id: "$pGkWQuGVmrPNByrFELxhzI6MCBgJecr5I2J3z88Gc2s", - room_id: "!BpMdOUkWWhFxmTrENV:cadence.moe" + room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" }), { ensureJoined: [], @@ -752,7 +752,7 @@ test("event2message: code blocks are uploaded as attachments instead if they con formatted_body: 'So if you run code like this
System.out.println("```");
it should print a markdown formatted code block' }, event_id: "$pGkWQuGVmrPNByrFELxhzI6MCBgJecr5I2J3z88Gc2s", - room_id: "!BpMdOUkWWhFxmTrENV:cadence.moe" + room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" }), { ensureJoined: [], @@ -784,7 +784,7 @@ test("event2message: code blocks are uploaded as attachments instead if they con formatted_body: 'So if you run code like this
System.out.println("```");
it should print a markdown formatted code block' }, event_id: "$pGkWQuGVmrPNByrFELxhzI6MCBgJecr5I2J3z88Gc2s", - room_id: "!BpMdOUkWWhFxmTrENV:cadence.moe" + room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" }), { ensureJoined: [], @@ -821,7 +821,7 @@ test("event2message: characters are encoded properly in code blocks", async t => + '\n' }, event_id: "$pGkWQuGVmrPNByrFELxhzI6MCBgJecr5I2J3z88Gc2s", - room_id: "!BpMdOUkWWhFxmTrENV:cadence.moe" + room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" }), { ensureJoined: [], @@ -902,7 +902,7 @@ test("event2message: lists have appropriate line breaks", async t => { 'm.mentions': {}, msgtype: 'm.text' }, - room_id: '!cBxtVRxDlZvSVhJXVK:cadence.moe', + room_id: '!TqlyQmifxGUggEmdBN:cadence.moe', sender: '@Milan:tchncs.de', type: 'm.room.message', }), @@ -943,7 +943,7 @@ test("event2message: ordered list start attribute works", async t => { 'm.mentions': {}, msgtype: 'm.text' }, - room_id: '!cBxtVRxDlZvSVhJXVK:cadence.moe', + room_id: '!TqlyQmifxGUggEmdBN:cadence.moe', sender: '@Milan:tchncs.de', type: 'm.room.message', }), @@ -1088,7 +1088,7 @@ test("event2message: rich reply to a rich reply to a multi-line message should c content: { body: "> <@cadence:cadence.moe> I just checked in a fix that will probably work, can you try reproducing this on the latest `main` branch and see if I fixed it?\n\nwill try later (tomorrow if I don't forgor)", format: "org.matrix.custom.html", - formatted_body: "
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": "!cBxtVRxDlZvSVhJXVK:cadence.moe" + "room_id": "!TqlyQmifxGUggEmdBN:cadence.moe" }) }, snow: { @@ -3476,6 +3476,56 @@ test("event2message: colon after mentions is stripped", async t => { }) test("event2message: caches the member if the member 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: "!qzDBLKlildpzrrOnFZ:cadence.moe", + sender: "@should_be_newly_cached:cadence.moe", + type: "m.room.message", + unsigned: { + age: 405299 + } + }, {}, { + api: { + getStateEvent: async (roomID, type, stateKey) => { + called++ + t.equal(roomID, "!qzDBLKlildpzrrOnFZ:cadence.moe") + t.equal(type, "m.room.member") + t.equal(stateKey, "@should_be_newly_cached:cadence.moe") + return { + avatar_url: "mxc://cadence.moe/this_is_the_avatar" + } + } + } + }), + { + ensureJoined: [], + messagesToDelete: [], + messagesToEdit: [], + messagesToSend: [{ + username: "should_be_newly_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: "!qzDBLKlildpzrrOnFZ:cadence.moe"}).all(), [ + {avatar_url: "mxc://cadence.moe/this_is_the_avatar", displayname: null, mxid: "@should_be_newly_cached:cadence.moe"} + ]) + t.equal(called, 1, "getStateEvent should be called once") +}) + +test("event2message: does not cache the member if the room is not known", async t => { let called = 0 t.deepEqual( await eventToMessage({ @@ -3511,7 +3561,7 @@ test("event2message: caches the member if the member is not known", async t => { messagesToSend: [{ username: "should_be_newly_cached", content: "testing the member state cache", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/this_is_the_avatar", + avatar_url: undefined, allowed_mentions: { parse: ["users", "roles"] } @@ -3519,9 +3569,7 @@ test("event2message: caches the member if the member is not known", async t => { } ) - t.deepEqual(select("member_cache", ["avatar_url", "displayname", "mxid"], {room_id: "!should_be_newly_cached:cadence.moe"}).all(), [ - {avatar_url: "mxc://cadence.moe/this_is_the_avatar", displayname: null, mxid: "@should_be_newly_cached:cadence.moe"} - ]) + t.deepEqual(select("member_cache", ["avatar_url", "displayname", "mxid"], {room_id: "!should_be_newly_cached:cadence.moe"}).all(), []) t.equal(called, 1, "getStateEvent should be called once") }) @@ -3580,7 +3628,7 @@ test("event2message: overly long usernames are shifted into the message content" }, event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", origin_server_ts: 1688301929913, - room_id: "!should_be_newly_cached_2:cadence.moe", + room_id: "!cqeGDbPiMFAhLsqqqq:cadence.moe", sender: "@should_be_newly_cached_2:cadence.moe", type: "m.room.message", unsigned: { @@ -3590,7 +3638,7 @@ test("event2message: overly long usernames are shifted into the message content" api: { getStateEvent: async (roomID, type, stateKey) => { called++ - t.equal(roomID, "!should_be_newly_cached_2:cadence.moe") + t.equal(roomID, "!cqeGDbPiMFAhLsqqqq:cadence.moe") t.equal(type, "m.room.member") t.equal(stateKey, "@should_be_newly_cached_2:cadence.moe") return { @@ -3613,7 +3661,7 @@ test("event2message: overly long usernames are shifted into the message content" }] } ) - t.deepEqual(select("member_cache", ["avatar_url", "displayname", "mxid"], {room_id: "!should_be_newly_cached_2:cadence.moe"}).all(), [ + t.deepEqual(select("member_cache", ["avatar_url", "displayname", "mxid"], {room_id: "!cqeGDbPiMFAhLsqqqq:cadence.moe"}).all(), [ {avatar_url: null, displayname: "I am BLACK I am WHITE I am SHORT I am LONG I am EVERYTHING YOU THINK IS IMPORTANT and I DON'T MATTER", mxid: "@should_be_newly_cached_2:cadence.moe"} ]) t.equal(called, 1, "getStateEvent should be called once") @@ -3628,7 +3676,7 @@ test("event2message: overly long usernames are not treated specially when the ms }, event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", origin_server_ts: 1688301929913, - room_id: "!should_be_newly_cached_2:cadence.moe", + room_id: "!cqeGDbPiMFAhLsqqqq:cadence.moe", sender: "@should_be_newly_cached_2:cadence.moe", type: "m.room.message", unsigned: { @@ -4477,7 +4525,7 @@ slow()("event2message: all unknown chess emojis are reuploaded as a sprite sheet formatted_body: "testing \":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: "!maggESguZBqGBZtSnr:cadence.moe" + room_id: "!kLRqKKUQXcibIMtOpl: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 a270293..6cbd6c6 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -199,18 +199,29 @@ sync.addTemporaryListener(as, "type:m.room.member", guard("m.room.member", async event => { if (event.state_key[0] !== "@") return if (utils.eventSenderIsFromDiscord(event.state_key)) return + if (event.content.membership === "leave" || event.content.membership === "ban") { // Member is gone - db.prepare("DELETE FROM member_cache WHERE room_id = ? and mxid = ?").run(event.room_id, event.state_key) - } else { - // Member is here - db.prepare("INSERT INTO member_cache (room_id, mxid, displayname, avatar_url) VALUES (?, ?, ?, ?) ON CONFLICT DO UPDATE SET displayname = ?, avatar_url = ?") - .run( - event.room_id, event.state_key, - event.content.displayname || null, event.content.avatar_url || null, - event.content.displayname || null, event.content.avatar_url || null - ) + return db.prepare("DELETE FROM member_cache WHERE room_id = ? and mxid = ?").run(event.room_id, event.state_key) } + + const room = select("channel_room", "room_id", {room_id: event.room_id}) + if (!room) 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/web/routes/oauth.js b/src/web/routes/oauth.js index 12c991d..1a3753f 100644 --- a/src/web/routes/oauth.js +++ b/src/web/routes/oauth.js @@ -87,7 +87,8 @@ as.router.get("/oauth", defineEventHandler(async event => { // Set auto-create for the guild // @ts-ignore if (managedGuilds.includes(parsedQuery.data.guild_id)) { - db.prepare("REPLACE INTO guild_active (guild_id, autocreate) VALUES (?, ?)").run(parsedQuery.data.guild_id, +!session.data.selfService) + const autocreateInteger = +!session.data.selfService + db.prepare("INSERT INTO guild_active (guild_id, autocreate) VALUES (?, ?) ON CONFLICT DO UPDATE SET autocreate = ?").run(parsedQuery.data.guild_id, autocreateInteger, autocreateInteger) } if (parsedQuery.data.guild_id) { diff --git a/test/ooye-test-data.sql b/test/ooye-test-data.sql index 38fed25..757ef9b 100644 --- a/test/ooye-test-data.sql +++ b/test/ooye-test-data.sql @@ -1,13 +1,14 @@ BEGIN TRANSACTION; -INSERT INTO guild_space (guild_id, space_id, privacy_level) VALUES -('112760669178241024', '!jjWAGMeQdNrVZSSfvz:cadence.moe', 0); - INSERT INTO guild_active (guild_id, autocreate) VALUES ('112760669178241024', 1); +INSERT INTO guild_space (guild_id, space_id, privacy_level) VALUES +('112760669178241024', '!jjWAGMeQdNrVZSSfvz:cadence.moe', 0); + INSERT INTO channel_room (channel_id, room_id, name, nick, thread_parent, custom_avatar) VALUES ('112760669178241024', '!kLRqKKUQXcibIMtOpl:cadence.moe', 'heave', 'main', NULL, NULL), +('687028734322147344', '!fGgIymcYWOqjbSRUdV:cadence.moe', 'slow-news-day', NULL, NULL, NULL), ('497161350934560778', '!CzvdIdUQXgUjDVKxeU:cadence.moe', 'amanda-spam', NULL, NULL, NULL), ('160197704226439168', '!hYnGGlPHlbujVVfktC:cadence.moe', 'the-stanley-parable-channel', 'bots', NULL, NULL), ('1100319550446252084', '!BnKuBPCvyfOkhcUjEu:cadence.moe', 'worm-farm', NULL, NULL, NULL), @@ -18,25 +19,25 @@ INSERT INTO channel_room (channel_id, room_id, name, nick, thread_parent, custom ('489237891895768942', '!tnedrGVYKFNUdnegvf:tchncs.de', 'ex-room-doesnt-exist-any-more', NULL, NULL, NULL), ('1160894080998461480', '!TqlyQmifxGUggEmdBN:cadence.moe', 'ooyexperiment', NULL, NULL, NULL); -INSERT INTO sim (user_id, sim_name, localpart, mxid) VALUES -('0', 'bot', '_ooye_bot', '@_ooye_bot:cadence.moe'), -('820865262526005258', 'crunch_god', '_ooye_crunch_god', '@_ooye_crunch_god:cadence.moe'), -('771520384671416320', 'bojack_horseman', '_ooye_bojack_horseman', '@_ooye_bojack_horseman:cadence.moe'), -('112890272819507200', '.wing.', '_ooye_.wing.', '@_ooye_.wing.:cadence.moe'), -('114147806469554185', 'extremity', '_ooye_extremity', '@_ooye_extremity:cadence.moe'), -('111604486476181504', 'kyuugryphon', '_ooye_kyuugryphon', '@_ooye_kyuugryphon:cadence.moe'), -('1109360903096369153', 'amanda', '_ooye_amanda', '@_ooye_amanda:cadence.moe'), -('43d378d5-1183-47dc-ab3c-d14e21c3fe58', '_pk_zoego', '_ooye__pk_zoego', '@_ooye__pk_zoego:cadence.moe'), -('320067006521147393', 'papiophidian', '_ooye_papiophidian', '@_ooye_papiophidian:cadence.moe'), -('772659086046658620', 'cadence', '_ooye_cadence', '@_ooye_cadence:cadence.moe'); - -INSERT INTO sim_proxy (user_id, proxy_owner_id, displayname) VALUES -('43d378d5-1183-47dc-ab3c-d14e21c3fe58', '196188877885538304', 'Azalea &flwr; 🌺'); +INSERT INTO sim (user_id, sim_name, mxid) VALUES +('0', 'bot', '@_ooye_bot:cadence.moe'), +('820865262526005258', 'crunch_god', '@_ooye_crunch_god:cadence.moe'), +('771520384671416320', 'bojack_horseman', '@_ooye_bojack_horseman:cadence.moe'), +('112890272819507200', '.wing.', '@_ooye_.wing.:cadence.moe'), +('114147806469554185', 'extremity', '@_ooye_extremity:cadence.moe'), +('111604486476181504', 'kyuugryphon', '@_ooye_kyuugryphon:cadence.moe'), +('1109360903096369153', 'amanda', '@_ooye_amanda:cadence.moe'), +('43d378d5-1183-47dc-ab3c-d14e21c3fe58', '_pk_zoego', '@_ooye__pk_zoego:cadence.moe'), +('320067006521147393', 'papiophidian', '@_ooye_papiophidian:cadence.moe'), +('772659086046658620', 'cadence', '@_ooye_cadence:cadence.moe'); INSERT INTO sim_member (mxid, room_id, hashed_profile_content) VALUES ('@_ooye_bojack_horseman:cadence.moe', '!hYnGGlPHlbujVVfktC:cadence.moe', NULL), ('@_ooye_cadence:cadence.moe', '!BnKuBPCvyfOkhcUjEu:cadence.moe', NULL); +INSERT INTO sim_proxy (user_id, proxy_owner_id, displayname) VALUES +('43d378d5-1183-47dc-ab3c-d14e21c3fe58', '196188877885538304', 'Azalea &flwr; 🌺'); + INSERT INTO message_channel (message_id, channel_id) VALUES ('1106366167788044450', '122155380120748034'), ('1106366167788044451', '122155380120748034'), @@ -65,7 +66,8 @@ INSERT INTO message_channel (message_id, channel_id) VALUES ('1273743950028607530', '1100319550446252084'), ('1278002262400176128', '1100319550446252084'), ('1278001833876525057', '1100319550446252084'), -('1191567971970191490', '176333891320283136'); +('1191567971970191490', '176333891320283136'), +('1144874214311067708', '687028734322147344'); INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part, reaction_part, source) VALUES ('$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg', 'm.room.message', 'm.text', '1126786462646550579', 0, 0, 1), @@ -140,19 +142,16 @@ INSERT INTO emoji (emoji_id, name, animated, mxc_url) VALUES INSERT INTO member_cache (room_id, mxid, displayname, avatar_url, power_level) VALUES ('!kLRqKKUQXcibIMtOpl:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', NULL, 0), -('!BpMdOUkWWhFxmTrENV:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', 'malformed mxc', 0), +('!kLRqKKUQXcibIMtOpl:cadence.moe', '@test_auto_invite:example.org', NULL, NULL, 0), ('!fGgIymcYWOqjbSRUdV:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', 'mxc://cadence.moe/azCAhThKTojXSZJRoWwZmhvU', 0), ('!fGgIymcYWOqjbSRUdV:cadence.moe', '@rnl:cadence.moe', 'RNL', NULL, 0), ('!BnKuBPCvyfOkhcUjEu:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', 'mxc://cadence.moe/azCAhThKTojXSZJRoWwZmhvU', 0), -('!maggESguZBqGBZtSnr:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', 'mxc://cadence.moe/azCAhThKTojXSZJRoWwZmhvU', 0), +('!BnKuBPCvyfOkhcUjEu:cadence.moe', '@ami:the-apothecary.club', 'Ami (she/her)', NULL, 0), ('!CzvdIdUQXgUjDVKxeU:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', 'mxc://cadence.moe/azCAhThKTojXSZJRoWwZmhvU', 0), -('!cBxtVRxDlZvSVhJXVK:cadence.moe', '@Milan:tchncs.de', 'Milan', NULL, 0), +('!TqlyQmifxGUggEmdBN:cadence.moe', '@Milan:tchncs.de', 'Milan', NULL, 0), ('!TqlyQmifxGUggEmdBN:cadence.moe', '@ampflower:matrix.org', 'Ampflower 🌺', 'mxc://cadence.moe/PRfhXYBTOalvgQYtmCLeUXko', 0), ('!TqlyQmifxGUggEmdBN:cadence.moe', '@aflower:syndicated.gay', 'Rose', 'mxc://syndicated.gay/ZkBUPXCiXTjdJvONpLJmcbKP', 0), -('!TqlyQmifxGUggEmdBN:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', NULL, 0), -('!BnKuBPCvyfOkhcUjEu:cadence.moe', '@ami:the-apothecary.club', 'Ami (she/her)', NULL, 0), -('!kLRqKKUQXcibIMtOpl:cadence.moe', '@test_auto_invite:example.org', NULL, NULL, 0), -('!BpMdOUkWWhFxmTrENV:cadence.moe', '@test_auto_invite:example.org', NULL, NULL, 100); +('!TqlyQmifxGUggEmdBN:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', NULL, 0); INSERT INTO reaction (hashed_event_id, message_id, encoded_emoji) VALUES (5162930312280790092, '1141501302736695317', '%F0%9F%90%88'); From 14574b4e2c31f451af069e029ce0db69219451b1 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Fri, 17 Jan 2025 11:40:34 +1300 Subject: [PATCH 002/148] Support alternate Discord hosts --- src/d2m/discord-client.js | 10 +++++++--- src/types.d.ts | 2 ++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/d2m/discord-client.js b/src/d2m/discord-client.js index bffb904..37b3eac 100644 --- a/src/d2m/discord-client.js +++ b/src/d2m/discord-client.js @@ -1,10 +1,14 @@ // @ts-check -const { SnowTransfer } = require("snowtransfer") -const { Client: CloudStorm } = require("cloudstorm") +const {Endpoints, SnowTransfer} = require("snowtransfer") +const {reg} = require("../matrix/read-registration") +const {Client: CloudStorm} = require("cloudstorm") + +// @ts-ignore +Endpoints.BASE_HOST = reg.ooye.discord_origin || "https://discord.com"; Endpoints.CDN_URL = reg.ooye.discord_cdn_origin || "https://cdn.discordapp.com" const passthrough = require("../passthrough") -const { sync } = passthrough +const {sync} = passthrough /** @type {import("./discord-packets")} */ const discordPackets = sync.require("./discord-packets") diff --git a/src/types.d.ts b/src/types.d.ts index 3298c40..84aad44 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -28,6 +28,8 @@ export type AppServiceRegistrationConfig = { content_length_workaround: boolean include_user_id_in_mxid: boolean invite: string[] + discord_origin?: string + discord_cdn_origin?: string } old_bridge?: { as_token: string From f42eb6495fba503401f84575751258d9ff9cafa0 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Fri, 17 Jan 2025 18:05:34 +1300 Subject: [PATCH 003/148] New unicode emoji processor --- scripts/emoji-surrogates-statistics.js | 77 + src/d2m/actions/remove-reaction.js | 2 +- src/m2d/actions/add-reaction.js | 2 +- src/m2d/converters/emoji.js | 128 +- src/m2d/converters/emoji.test.js | 52 + src/m2d/converters/emojis.txt | 3799 ++++++++++++++++++++++++ test/test.js | 1 + 7 files changed, 4015 insertions(+), 46 deletions(-) create mode 100644 scripts/emoji-surrogates-statistics.js create mode 100644 src/m2d/converters/emoji.test.js create mode 100644 src/m2d/converters/emojis.txt diff --git a/scripts/emoji-surrogates-statistics.js b/scripts/emoji-surrogates-statistics.js new file mode 100644 index 0000000..29abce3 --- /dev/null +++ b/scripts/emoji-surrogates-statistics.js @@ -0,0 +1,77 @@ +// @ts-check + +const fs = require("fs") +const {join} = require("path") +const s = fs.readFileSync(join(__dirname, "..", "src", "m2d", "converters", "emojis.txt"), "utf8").split("\n").map(x => encodeURIComponent(x)) +const searchPattern = "%EF%B8%8F" + +/** + * adapted from es.map.group-by.js in core-js + * @template K,V + * @param {V[]} items + * @param {(item: V) => K} fn + * @returns {Map} + */ +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/src/d2m/actions/remove-reaction.js b/src/d2m/actions/remove-reaction.js index d991f08..6f922cb 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 = emoji.encodeEmoji(key, undefined) + const discordPreferredEncoding = await emoji.encodeEmoji(key, undefined) db.prepare("DELETE FROM reaction WHERE message_id = ? AND encoded_emoji = ?").run(data.message_id, discordPreferredEncoding) return converter.removeEmojiReaction(data, reactions, key) diff --git a/src/m2d/actions/add-reaction.js b/src/m2d/actions/add-reaction.js index cfd471b..d54bf77 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 = emoji.encodeEmoji(key, event.content.shortcode) + const discordPreferredEncoding = await emoji.encodeEmoji(key, event.content.shortcode) if (!discordPreferredEncoding) return await discord.snow.channel.createReaction(channelID, messageID, discordPreferredEncoding) // acting as the discord bot itself diff --git a/src/m2d/converters/emoji.js b/src/m2d/converters/emoji.js index 66a690c..63e53a9 100644 --- a/src/m2d/converters/emoji.js +++ b/src/m2d/converters/emoji.js @@ -1,58 +1,98 @@ // @ts-check -const assert = require("assert").strict -const Ty = require("../../types") +const fsp = require("fs").promises +const {join} = require("path") +const emojisp = fsp.readFile(join(__dirname, "emojis.txt"), "utf8").then(content => content.split("\n")) const passthrough = require("../../passthrough") -const {sync, select} = passthrough +const {select} = passthrough + /** * @param {string} input * @param {string | null | undefined} shortcode * @returns {string?} */ -function encodeEmoji(input, shortcode) { - let discordPreferredEncoding - if (input.startsWith("mxc://")) { - // 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 { - // 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) +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) { + if (input.startsWith("mxc://")) { + return encodeCustomEmoji(input, shortcode) + } else { + return encodeDefaultEmoji(input) } - return discordPreferredEncoding } module.exports.encodeEmoji = encodeEmoji diff --git a/src/m2d/converters/emoji.test.js b/src/m2d/converters/emoji.test.js new file mode 100644 index 0000000..ad9846b --- /dev/null +++ b/src/m2d/converters/emoji.test.js @@ -0,0 +1,52 @@ +// @ts-check + +const {test} = require("supertape") +const {encodeEmoji} = require("./emoji") + +test("emoji: valid", async t => { + t.equal(await encodeEmoji("🦄", null), "%F0%9F%A6%84") +}) + +test("emoji: freeform text", async t => { + t.equal(await encodeEmoji("ha", null), null) +}) + +test("emoji: suspicious unicode", async t => { + t.equal(await encodeEmoji("Ⓐ", null), null) +}) + +test("emoji: needs u+fe0f added", async t => { + t.equal(await encodeEmoji("☺", null), "%E2%98%BA%EF%B8%8F") +}) + +test("emoji: needs u+fe0f removed", async t => { + t.equal(await encodeEmoji("⭐️", null), "%E2%AD%90") +}) + +test("emoji: number key needs u+fe0f in the middle", async t => { + t.equal(await encodeEmoji("3⃣", null), "3%EF%B8%8F%E2%83%A3") +}) + +test("emoji: hash key needs u+fe0f in the middle", async t => { + t.equal(await encodeEmoji("#⃣", null), "%23%EF%B8%8F%E2%83%A3") +}) + +test("emoji: broken chains needs u+fe0f in the middle", async t => { + t.equal(await encodeEmoji("⛓‍💥", null), "%E2%9B%93%EF%B8%8F%E2%80%8D%F0%9F%92%A5") +}) + +test("emoji: balling needs u+fe0f in the middle", async t => { + t.equal(await encodeEmoji("⛹‍♀", null), "%E2%9B%B9%EF%B8%8F%E2%80%8D%E2%99%80%EF%B8%8F") +}) + +test("emoji: trans flag needs u+fe0f in the middle", async t => { + t.equal(await encodeEmoji("🏳‍⚧", null), "%F0%9F%8F%B3%EF%B8%8F%E2%80%8D%E2%9A%A7%EF%B8%8F") +}) + +test("emoji: spy needs u+fe0f in the middle", async t => { + t.equal(await encodeEmoji("🕵‍♀", null), "%F0%9F%95%B5%EF%B8%8F%E2%80%8D%E2%99%80%EF%B8%8F") +}) + +test("emoji: couple needs u+fe0f in the middle", async t => { + t.equal(await encodeEmoji("👩‍❤‍👩", null), "%F0%9F%91%A9%E2%80%8D%E2%9D%A4%EF%B8%8F%E2%80%8D%F0%9F%91%A9") +}) diff --git a/src/m2d/converters/emojis.txt b/src/m2d/converters/emojis.txt new file mode 100644 index 0000000..32c3f15 --- /dev/null +++ b/src/m2d/converters/emojis.txt @@ -0,0 +1,3799 @@ +😀 +😃 +😄 +😁 +😆 +🥹 +😅 +😂 +🤣 +🥲 +☺️ +😊 +😇 +🙂 +🙃 +😉 +😌 +😍 +🥰 +😘 +😗 +😙 +😚 +😋 +😛 +😝 +😜 +🤪 +🤨 +🧐 +🤓 +😎 +🥸 +🤩 +🥳 +😏 +😒 +😞 +😔 +😟 +😕 +🙁 +☹️ +😣 +😖 +😫 +😩 +🥺 +😢 +😭 +😤 +😠 +😡 +🤬 +🤯 +😳 +🥵 +🥶 +😶‍🌫️ +😱 +😨 +😰 +😥 +😓 +🤗 +🤔 +🫣 +🤭 +🫢 +🫡 +🤫 +🫠 +🤥 +😶 +🫥 +😐 +🫤 +😑 +🫨 +🙂‍↔️ +🙂‍↕️ +😬 +🙄 +😯 +😦 +😧 +😮 +😲 +🥱 +😴 +🤤 +😪 +😮‍💨 +😵 +😵‍💫 +🤐 +🥴 +🤢 +🤮 +🤧 +😷 +🤒 +🤕 +🤑 +🤠 +😈 +👿 +👹 +👺 +🤡 +💩 +👻 +💀 +☠️ +👽 +👾 +🤖 +🎃 +😺 +😸 +😹 +😻 +😼 +😽 +🙀 +😿 +😾 +🤝🏻 +🫱🏻‍🫲🏼 +🫱🏻‍🫲🏽 +🫱🏻‍🫲🏾 +🫱🏻‍🫲🏿 +🫱🏼‍🫲🏻 +🤝🏼 +🫱🏼‍🫲🏽 +🫱🏼‍🫲🏾 +🫱🏼‍🫲🏿 +🫱🏽‍🫲🏻 +🫱🏽‍🫲🏼 +🤝🏽 +🫱🏽‍🫲🏾 +🫱🏽‍🫲🏿 +🫱🏾‍🫲🏻 +🫱🏾‍🫲🏼 +🫱🏾‍🫲🏽 +🤝🏾 +🫱🏾‍🫲🏿 +🫱🏿‍🫲🏻 +🫱🏿‍🫲🏼 +🫱🏿‍🫲🏽 +🫱🏿‍🫲🏾 +🤝🏿 +🤝 +🫶🏻 +🫶🏼 +🫶🏽 +🫶🏾 +🫶🏿 +🫶 +🤲🏻 +🤲🏼 +🤲🏽 +🤲🏾 +🤲🏿 +🤲 +👐🏻 +👐🏼 +👐🏽 +👐🏾 +👐🏿 +👐 +🙌🏻 +🙌🏼 +🙌🏽 +🙌🏾 +🙌🏿 +🙌 +👏🏻 +👏🏼 +👏🏽 +👏🏾 +👏🏿 +👏 +👍🏻 +👍🏼 +👍🏽 +👍🏾 +👍🏿 +👍 +👎🏻 +👎🏼 +👎🏽 +👎🏾 +👎🏿 +👎 +👊🏻 +👊🏼 +👊🏽 +👊🏾 +👊🏿 +👊 +✊🏻 +✊🏼 +✊🏽 +✊🏾 +✊🏿 +✊ +🤛🏻 +🤛🏼 +🤛🏽 +🤛🏾 +🤛🏿 +🤛 +🤜🏻 +🤜🏼 +🤜🏽 +🤜🏾 +🤜🏿 +🤜 +🫷🏻 +🫷🏼 +🫷🏽 +🫷🏾 +🫷🏿 +🫷 +🫸🏻 +🫸🏼 +🫸🏽 +🫸🏾 +🫸🏿 +🫸 +🤞🏻 +🤞🏼 +🤞🏽 +🤞🏾 +🤞🏿 +🤞 +✌🏻 +✌🏼 +✌🏽 +✌🏾 +✌🏿 +✌️ +🫰🏻 +🫰🏼 +🫰🏽 +🫰🏾 +🫰🏿 +🫰 +🤟🏻 +🤟🏼 +🤟🏽 +🤟🏾 +🤟🏿 +🤟 +🤘🏻 +🤘🏼 +🤘🏽 +🤘🏾 +🤘🏿 +🤘 +👌🏻 +👌🏼 +👌🏽 +👌🏾 +👌🏿 +👌 +🤌🏼 +🤌🏻 +🤌🏽 +🤌🏾 +🤌🏿 +🤌 +🤏🏻 +🤏🏼 +🤏🏽 +🤏🏾 +🤏🏿 +🤏 +🫳🏻 +🫳🏼 +🫳🏽 +🫳🏾 +🫳🏿 +🫳 +🫴🏻 +🫴🏼 +🫴🏽 +🫴🏾 +🫴🏿 +🫴 +👈🏻 +👈🏼 +👈🏽 +👈🏾 +👈🏿 +👈 +👉🏻 +👉🏼 +👉🏽 +👉🏾 +👉🏿 +👉 +👆🏻 +👆🏼 +👆🏽 +👆🏾 +👆🏿 +👆 +👇🏻 +👇🏼 +👇🏽 +👇🏾 +👇🏿 +👇 +☝🏻 +☝🏼 +☝🏽 +☝🏾 +☝🏿 +☝️ +✋🏻 +✋🏼 +✋🏽 +✋🏾 +✋🏿 +✋ +🤚🏻 +🤚🏼 +🤚🏽 +🤚🏾 +🤚🏿 +🤚 +🖐🏻 +🖐🏼 +🖐🏽 +🖐🏾 +🖐🏿 +🖐️ +🖖🏻 +🖖🏼 +🖖🏽 +🖖🏾 +🖖🏿 +🖖 +👋🏻 +👋🏼 +👋🏽 +👋🏾 +👋🏿 +👋 +🤙🏻 +🤙🏼 +🤙🏽 +🤙🏾 +🤙🏿 +🤙 +🫲🏻 +🫲🏼 +🫲🏽 +🫲🏾 +🫲🏿 +🫲 +🫱🏻 +🫱🏼 +🫱🏽 +🫱🏾 +🫱🏿 +🫱 +💪🏻 +💪🏼 +💪🏽 +💪🏾 +💪🏿 +💪 +🦾 +🖕🏻 +🖕🏼 +🖕🏽 +🖕🏾 +🖕🏿 +🖕 +✍🏻 +✍🏼 +✍🏽 +✍🏾 +✍🏿 +✍️ +🙏🏻 +🙏🏼 +🙏🏽 +🙏🏾 +🙏🏿 +🙏 +🫵🏻 +🫵🏼 +🫵🏽 +🫵🏾 +🫵🏿 +🫵 +🦶🏻 +🦶🏼 +🦶🏽 +🦶🏾 +🦶🏿 +🦶 +🦵🏻 +🦵🏼 +🦵🏽 +🦵🏾 +🦵🏿 +🦵 +🦿 +💄 +💋 +👄 +🫦 +🦷 +👅 +👂🏻 +👂🏼 +👂🏽 +👂🏾 +👂🏿 +👂 +🦻🏻 +🦻🏼 +🦻🏽 +🦻🏾 +🦻🏿 +🦻 +👃🏻 +👃🏼 +👃🏽 +👃🏾 +👃🏿 +👃 +👣 +👁️ +👀 +🫀 +🫁 +🧠 +🗣️ +👤 +👥 +🫂 +👶🏻 +👶🏼 +👶🏽 +👶🏾 +👶🏿 +👶 +🧒🏻 +🧒🏼 +🧒🏽 +🧒🏾 +🧒🏿 +🧒 +👧🏻 +👧🏼 +👧🏽 +👧🏾 +👧🏿 +👧 +👦🏻 +👦🏼 +👦🏽 +👦🏾 +👦🏿 +👦 +🧑🏻 +🧑🏼 +🧑🏽 +🧑🏾 +🧑🏿 +🧑 +👩🏻 +👩🏼 +👩🏽 +👩🏾 +👩🏿 +👩 +👨🏻 +👨🏼 +👨🏽 +👨🏾 +👨🏿 +👨 +🧑🏻‍🦱 +🧑🏼‍🦱 +🧑🏽‍🦱 +🧑🏾‍🦱 +🧑🏿‍🦱 +🧑‍🦱 +👩🏻‍🦱 +👩🏼‍🦱 +👩🏽‍🦱 +👩🏾‍🦱 +👩🏿‍🦱 +👩‍🦱 +👨🏻‍🦱 +👨🏼‍🦱 +👨🏽‍🦱 +👨🏾‍🦱 +👨🏿‍🦱 +👨‍🦱 +🧑🏻‍🦰 +🧑🏼‍🦰 +🧑🏽‍🦰 +🧑🏾‍🦰 +🧑🏿‍🦰 +🧑‍🦰 +👩🏻‍🦰 +👩🏼‍🦰 +👩🏽‍🦰 +👩🏾‍🦰 +👩🏿‍🦰 +👩‍🦰 +👨🏻‍🦰 +👨🏼‍🦰 +👨🏽‍🦰 +👨🏾‍🦰 +👨🏿‍🦰 +👨‍🦰 +👱🏻 +👱🏼 +👱🏽 +👱🏾 +👱🏿 +👱 +👱🏻‍♀️ +👱🏼‍♀️ +👱🏽‍♀️ +👱🏾‍♀️ +👱🏿‍♀️ +👱‍♀️ +👱🏻‍♂️ +👱🏼‍♂️ +👱🏽‍♂️ +👱🏾‍♂️ +👱🏿‍♂️ +👱‍♂️ +🧑🏻‍🦳 +🧑🏼‍🦳 +🧑🏽‍🦳 +🧑🏾‍🦳 +🧑🏿‍🦳 +🧑‍🦳 +👩🏻‍🦳 +👩🏼‍🦳 +👩🏽‍🦳 +👩🏾‍🦳 +👩🏿‍🦳 +👩‍🦳 +👨🏻‍🦳 +👨🏼‍🦳 +👨🏽‍🦳 +👨🏾‍🦳 +👨🏿‍🦳 +👨‍🦳 +🧑🏻‍🦲 +🧑🏼‍🦲 +🧑🏽‍🦲 +🧑🏾‍🦲 +🧑🏿‍🦲 +🧑‍🦲 +👩🏻‍🦲 +👩🏼‍🦲 +👩🏽‍🦲 +👩🏾‍🦲 +👩🏿‍🦲 +👩‍🦲 +👨🏻‍🦲 +👨🏼‍🦲 +👨🏽‍🦲 +👨🏾‍🦲 +👨🏿‍🦲 +👨‍🦲 +🧔🏻 +🧔🏼 +🧔🏽 +🧔🏾 +🧔🏿 +🧔 +🧔🏻‍♀️ +🧔🏼‍♀️ +🧔🏽‍♀️ +🧔🏾‍♀️ +🧔🏿‍♀️ +🧔‍♀️ +🧔🏻‍♂️ +🧔🏼‍♂️ +🧔🏽‍♂️ +🧔🏾‍♂️ +🧔🏿‍♂️ +🧔‍♂️ +🧓🏻 +🧓🏼 +🧓🏽 +🧓🏾 +🧓🏿 +🧓 +👵🏻 +👵🏼 +👵🏽 +👵🏾 +👵🏿 +👵 +👴🏻 +👴🏼 +👴🏽 +👴🏾 +👴🏿 +👴 +👲🏻 +👲🏼 +👲🏽 +👲🏾 +👲🏿 +👲 +👳🏻 +👳🏼 +👳🏽 +👳🏾 +👳🏿 +👳 +👳🏻‍♀️ +👳🏼‍♀️ +👳🏽‍♀️ +👳🏾‍♀️ +👳🏿‍♀️ +👳‍♀️ +👳🏻‍♂️ +👳🏼‍♂️ +👳🏽‍♂️ +👳🏾‍♂️ +👳🏿‍♂️ +👳‍♂️ +🧕🏻 +🧕🏼 +🧕🏽 +🧕🏾 +🧕🏿 +🧕 +👮🏻 +👮🏼 +👮🏽 +👮🏾 +👮🏿 +👮 +👮🏻‍♀️ +👮🏼‍♀️ +👮🏽‍♀️ +👮🏾‍♀️ +👮🏿‍♀️ +👮‍♀️ +👮🏻‍♂️ +👮🏼‍♂️ +👮🏽‍♂️ +👮🏾‍♂️ +👮🏿‍♂️ +👮‍♂️ +👷🏻 +👷🏼 +👷🏽 +👷🏾 +👷🏿 +👷 +👷🏻‍♀️ +👷🏼‍♀️ +👷🏽‍♀️ +👷🏾‍♀️ +👷🏿‍♀️ +👷‍♀️ +👷🏻‍♂️ +👷🏼‍♂️ +👷🏽‍♂️ +👷🏾‍♂️ +👷🏿‍♂️ +👷‍♂️ +💂🏻 +💂🏼 +💂🏽 +💂🏾 +💂🏿 +💂 +💂🏻‍♀️ +💂🏼‍♀️ +💂🏽‍♀️ +💂🏾‍♀️ +💂🏿‍♀️ +💂‍♀️ +💂🏻‍♂️ +💂🏼‍♂️ +💂🏽‍♂️ +💂🏾‍♂️ +💂🏿‍♂️ +💂‍♂️ +🕵🏻 +🕵🏼 +🕵🏽 +🕵🏾 +🕵🏿 +🕵️ +🕵🏻‍♀️ +🕵🏼‍♀️ +🕵🏽‍♀️ +🕵🏾‍♀️ +🕵🏿‍♀️ +🕵️‍♀️ +🕵🏻‍♂️ +🕵🏼‍♂️ +🕵🏽‍♂️ +🕵🏾‍♂️ +🕵🏿‍♂️ +🕵️‍♂️ +🧑🏻‍⚕️ +🧑🏼‍⚕️ +🧑🏽‍⚕️ +🧑🏾‍⚕️ +🧑🏿‍⚕️ +🧑‍⚕️ +👩🏻‍⚕️ +👩🏼‍⚕️ +👩🏽‍⚕️ +👩🏾‍⚕️ +👩🏿‍⚕️ +👩‍⚕️ +👨🏻‍⚕️ +👨🏼‍⚕️ +👨🏽‍⚕️ +👨🏾‍⚕️ +👨🏿‍⚕️ +👨‍⚕️ +🧑🏻‍🌾 +🧑🏼‍🌾 +🧑🏽‍🌾 +🧑🏾‍🌾 +🧑🏿‍🌾 +🧑‍🌾 +👩🏻‍🌾 +👩🏼‍🌾 +👩🏽‍🌾 +👩🏾‍🌾 +👩🏿‍🌾 +👩‍🌾 +👨🏻‍🌾 +👨🏼‍🌾 +👨🏽‍🌾 +👨🏾‍🌾 +👨🏿‍🌾 +👨‍🌾 +🧑🏻‍🍳 +🧑🏼‍🍳 +🧑🏽‍🍳 +🧑🏾‍🍳 +🧑🏿‍🍳 +🧑‍🍳 +👩🏻‍🍳 +👩🏼‍🍳 +👩🏽‍🍳 +👩🏾‍🍳 +👩🏿‍🍳 +👩‍🍳 +👨🏻‍🍳 +👨🏼‍🍳 +👨🏽‍🍳 +👨🏾‍🍳 +👨🏿‍🍳 +👨‍🍳 +🧑🏻‍🎓 +🧑🏼‍🎓 +🧑🏽‍🎓 +🧑🏾‍🎓 +🧑🏿‍🎓 +🧑‍🎓 +👩🏻‍🎓 +👩🏼‍🎓 +👩🏽‍🎓 +👩🏾‍🎓 +👩🏿‍🎓 +👩‍🎓 +👨🏻‍🎓 +👨🏼‍🎓 +👨🏽‍🎓 +👨🏾‍🎓 +👨🏿‍🎓 +👨‍🎓 +🧑🏻‍🎤 +🧑🏼‍🎤 +🧑🏽‍🎤 +🧑🏾‍🎤 +🧑🏿‍🎤 +🧑‍🎤 +👩🏻‍🎤 +👩🏼‍🎤 +👩🏽‍🎤 +👩🏾‍🎤 +👩🏿‍🎤 +👩‍🎤 +👨🏻‍🎤 +👨🏼‍🎤 +👨🏽‍🎤 +👨🏾‍🎤 +👨🏿‍🎤 +👨‍🎤 +🧑🏻‍🏫 +🧑🏼‍🏫 +🧑🏽‍🏫 +🧑🏾‍🏫 +🧑🏿‍🏫 +🧑‍🏫 +👩🏻‍🏫 +👩🏼‍🏫 +👩🏽‍🏫 +👩🏾‍🏫 +👩🏿‍🏫 +👩‍🏫 +👨🏻‍🏫 +👨🏼‍🏫 +👨🏽‍🏫 +👨🏾‍🏫 +👨🏿‍🏫 +👨‍🏫 +🧑🏻‍🏭 +🧑🏼‍🏭 +🧑🏽‍🏭 +🧑🏾‍🏭 +🧑🏿‍🏭 +🧑‍🏭 +👩🏻‍🏭 +👩🏼‍🏭 +👩🏽‍🏭 +👩🏾‍🏭 +👩🏿‍🏭 +👩‍🏭 +👨🏻‍🏭 +👨🏼‍🏭 +👨🏽‍🏭 +👨🏾‍🏭 +👨🏿‍🏭 +👨‍🏭 +🧑🏻‍💻 +🧑🏼‍💻 +🧑🏽‍💻 +🧑🏾‍💻 +🧑🏿‍💻 +🧑‍💻 +👩🏻‍💻 +👩🏼‍💻 +👩🏽‍💻 +👩🏾‍💻 +👩🏿‍💻 +👩‍💻 +👨🏻‍💻 +👨🏼‍💻 +👨🏽‍💻 +👨🏾‍💻 +👨🏿‍💻 +👨‍💻 +🧑🏻‍💼 +🧑🏼‍💼 +🧑🏽‍💼 +🧑🏾‍💼 +🧑🏿‍💼 +🧑‍💼 +👩🏻‍💼 +👩🏼‍💼 +👩🏽‍💼 +👩🏾‍💼 +👩🏿‍💼 +👩‍💼 +👨🏻‍💼 +👨🏼‍💼 +👨🏽‍💼 +👨🏾‍💼 +👨🏿‍💼 +👨‍💼 +🧑🏻‍🔧 +🧑🏼‍🔧 +🧑🏽‍🔧 +🧑🏾‍🔧 +🧑🏿‍🔧 +🧑‍🔧 +👩🏻‍🔧 +👩🏼‍🔧 +👩🏽‍🔧 +👩🏾‍🔧 +👩🏿‍🔧 +👩‍🔧 +👨🏻‍🔧 +👨🏼‍🔧 +👨🏽‍🔧 +👨🏾‍🔧 +👨🏿‍🔧 +👨‍🔧 +🧑🏻‍🔬 +🧑🏼‍🔬 +🧑🏽‍🔬 +🧑🏾‍🔬 +🧑🏿‍🔬 +🧑‍🔬 +👩🏻‍🔬 +👩🏼‍🔬 +👩🏽‍🔬 +👩🏾‍🔬 +👩🏿‍🔬 +👩‍🔬 +👨🏻‍🔬 +👨🏼‍🔬 +👨🏽‍🔬 +👨🏾‍🔬 +👨🏿‍🔬 +👨‍🔬 +🧑🏻‍🎨 +🧑🏼‍🎨 +🧑🏽‍🎨 +🧑🏾‍🎨 +🧑🏿‍🎨 +🧑‍🎨 +👩🏻‍🎨 +👩🏼‍🎨 +👩🏽‍🎨 +👩🏾‍🎨 +👩🏿‍🎨 +👩‍🎨 +👨🏻‍🎨 +👨🏼‍🎨 +👨🏽‍🎨 +👨🏾‍🎨 +👨🏿‍🎨 +👨‍🎨 +🧑🏻‍🚒 +🧑🏼‍🚒 +🧑🏽‍🚒 +🧑🏾‍🚒 +🧑🏿‍🚒 +🧑‍🚒 +👩🏻‍🚒 +👩🏼‍🚒 +👩🏽‍🚒 +👩🏾‍🚒 +👩🏿‍🚒 +👩‍🚒 +👨🏻‍🚒 +👨🏼‍🚒 +👨🏽‍🚒 +👨🏾‍🚒 +👨🏿‍🚒 +👨‍🚒 +🧑🏻‍✈️ +🧑🏼‍✈️ +🧑🏽‍✈️ +🧑🏾‍✈️ +🧑🏿‍✈️ +🧑‍✈️ +👩🏻‍✈️ +👩🏼‍✈️ +👩🏽‍✈️ +👩🏾‍✈️ +👩🏿‍✈️ +👩‍✈️ +👨🏻‍✈️ +👨🏼‍✈️ +👨🏽‍✈️ +👨🏾‍✈️ +👨🏿‍✈️ +👨‍✈️ +🧑🏻‍🚀 +🧑🏼‍🚀 +🧑🏽‍🚀 +🧑🏾‍🚀 +🧑🏿‍🚀 +🧑‍🚀 +👩🏻‍🚀 +👩🏼‍🚀 +👩🏽‍🚀 +👩🏾‍🚀 +👩🏿‍🚀 +👩‍🚀 +👨🏻‍🚀 +👨🏼‍🚀 +👨🏽‍🚀 +👨🏾‍🚀 +👨🏿‍🚀 +👨‍🚀 +🧑🏻‍⚖️ +🧑🏼‍⚖️ +🧑🏽‍⚖️ +🧑🏾‍⚖️ +🧑🏿‍⚖️ +🧑‍⚖️ +👩🏻‍⚖️ +👩🏼‍⚖️ +👩🏽‍⚖️ +👩🏾‍⚖️ +👩🏿‍⚖️ +👩‍⚖️ +👨🏻‍⚖️ +👨🏼‍⚖️ +👨🏽‍⚖️ +👨🏾‍⚖️ +👨🏿‍⚖️ +👨‍⚖️ +👰🏻 +👰🏼 +👰🏽 +👰🏾 +👰🏿 +👰 +👰🏻‍♀️ +👰🏼‍♀️ +👰🏽‍♀️ +👰🏾‍♀️ +👰🏿‍♀️ +👰‍♀️ +👰🏻‍♂️ +👰🏼‍♂️ +👰🏽‍♂️ +👰🏾‍♂️ +👰🏿‍♂️ +👰‍♂️ +🤵🏻 +🤵🏼 +🤵🏽 +🤵🏾 +🤵🏿 +🤵 +🤵🏻‍♀️ +🤵🏼‍♀️ +🤵🏽‍♀️ +🤵🏾‍♀️ +🤵🏿‍♀️ +🤵‍♀️ +🤵🏻‍♂️ +🤵🏼‍♂️ +🤵🏽‍♂️ +🤵🏾‍♂️ +🤵🏿‍♂️ +🤵‍♂️ +🫅🏻 +🫅🏼 +🫅🏽 +🫅🏾 +🫅🏿 +🫅 +👸🏻 +👸🏼 +👸🏽 +👸🏾 +👸🏿 +👸 +🤴🏻 +🤴🏼 +🤴🏽 +🤴🏾 +🤴🏿 +🤴 +🦸🏻 +🦸🏼 +🦸🏽 +🦸🏾 +🦸🏿 +🦸 +🦸🏻‍♀️ +🦸🏼‍♀️ +🦸🏽‍♀️ +🦸🏾‍♀️ +🦸🏿‍♀️ +🦸‍♀️ +🦸🏻‍♂️ +🦸🏼‍♂️ +🦸🏽‍♂️ +🦸🏾‍♂️ +🦸🏿‍♂️ +🦸‍♂️ +🦹🏻 +🦹🏼 +🦹🏽 +🦹🏾 +🦹🏿 +🦹 +🦹🏻‍♀️ +🦹🏼‍♀️ +🦹🏽‍♀️ +🦹🏾‍♀️ +🦹🏿‍♀️ +🦹‍♀️ +🦹🏻‍♂️ +🦹🏼‍♂️ +🦹🏽‍♂️ +🦹🏾‍♂️ +🦹🏿‍♂️ +🦹‍♂️ +🥷🏻 +🥷🏼 +🥷🏽 +🥷🏾 +🥷🏿 +🥷 +🧑🏻‍🎄 +🧑🏼‍🎄 +🧑🏽‍🎄 +🧑🏾‍🎄 +🧑🏿‍🎄 +🧑‍🎄 +🤶🏻 +🤶🏼 +🤶🏽 +🤶🏾 +🤶🏿 +🤶 +🎅🏻 +🎅🏼 +🎅🏽 +🎅🏾 +🎅🏿 +🎅 +🧙🏻 +🧙🏼 +🧙🏽 +🧙🏾 +🧙🏿 +🧙 +🧙🏻‍♀️ +🧙🏼‍♀️ +🧙🏽‍♀️ +🧙🏾‍♀️ +🧙🏿‍♀️ +🧙‍♀️ +🧙🏻‍♂️ +🧙🏼‍♂️ +🧙🏽‍♂️ +🧙🏾‍♂️ +🧙🏿‍♂️ +🧙‍♂️ +🧝🏻 +🧝🏼 +🧝🏽 +🧝🏾 +🧝🏿 +🧝 +🧝🏻‍♀️ +🧝🏼‍♀️ +🧝🏽‍♀️ +🧝🏾‍♀️ +🧝🏿‍♀️ +🧝‍♀️ +🧝🏻‍♂️ +🧝🏼‍♂️ +🧝🏽‍♂️ +🧝🏾‍♂️ +🧝🏿‍♂️ +🧝‍♂️ +🧌 +🧛🏻 +🧛🏼 +🧛🏽 +🧛🏾 +🧛🏿 +🧛 +🧛🏻‍♀️ +🧛🏼‍♀️ +🧛🏽‍♀️ +🧛🏾‍♀️ +🧛🏿‍♀️ +🧛‍♀️ +🧛🏻‍♂️ +🧛🏼‍♂️ +🧛🏽‍♂️ +🧛🏾‍♂️ +🧛🏿‍♂️ +🧛‍♂️ +🧟 +🧟‍♀️ +🧟‍♂️ +🧞 +🧞‍♀️ +🧞‍♂️ +🧜🏻 +🧜🏼 +🧜🏽 +🧜🏾 +🧜🏿 +🧜 +🧜🏻‍♀️ +🧜🏼‍♀️ +🧜🏽‍♀️ +🧜🏾‍♀️ +🧜🏿‍♀️ +🧜‍♀️ +🧜🏻‍♂️ +🧜🏼‍♂️ +🧜🏽‍♂️ +🧜🏾‍♂️ +🧜🏿‍♂️ +🧜‍♂️ +🧚🏻 +🧚🏼 +🧚🏽 +🧚🏾 +🧚🏿 +🧚 +🧚🏻‍♀️ +🧚🏼‍♀️ +🧚🏽‍♀️ +🧚🏾‍♀️ +🧚🏿‍♀️ +🧚‍♀️ +🧚🏻‍♂️ +🧚🏼‍♂️ +🧚🏽‍♂️ +🧚🏾‍♂️ +🧚🏿‍♂️ +🧚‍♂️ +👼🏻 +👼🏼 +👼🏽 +👼🏾 +👼🏿 +👼 +🫄🏻 +🫄🏼 +🫄🏽 +🫄🏾 +🫄🏿 +🫄 +🤰🏻 +🤰🏼 +🤰🏽 +🤰🏾 +🤰🏿 +🤰 +🫃🏻 +🫃🏼 +🫃🏽 +🫃🏾 +🫃🏿 +🫃 +🤱🏻 +🤱🏼 +🤱🏽 +🤱🏾 +🤱🏿 +🤱 +🧑🏻‍🍼 +🧑🏼‍🍼 +🧑🏽‍🍼 +🧑🏾‍🍼 +🧑🏿‍🍼 +🧑‍🍼 +👩🏻‍🍼 +👩🏼‍🍼 +👩🏽‍🍼 +👩🏾‍🍼 +👩🏿‍🍼 +👩‍🍼 +👨🏻‍🍼 +👨🏼‍🍼 +👨🏽‍🍼 +👨🏾‍🍼 +👨🏿‍🍼 +👨‍🍼 +🙇🏻 +🙇🏼 +🙇🏽 +🙇🏾 +🙇🏿 +🙇 +🙇🏻‍♀️ +🙇🏼‍♀️ +🙇🏽‍♀️ +🙇🏾‍♀️ +🙇🏿‍♀️ +🙇‍♀️ +🙇🏻‍♂️ +🙇🏼‍♂️ +🙇🏽‍♂️ +🙇🏾‍♂️ +🙇🏿‍♂️ +🙇‍♂️ +💁🏻 +💁🏼 +💁🏽 +💁🏾 +💁🏿 +💁 +💁🏻‍♀️ +💁🏼‍♀️ +💁🏽‍♀️ +💁🏾‍♀️ +💁🏿‍♀️ +💁‍♀️ +💁🏻‍♂️ +💁🏼‍♂️ +💁🏽‍♂️ +💁🏾‍♂️ +💁🏿‍♂️ +💁‍♂️ +🙅🏻 +🙅🏼 +🙅🏽 +🙅🏾 +🙅🏿 +🙅 +🙅🏻‍♀️ +🙅🏼‍♀️ +🙅🏽‍♀️ +🙅🏾‍♀️ +🙅🏿‍♀️ +🙅‍♀️ +🙅🏻‍♂️ +🙅🏼‍♂️ +🙅🏽‍♂️ +🙅🏾‍♂️ +🙅🏿‍♂️ +🙅‍♂️ +🙆🏻 +🙆🏼 +🙆🏽 +🙆🏾 +🙆🏿 +🙆 +🙆🏻‍♀️ +🙆🏼‍♀️ +🙆🏽‍♀️ +🙆🏾‍♀️ +🙆🏿‍♀️ +🙆‍♀️ +🙆🏻‍♂️ +🙆🏼‍♂️ +🙆🏽‍♂️ +🙆🏾‍♂️ +🙆🏿‍♂️ +🙆‍♂️ +🙋🏻 +🙋🏼 +🙋🏽 +🙋🏾 +🙋🏿 +🙋 +🙋🏻‍♀️ +🙋🏼‍♀️ +🙋🏽‍♀️ +🙋🏾‍♀️ +🙋🏿‍♀️ +🙋‍♀️ +🙋🏻‍♂️ +🙋🏼‍♂️ +🙋🏽‍♂️ +🙋🏾‍♂️ +🙋🏿‍♂️ +🙋‍♂️ +🧏🏻 +🧏🏼 +🧏🏽 +🧏🏾 +🧏🏿 +🧏 +🧏🏻‍♀️ +🧏🏼‍♀️ +🧏🏽‍♀️ +🧏🏾‍♀️ +🧏🏿‍♀️ +🧏‍♀️ +🧏🏻‍♂️ +🧏🏼‍♂️ +🧏🏽‍♂️ +🧏🏾‍♂️ +🧏🏿‍♂️ +🧏‍♂️ +🤦🏻 +🤦🏼 +🤦🏽 +🤦🏾 +🤦🏿 +🤦 +🤦🏻‍♀️ +🤦🏼‍♀️ +🤦🏽‍♀️ +🤦🏾‍♀️ +🤦🏿‍♀️ +🤦‍♀️ +🤦🏻‍♂️ +🤦🏼‍♂️ +🤦🏽‍♂️ +🤦🏾‍♂️ +🤦🏿‍♂️ +🤦‍♂️ +🤷🏻 +🤷🏼 +🤷🏽 +🤷🏾 +🤷🏿 +🤷 +🤷🏻‍♀️ +🤷🏼‍♀️ +🤷🏽‍♀️ +🤷🏾‍♀️ +🤷🏿‍♀️ +🤷‍♀️ +🤷🏻‍♂️ +🤷🏼‍♂️ +🤷🏽‍♂️ +🤷🏾‍♂️ +🤷🏿‍♂️ +🤷‍♂️ +🙎🏻 +🙎🏼 +🙎🏽 +🙎🏾 +🙎🏿 +🙎 +🙎🏻‍♀️ +🙎🏼‍♀️ +🙎🏽‍♀️ +🙎🏾‍♀️ +🙎🏿‍♀️ +🙎‍♀️ +🙎🏻‍♂️ +🙎🏼‍♂️ +🙎🏽‍♂️ +🙎🏾‍♂️ +🙎🏿‍♂️ +🙎‍♂️ +🙍🏻 +🙍🏼 +🙍🏽 +🙍🏾 +🙍🏿 +🙍 +🙍🏻‍♀️ +🙍🏼‍♀️ +🙍🏽‍♀️ +🙍🏾‍♀️ +🙍🏿‍♀️ +🙍‍♀️ +🙍🏻‍♂️ +🙍🏼‍♂️ +🙍🏽‍♂️ +🙍🏾‍♂️ +🙍🏿‍♂️ +🙍‍♂️ +💇🏻 +💇🏼 +💇🏽 +💇🏾 +💇🏿 +💇 +💇🏻‍♀️ +💇🏼‍♀️ +💇🏽‍♀️ +💇🏾‍♀️ +💇🏿‍♀️ +💇‍♀️ +💇🏻‍♂️ +💇🏼‍♂️ +💇🏽‍♂️ +💇🏾‍♂️ +💇🏿‍♂️ +💇‍♂️ +💆🏻 +💆🏼 +💆🏽 +💆🏾 +💆🏿 +💆 +💆🏻‍♀️ +💆🏼‍♀️ +💆🏽‍♀️ +💆🏾‍♀️ +💆🏿‍♀️ +💆‍♀️ +💆🏻‍♂️ +💆🏼‍♂️ +💆🏽‍♂️ +💆🏾‍♂️ +💆🏿‍♂️ +💆‍♂️ +🧖🏻 +🧖🏼 +🧖🏽 +🧖🏾 +🧖🏿 +🧖 +🧖🏻‍♀️ +🧖🏼‍♀️ +🧖🏽‍♀️ +🧖🏾‍♀️ +🧖🏿‍♀️ +🧖‍♀️ +🧖🏻‍♂️ +🧖🏼‍♂️ +🧖🏽‍♂️ +🧖🏾‍♂️ +🧖🏿‍♂️ +🧖‍♂️ +💅🏻 +💅🏼 +💅🏽 +💅🏾 +💅🏿 +💅 +🤳🏻 +🤳🏼 +🤳🏽 +🤳🏾 +🤳🏿 +🤳 +💃🏻 +💃🏼 +💃🏽 +💃🏾 +💃🏿 +💃 +🕺🏻 +🕺🏼 +🕺🏽 +🕺🏿 +🕺🏾 +🕺 +👯 +👯‍♀️ +👯‍♂️ +🕴🏻 +🕴🏼 +🕴🏽 +🕴🏾 +🕴🏿 +🕴️ +🧑🏻‍🦽 +🧑🏼‍🦽 +🧑🏽‍🦽 +🧑🏾‍🦽 +🧑🏿‍🦽 +🧑‍🦽 +👩🏻‍🦽 +👩🏼‍🦽 +👩🏽‍🦽 +👩🏾‍🦽 +👩🏿‍🦽 +👩‍🦽 +👨🏻‍🦽 +👨🏼‍🦽 +👨🏽‍🦽 +👨🏾‍🦽 +👨🏿‍🦽 +👨‍🦽 +🧑🏻‍🦽‍➡️ +🧑🏼‍🦽‍➡️ +🧑🏽‍🦽‍➡️ +🧑🏾‍🦽‍➡️ +🧑🏿‍🦽‍➡️ +🧑‍🦽‍➡️ +👨🏼‍🦽‍➡️ +👨🏻‍🦽‍➡️ +👨🏽‍🦽‍➡️ +👨🏾‍🦽‍➡️ +👨🏿‍🦽‍➡️ +👨‍🦽‍➡️ +👩🏻‍🦽‍➡️ +👩🏼‍🦽‍➡️ +👩🏽‍🦽‍➡️ +👩🏾‍🦽‍➡️ +👩🏿‍🦽‍➡️ +👩‍🦽‍➡️ +🧑🏻‍🦼 +🧑🏼‍🦼 +🧑🏽‍🦼 +🧑🏾‍🦼 +🧑🏿‍🦼 +🧑‍🦼 +👩🏻‍🦼 +👩🏼‍🦼 +👩🏽‍🦼 +👩🏾‍🦼 +👩🏿‍🦼 +👩‍🦼 +👨🏻‍🦼 +👨🏼‍🦼 +👨🏽‍🦼 +👨🏾‍🦼 +👨🏿‍🦼 +👨‍🦼 +🧑🏻‍🦼‍➡️ +🧑🏼‍🦼‍➡️ +🧑🏽‍🦼‍➡️ +🧑🏾‍🦼‍➡️ +🧑🏿‍🦼‍➡️ +🧑‍🦼‍➡️ +👨🏻‍🦼‍➡️ +👨🏼‍🦼‍➡️ +👨🏽‍🦼‍➡️ +👨🏾‍🦼‍➡️ +👨🏿‍🦼‍➡️ +👨‍🦼‍➡️ +👩🏻‍🦼‍➡️ +👩🏼‍🦼‍➡️ +👩🏽‍🦼‍➡️ +👩🏾‍🦼‍➡️ +👩🏿‍🦼‍➡️ +👩‍🦼‍➡️ +🚶🏻 +🚶🏼 +🚶🏽 +🚶🏾 +🚶🏿 +🚶 +🚶🏻‍♀️ +🚶🏼‍♀️ +🚶🏽‍♀️ +🚶🏾‍♀️ +🚶🏿‍♀️ +🚶‍♀️ +🚶🏻‍♂️ +🚶🏼‍♂️ +🚶🏽‍♂️ +🚶🏾‍♂️ +🚶🏿‍♂️ +🚶‍♂️ +🚶🏻‍➡️ +🚶🏼‍➡️ +🚶🏽‍➡️ +🚶🏾‍➡️ +🚶🏿‍➡️ +🚶‍➡️ +🚶🏻‍♀️‍➡️ +🚶🏼‍♀️‍➡️ +🚶🏽‍♀️‍➡️ +🚶🏾‍♀️‍➡️ +🚶🏿‍♀️‍➡️ +🚶‍♀️‍➡️ +🚶🏻‍♂️‍➡️ +🚶🏼‍♂️‍➡️ +🚶🏽‍♂️‍➡️ +🚶🏾‍♂️‍➡️ +🚶🏿‍♂️‍➡️ +🚶‍♂️‍➡️ +🧑🏻‍🦯 +🧑🏼‍🦯 +🧑🏽‍🦯 +🧑🏾‍🦯 +🧑🏿‍🦯 +🧑‍🦯 +👩🏻‍🦯 +👩🏼‍🦯 +👩🏽‍🦯 +👩🏾‍🦯 +👩🏿‍🦯 +👩‍🦯 +👨🏻‍🦯 +👨🏼‍🦯 +👨🏽‍🦯 +👨🏾‍🦯 +👨🏿‍🦯 +👨‍🦯 +🧑🏻‍🦯‍➡️ +🧑🏼‍🦯‍➡️ +🧑🏽‍🦯‍➡️ +🧑🏾‍🦯‍➡️ +🧑🏿‍🦯‍➡️ +🧑‍🦯‍➡️ +👨🏻‍🦯‍➡️ +👨🏼‍🦯‍➡️ +👨🏽‍🦯‍➡️ +👨🏾‍🦯‍➡️ +👨🏿‍🦯‍➡️ +👨‍🦯‍➡️ +👩🏻‍🦯‍➡️ +👩🏼‍🦯‍➡️ +👩🏽‍🦯‍➡️ +👩🏾‍🦯‍➡️ +👩🏿‍🦯‍➡️ +👩‍🦯‍➡️ +🧎🏻 +🧎🏼 +🧎🏽 +🧎🏾 +🧎🏿 +🧎 +🧎🏻‍♀️ +🧎🏼‍♀️ +🧎🏽‍♀️ +🧎🏾‍♀️ +🧎🏿‍♀️ +🧎‍♀️ +🧎🏻‍♂️ +🧎🏼‍♂️ +🧎🏽‍♂️ +🧎🏾‍♂️ +🧎🏿‍♂️ +🧎‍♂️ +🧎🏻‍➡️ +🧎🏼‍➡️ +🧎🏽‍➡️ +🧎🏾‍➡️ +🧎🏿‍➡️ +🧎‍➡️ +🧎🏻‍♀️‍➡️ +🧎🏼‍♀️‍➡️ +🧎🏽‍♀️‍➡️ +🧎🏾‍♀️‍➡️ +🧎🏿‍♀️‍➡️ +🧎‍♀️‍➡️ +🧎🏻‍♂️‍➡️ +🧎🏼‍♂️‍➡️ +🧎🏽‍♂️‍➡️ +🧎🏾‍♂️‍➡️ +🧎🏿‍♂️‍➡️ +🧎‍♂️‍➡️ +🏃🏻 +🏃🏼 +🏃🏽 +🏃🏾 +🏃🏿 +🏃 +🏃🏻‍♀️ +🏃🏼‍♀️ +🏃🏽‍♀️ +🏃🏾‍♀️ +🏃🏿‍♀️ +🏃‍♀️ +🏃🏻‍♂️ +🏃🏼‍♂️ +🏃🏽‍♂️ +🏃🏾‍♂️ +🏃🏿‍♂️ +🏃‍♂️ +🏃🏻‍➡️ +🏃🏼‍➡️ +🏃🏽‍➡️ +🏃🏾‍➡️ +🏃🏿‍➡️ +🏃‍➡️ +🏃🏻‍♀️‍➡️ +🏃🏼‍♀️‍➡️ +🏃🏽‍♀️‍➡️ +🏃🏾‍♀️‍➡️ +🏃🏿‍♀️‍➡️ +🏃‍♀️‍➡️ +🏃🏻‍♂️‍➡️ +🏃🏼‍♂️‍➡️ +🏃🏽‍♂️‍➡️ +🏃🏾‍♂️‍➡️ +🏃🏿‍♂️‍➡️ +🏃‍♂️‍➡️ +🧍🏻 +🧍🏼 +🧍🏽 +🧍🏾 +🧍🏿 +🧍 +🧍🏻‍♀️ +🧍🏼‍♀️ +🧍🏽‍♀️ +🧍🏾‍♀️ +🧍🏿‍♀️ +🧍‍♀️ +🧍🏻‍♂️ +🧍🏼‍♂️ +🧍🏽‍♂️ +🧍🏾‍♂️ +🧍🏿‍♂️ +🧍‍♂️ +🧑🏻‍🤝‍🧑🏻 +🧑🏻‍🤝‍🧑🏼 +🧑🏻‍🤝‍🧑🏽 +🧑🏻‍🤝‍🧑🏾 +🧑🏻‍🤝‍🧑🏿 +🧑🏼‍🤝‍🧑🏻 +🧑🏼‍🤝‍🧑🏼 +🧑🏼‍🤝‍🧑🏽 +🧑🏼‍🤝‍🧑🏾 +🧑🏼‍🤝‍🧑🏿 +🧑🏽‍🤝‍🧑🏻 +🧑🏽‍🤝‍🧑🏼 +🧑🏽‍🤝‍🧑🏽 +🧑🏽‍🤝‍🧑🏾 +🧑🏽‍🤝‍🧑🏿 +🧑🏾‍🤝‍🧑🏻 +🧑🏾‍🤝‍🧑🏼 +🧑🏾‍🤝‍🧑🏽 +🧑🏾‍🤝‍🧑🏾 +🧑🏾‍🤝‍🧑🏿 +🧑🏿‍🤝‍🧑🏻 +🧑🏿‍🤝‍🧑🏼 +🧑🏿‍🤝‍🧑🏽 +🧑🏿‍🤝‍🧑🏾 +🧑🏿‍🤝‍🧑🏿 +🧑‍🤝‍🧑 +👫🏻 +👩🏻‍🤝‍👨🏼 +👩🏻‍🤝‍👨🏽 +👩🏻‍🤝‍👨🏾 +👩🏻‍🤝‍👨🏿 +👩🏼‍🤝‍👨🏻 +👫🏼 +👩🏼‍🤝‍👨🏽 +👩🏼‍🤝‍👨🏾 +👩🏼‍🤝‍👨🏿 +👩🏽‍🤝‍👨🏻 +👩🏽‍🤝‍👨🏼 +👫🏽 +👩🏽‍🤝‍👨🏾 +👩🏽‍🤝‍👨🏿 +👩🏾‍🤝‍👨🏻 +👩🏾‍🤝‍👨🏼 +👩🏾‍🤝‍👨🏽 +👫🏾 +👩🏾‍🤝‍👨🏿 +👩🏿‍🤝‍👨🏻 +👩🏿‍🤝‍👨🏼 +👩🏿‍🤝‍👨🏽 +👩🏿‍🤝‍👨🏾 +👫🏿 +👫 +👭🏻 +👩🏻‍🤝‍👩🏼 +👩🏻‍🤝‍👩🏽 +👩🏻‍🤝‍👩🏾 +👩🏻‍🤝‍👩🏿 +👩🏼‍🤝‍👩🏻 +👭🏼 +👩🏼‍🤝‍👩🏽 +👩🏼‍🤝‍👩🏾 +👩🏼‍🤝‍👩🏿 +👩🏽‍🤝‍👩🏻 +👩🏽‍🤝‍👩🏼 +👭🏽 +👩🏽‍🤝‍👩🏾 +👩🏽‍🤝‍👩🏿 +👩🏾‍🤝‍👩🏻 +👩🏾‍🤝‍👩🏼 +👩🏾‍🤝‍👩🏽 +👭🏾 +👩🏾‍🤝‍👩🏿 +👩🏿‍🤝‍👩🏻 +👩🏿‍🤝‍👩🏼 +👩🏿‍🤝‍👩🏽 +👩🏿‍🤝‍👩🏾 +👭🏿 +👭 +👬🏻 +👨🏻‍🤝‍👨🏼 +👨🏻‍🤝‍👨🏽 +👨🏻‍🤝‍👨🏾 +👨🏻‍🤝‍👨🏿 +👨🏼‍🤝‍👨🏻 +👬🏼 +👨🏼‍🤝‍👨🏽 +👨🏼‍🤝‍👨🏾 +👨🏼‍🤝‍👨🏿 +👨🏽‍🤝‍👨🏻 +👨🏽‍🤝‍👨🏼 +👬🏽 +👨🏽‍🤝‍👨🏾 +👨🏽‍🤝‍👨🏿 +👨🏾‍🤝‍👨🏻 +👨🏾‍🤝‍👨🏼 +👨🏾‍🤝‍👨🏽 +👬🏾 +👨🏾‍🤝‍👨🏿 +👨🏿‍🤝‍👨🏻 +👨🏿‍🤝‍👨🏼 +👨🏿‍🤝‍👨🏽 +👨🏿‍🤝‍👨🏾 +👬🏿 +👬 +💑🏻 +🧑🏻‍❤️‍🧑🏼 +🧑🏻‍❤️‍🧑🏽 +🧑🏻‍❤️‍🧑🏾 +🧑🏻‍❤️‍🧑🏿 +🧑🏼‍❤️‍🧑🏻 +💑🏼 +🧑🏼‍❤️‍🧑🏽 +🧑🏼‍❤️‍🧑🏾 +🧑🏼‍❤️‍🧑🏿 +🧑🏽‍❤️‍🧑🏻 +🧑🏽‍❤️‍🧑🏼 +💑🏽 +🧑🏽‍❤️‍🧑🏾 +🧑🏽‍❤️‍🧑🏿 +🧑🏾‍❤️‍🧑🏻 +🧑🏾‍❤️‍🧑🏼 +🧑🏾‍❤️‍🧑🏽 +💑🏾 +🧑🏾‍❤️‍🧑🏿 +🧑🏿‍❤️‍🧑🏻 +🧑🏿‍❤️‍🧑🏼 +🧑🏿‍❤️‍🧑🏽 +🧑🏿‍❤️‍🧑🏾 +💑🏿 +💑 +👩🏻‍❤️‍👨🏻 +👩🏻‍❤️‍👨🏼 +👩🏻‍❤️‍👨🏽 +👩🏻‍❤️‍👨🏾 +👩🏻‍❤️‍👨🏿 +👩🏼‍❤️‍👨🏻 +👩🏼‍❤️‍👨🏼 +👩🏼‍❤️‍👨🏽 +👩🏼‍❤️‍👨🏾 +👩🏼‍❤️‍👨🏿 +👩🏽‍❤️‍👨🏻 +👩🏽‍❤️‍👨🏼 +👩🏽‍❤️‍👨🏽 +👩🏽‍❤️‍👨🏾 +👩🏽‍❤️‍👨🏿 +👩🏾‍❤️‍👨🏻 +👩🏾‍❤️‍👨🏼 +👩🏾‍❤️‍👨🏽 +👩🏾‍❤️‍👨🏾 +👩🏾‍❤️‍👨🏿 +👩🏿‍❤️‍👨🏻 +👩🏿‍❤️‍👨🏼 +👩🏿‍❤️‍👨🏽 +👩🏿‍❤️‍👨🏾 +👩🏿‍❤️‍👨🏿 +👩‍❤️‍👨 +👩🏻‍❤️‍👩🏻 +👩🏻‍❤️‍👩🏼 +👩🏻‍❤️‍👩🏽 +👩🏻‍❤️‍👩🏾 +👩🏻‍❤️‍👩🏿 +👩🏼‍❤️‍👩🏻 +👩🏼‍❤️‍👩🏼 +👩🏼‍❤️‍👩🏽 +👩🏼‍❤️‍👩🏾 +👩🏼‍❤️‍👩🏿 +👩🏽‍❤️‍👩🏻 +👩🏽‍❤️‍👩🏼 +👩🏽‍❤️‍👩🏽 +👩🏽‍❤️‍👩🏾 +👩🏽‍❤️‍👩🏿 +👩🏾‍❤️‍👩🏻 +👩🏾‍❤️‍👩🏼 +👩🏾‍❤️‍👩🏽 +👩🏾‍❤️‍👩🏾 +👩🏾‍❤️‍👩🏿 +👩🏿‍❤️‍👩🏻 +👩🏿‍❤️‍👩🏼 +👩🏿‍❤️‍👩🏽 +👩🏿‍❤️‍👩🏾 +👩🏿‍❤️‍👩🏿 +👩‍❤️‍👩 +👨🏻‍❤️‍👨🏻 +👨🏻‍❤️‍👨🏼 +👨🏻‍❤️‍👨🏽 +👨🏻‍❤️‍👨🏾 +👨🏻‍❤️‍👨🏿 +👨🏼‍❤️‍👨🏻 +👨🏼‍❤️‍👨🏼 +👨🏼‍❤️‍👨🏽 +👨🏼‍❤️‍👨🏾 +👨🏼‍❤️‍👨🏿 +👨🏽‍❤️‍👨🏻 +👨🏽‍❤️‍👨🏼 +👨🏽‍❤️‍👨🏽 +👨🏽‍❤️‍👨🏾 +👨🏽‍❤️‍👨🏿 +👨🏾‍❤️‍👨🏻 +👨🏾‍❤️‍👨🏼 +👨🏾‍❤️‍👨🏽 +👨🏾‍❤️‍👨🏾 +👨🏾‍❤️‍👨🏿 +👨🏿‍❤️‍👨🏻 +👨🏿‍❤️‍👨🏼 +👨🏿‍❤️‍👨🏽 +👨🏿‍❤️‍👨🏾 +👨🏿‍❤️‍👨🏿 +👨‍❤️‍👨 +💏🏻 +🧑🏻‍❤️‍💋‍🧑🏼 +🧑🏻‍❤️‍💋‍🧑🏽 +🧑🏻‍❤️‍💋‍🧑🏾 +🧑🏻‍❤️‍💋‍🧑🏿 +🧑🏼‍❤️‍💋‍🧑🏻 +💏🏼 +🧑🏼‍❤️‍💋‍🧑🏽 +🧑🏼‍❤️‍💋‍🧑🏾 +🧑🏼‍❤️‍💋‍🧑🏿 +🧑🏽‍❤️‍💋‍🧑🏻 +🧑🏽‍❤️‍💋‍🧑🏼 +💏🏽 +🧑🏽‍❤️‍💋‍🧑🏾 +🧑🏽‍❤️‍💋‍🧑🏿 +🧑🏾‍❤️‍💋‍🧑🏻 +🧑🏾‍❤️‍💋‍🧑🏼 +🧑🏾‍❤️‍💋‍🧑🏽 +💏🏾 +🧑🏾‍❤️‍💋‍🧑🏿 +🧑🏿‍❤️‍💋‍🧑🏻 +🧑🏿‍❤️‍💋‍🧑🏼 +🧑🏿‍❤️‍💋‍🧑🏽 +🧑🏿‍❤️‍💋‍🧑🏾 +💏🏿 +💏 +👩🏻‍❤️‍💋‍👨🏻 +👩🏻‍❤️‍💋‍👨🏼 +👩🏻‍❤️‍💋‍👨🏽 +👩🏻‍❤️‍💋‍👨🏾 +👩🏻‍❤️‍💋‍👨🏿 +👩🏼‍❤️‍💋‍👨🏻 +👩🏼‍❤️‍💋‍👨🏼 +👩🏼‍❤️‍💋‍👨🏽 +👩🏼‍❤️‍💋‍👨🏾 +👩🏼‍❤️‍💋‍👨🏿 +👩🏽‍❤️‍💋‍👨🏻 +👩🏽‍❤️‍💋‍👨🏼 +👩🏽‍❤️‍💋‍👨🏽 +👩🏽‍❤️‍💋‍👨🏾 +👩🏽‍❤️‍💋‍👨🏿 +👩🏾‍❤️‍💋‍👨🏻 +👩🏾‍❤️‍💋‍👨🏼 +👩🏾‍❤️‍💋‍👨🏽 +👩🏾‍❤️‍💋‍👨🏾 +👩🏾‍❤️‍💋‍👨🏿 +👩🏿‍❤️‍💋‍👨🏻 +👩🏿‍❤️‍💋‍👨🏼 +👩🏿‍❤️‍💋‍👨🏽 +👩🏿‍❤️‍💋‍👨🏾 +👩🏿‍❤️‍💋‍👨🏿 +👩‍❤️‍💋‍👨 +👩🏻‍❤️‍💋‍👩🏻 +👩🏻‍❤️‍💋‍👩🏼 +👩🏻‍❤️‍💋‍👩🏽 +👩🏻‍❤️‍💋‍👩🏾 +👩🏻‍❤️‍💋‍👩🏿 +👩🏼‍❤️‍💋‍👩🏻 +👩🏼‍❤️‍💋‍👩🏼 +👩🏼‍❤️‍💋‍👩🏽 +👩🏼‍❤️‍💋‍👩🏾 +👩🏼‍❤️‍💋‍👩🏿 +👩🏽‍❤️‍💋‍👩🏻 +👩🏽‍❤️‍💋‍👩🏼 +👩🏽‍❤️‍💋‍👩🏽 +👩🏽‍❤️‍💋‍👩🏾 +👩🏽‍❤️‍💋‍👩🏿 +👩🏾‍❤️‍💋‍👩🏻 +👩🏾‍❤️‍💋‍👩🏼 +👩🏾‍❤️‍💋‍👩🏽 +👩🏾‍❤️‍💋‍👩🏾 +👩🏾‍❤️‍💋‍👩🏿 +👩🏿‍❤️‍💋‍👩🏻 +👩🏿‍❤️‍💋‍👩🏼 +👩🏿‍❤️‍💋‍👩🏽 +👩🏿‍❤️‍💋‍👩🏾 +👩🏿‍❤️‍💋‍👩🏿 +👩‍❤️‍💋‍👩 +👨🏻‍❤️‍💋‍👨🏻 +👨🏻‍❤️‍💋‍👨🏼 +👨🏻‍❤️‍💋‍👨🏽 +👨🏻‍❤️‍💋‍👨🏾 +👨🏻‍❤️‍💋‍👨🏿 +👨🏼‍❤️‍💋‍👨🏻 +👨🏼‍❤️‍💋‍👨🏼 +👨🏼‍❤️‍💋‍👨🏽 +👨🏼‍❤️‍💋‍👨🏾 +👨🏼‍❤️‍💋‍👨🏿 +👨🏽‍❤️‍💋‍👨🏻 +👨🏽‍❤️‍💋‍👨🏼 +👨🏽‍❤️‍💋‍👨🏽 +👨🏽‍❤️‍💋‍👨🏾 +👨🏽‍❤️‍💋‍👨🏿 +👨🏾‍❤️‍💋‍👨🏻 +👨🏾‍❤️‍💋‍👨🏼 +👨🏾‍❤️‍💋‍👨🏽 +👨🏾‍❤️‍💋‍👨🏾 +👨🏾‍❤️‍💋‍👨🏿 +👨🏿‍❤️‍💋‍👨🏻 +👨🏿‍❤️‍💋‍👨🏼 +👨🏿‍❤️‍💋‍👨🏽 +👨🏿‍❤️‍💋‍👨🏾 +👨🏿‍❤️‍💋‍👨🏿 +👨‍❤️‍💋‍👨 +🧑‍🧑‍🧒‍🧒 +🧑‍🧑‍🧒 +🧑‍🧒‍🧒 +🧑‍🧒 +👪 +👨‍👩‍👦 +👨‍👩‍👧 +👨‍👩‍👧‍👦 +👨‍👩‍👦‍👦 +👨‍👩‍👧‍👧 +👩‍👩‍👦 +👩‍👩‍👧 +👩‍👩‍👧‍👦 +👩‍👩‍👦‍👦 +👩‍👩‍👧‍👧 +👨‍👨‍👦 +👨‍👨‍👧 +👨‍👨‍👧‍👦 +👨‍👨‍👦‍👦 +👨‍👨‍👧‍👧 +👩‍👦 +👩‍👧 +👩‍👧‍👦 +👩‍👦‍👦 +👩‍👧‍👧 +👨‍👦 +👨‍👧 +👨‍👧‍👦 +👨‍👦‍👦 +👨‍👧‍👧 +🪢 +🧶 +🧵 +🪡 +🧥 +🥼 +🦺 +👚 +👕 +👖 +🩲 +🩳 +👔 +👗 +👙 +🩱 +👘 +🥻 +🩴 +🥿 +👠 +👡 +👢 +👞 +👟 +🥾 +🧦 +🧤 +🧣 +🎩 +🧢 +👒 +🎓 +⛑️ +🪖 +👑 +💍 +👝 +👛 +👜 +💼 +🎒 +🧳 +👓 +🕶️ +🥽 +🌂 +🐶 +🐱 +🐭 +🐹 +🐰 +🦊 +🐻 +🐼 +🐻‍❄️ +🐨 +🐯 +🦁 +🐮 +🐷 +🐽 +🐸 +🐵 +🙈 +🙉 +🙊 +🐒 +🐔 +🐧 +🐦 +🐤 +🐣 +🐥 +🪿 +🦆 +🐦‍⬛ +🦅 +🦉 +🦇 +🐺 +🐗 +🐴 +🦄 +🫎 +🐝 +🪱 +🐛 +🦋 +🐌 +🐞 +🐜 +🪰 +🪲 +🪳 +🦟 +🦗 +🕷️ +🕸️ +🦂 +🐢 +🐍 +🦎 +🦖 +🦕 +🐙 +🦑 +🪼 +🦐 +🦞 +🦀 +🐡 +🐠 +🐟 +🐬 +🐳 +🐋 +🦈 +🦭 +🐊 +🐅 +🐆 +🦓 +🦍 +🦧 +🦣 +🐘 +🦛 +🦏 +🐪 +🐫 +🦒 +🦘 +🦬 +🐃 +🐂 +🐄 +🫏 +🐎 +🐖 +🐏 +🐑 +🦙 +🐐 +🦌 +🐕 +🐩 +🦮 +🐕‍🦺 +🐈 +🐈‍⬛ +🪶 +🪽 +🐓 +🦃 +🦤 +🦚 +🦜 +🦢 +🦩 +🕊️ +🐇 +🦝 +🦨 +🦡 +🦫 +🦦 +🦥 +🐁 +🐀 +🐿️ +🦔 +🐾 +🐉 +🐲 +🐦‍🔥 +🌵 +🎄 +🌲 +🌳 +🌴 +🪵 +🌱 +🌿 +☘️ +🍀 +🎍 +🪴 +🎋 +🍃 +🍂 +🍁 +🪺 +🪹 +🍄 +🍄‍🟫 +🐚 +🪸 +🪨 +🌾 +💐 +🌷 +🌹 +🥀 +🪻 +🪷 +🌺 +🌸 +🌼 +🌻 +🌞 +🌝 +🌛 +🌜 +🌚 +🌕 +🌖 +🌗 +🌘 +🌑 +🌒 +🌓 +🌔 +🌙 +🌎 +🌍 +🌏 +🪐 +💫 +⭐ +🌟 +✨ +⚡ +☄️ +💥 +🔥 +🌪️ +🌈 +☀️ +🌤️ +⛅ +🌥️ +☁️ +🌦️ +🌧️ +⛈️ +🌩️ +🌨️ +❄️ +☃️ +⛄ +🌬️ +💨 +💧 +💦 +🫧 +☔ +☂️ +🌊 +🌫️ +🍏 +🍎 +🍐 +🍊 +🍋 +🍋‍🟩 +🍌 +🍉 +🍇 +🍓 +🫐 +🍈 +🍒 +🍑 +🥭 +🍍 +🥥 +🥝 +🍅 +🍆 +🥑 +🫛 +🥦 +🥬 +🥒 +🌶️ +🫑 +🌽 +🥕 +🫒 +🧄 +🧅 +🥔 +🍠 +🫚 +🥐 +🥯 +🍞 +🥖 +🥨 +🧀 +🥚 +🍳 +🧈 +🥞 +🧇 +🥓 +🥩 +🍗 +🍖 +🦴 +🌭 +🍔 +🍟 +🍕 +🫓 +🥪 +🥙 +🧆 +🌮 +🌯 +🫔 +🥗 +🥘 +🫕 +🥫 +🫙 +🍝 +🍜 +🍲 +🍛 +🍣 +🍱 +🥟 +🦪 +🍤 +🍙 +🍚 +🍘 +🍥 +🥠 +🥮 +🍢 +🍡 +🍧 +🍨 +🍦 +🥧 +🧁 +🍰 +🎂 +🍮 +🍭 +🍬 +🍫 +🍿 +🍩 +🍪 +🌰 +🥜 +🫘 +🍯 +🥛 +🫗 +🍼 +🫖 +☕ +🍵 +🧉 +🧃 +🥤 +🧋 +🍶 +🍺 +🍻 +🥂 +🍷 +🥃 +🍸 +🍹 +🍾 +🧊 +🥄 +🍴 +🍽️ +🥣 +🥡 +🥢 +🧂 +⚽ +🏀 +🏈 +⚾ +🥎 +🎾 +🏐 +🏉 +🥏 +🎱 +🪀 +🏓 +🏸 +🏒 +🏑 +🥍 +🏏 +🪃 +🥅 +⛳ +🪁 +🛝 +🏹 +🎣 +🤿 +🥊 +🥋 +🎽 +🛹 +🛼 +🛷 +⛸️ +🥌 +🎿 +⛷️ +🏂🏻 +🏂🏼 +🏂🏽 +🏂🏾 +🏂🏿 +🏂 +🪂 +🏋🏻 +🏋🏼 +🏋🏽 +🏋🏾 +🏋🏿 +🏋️ +🏋🏻‍♀️ +🏋🏼‍♀️ +🏋🏽‍♀️ +🏋🏾‍♀️ +🏋🏿‍♀️ +🏋️‍♀️ +🏋🏻‍♂️ +🏋🏼‍♂️ +🏋🏽‍♂️ +🏋🏾‍♂️ +🏋🏿‍♂️ +🏋️‍♂️ +🤼 +🤼‍♀️ +🤼‍♂️ +🤸🏻 +🤸🏼 +🤸🏽 +🤸🏾 +🤸🏿 +🤸 +🤸🏻‍♀️ +🤸🏼‍♀️ +🤸🏽‍♀️ +🤸🏾‍♀️ +🤸🏿‍♀️ +🤸‍♀️ +🤸🏻‍♂️ +🤸🏼‍♂️ +🤸🏽‍♂️ +🤸🏾‍♂️ +🤸🏿‍♂️ +🤸‍♂️ +⛹🏻 +⛹🏼 +⛹🏽 +⛹🏾 +⛹🏿 +⛹️ +⛹🏻‍♀️ +⛹🏼‍♀️ +⛹🏽‍♀️ +⛹🏾‍♀️ +⛹🏿‍♀️ +⛹️‍♀️ +⛹🏻‍♂️ +⛹🏼‍♂️ +⛹🏽‍♂️ +⛹🏾‍♂️ +⛹🏿‍♂️ +⛹️‍♂️ +🤺 +🤾🏻 +🤾🏼 +🤾🏽 +🤾🏾 +🤾🏿 +🤾 +🤾🏻‍♀️ +🤾🏼‍♀️ +🤾🏽‍♀️ +🤾🏾‍♀️ +🤾🏿‍♀️ +🤾‍♀️ +🤾🏻‍♂️ +🤾🏼‍♂️ +🤾🏽‍♂️ +🤾🏾‍♂️ +🤾🏿‍♂️ +🤾‍♂️ +🏌🏻 +🏌🏼 +🏌🏽 +🏌🏾 +🏌🏿 +🏌️ +🏌🏻‍♀️ +🏌🏼‍♀️ +🏌🏽‍♀️ +🏌🏾‍♀️ +🏌🏿‍♀️ +🏌️‍♀️ +🏌🏻‍♂️ +🏌🏼‍♂️ +🏌🏽‍♂️ +🏌🏾‍♂️ +🏌🏿‍♂️ +🏌️‍♂️ +🏇🏻 +🏇🏼 +🏇🏽 +🏇🏾 +🏇🏿 +🏇 +🧘🏻 +🧘🏼 +🧘🏽 +🧘🏾 +🧘🏿 +🧘 +🧘🏻‍♀️ +🧘🏼‍♀️ +🧘🏽‍♀️ +🧘🏾‍♀️ +🧘🏿‍♀️ +🧘‍♀️ +🧘🏻‍♂️ +🧘🏼‍♂️ +🧘🏽‍♂️ +🧘🏾‍♂️ +🧘🏿‍♂️ +🧘‍♂️ +🏄🏻 +🏄🏼 +🏄🏽 +🏄🏾 +🏄🏿 +🏄 +🏄🏻‍♀️ +🏄🏼‍♀️ +🏄🏽‍♀️ +🏄🏾‍♀️ +🏄🏿‍♀️ +🏄‍♀️ +🏄🏻‍♂️ +🏄🏼‍♂️ +🏄🏽‍♂️ +🏄🏾‍♂️ +🏄🏿‍♂️ +🏄‍♂️ +🏊🏻 +🏊🏼 +🏊🏽 +🏊🏾 +🏊🏿 +🏊 +🏊🏻‍♀️ +🏊🏼‍♀️ +🏊🏽‍♀️ +🏊🏾‍♀️ +🏊🏿‍♀️ +🏊‍♀️ +🏊🏻‍♂️ +🏊🏼‍♂️ +🏊🏽‍♂️ +🏊🏾‍♂️ +🏊🏿‍♂️ +🏊‍♂️ +🤽🏻 +🤽🏼 +🤽🏽 +🤽🏾 +🤽🏿 +🤽 +🤽🏻‍♀️ +🤽🏼‍♀️ +🤽🏽‍♀️ +🤽🏾‍♀️ +🤽🏿‍♀️ +🤽‍♀️ +🤽🏻‍♂️ +🤽🏼‍♂️ +🤽🏽‍♂️ +🤽🏾‍♂️ +🤽🏿‍♂️ +🤽‍♂️ +🚣🏻 +🚣🏼 +🚣🏽 +🚣🏾 +🚣🏿 +🚣 +🚣🏻‍♀️ +🚣🏼‍♀️ +🚣🏽‍♀️ +🚣🏾‍♀️ +🚣🏿‍♀️ +🚣‍♀️ +🚣🏻‍♂️ +🚣🏼‍♂️ +🚣🏽‍♂️ +🚣🏾‍♂️ +🚣🏿‍♂️ +🚣‍♂️ +🧗🏻 +🧗🏼 +🧗🏽 +🧗🏾 +🧗🏿 +🧗 +🧗🏻‍♀️ +🧗🏼‍♀️ +🧗🏽‍♀️ +🧗🏾‍♀️ +🧗🏿‍♀️ +🧗‍♀️ +🧗🏻‍♂️ +🧗🏼‍♂️ +🧗🏽‍♂️ +🧗🏾‍♂️ +🧗🏿‍♂️ +🧗‍♂️ +🚵🏻 +🚵🏼 +🚵🏽 +🚵🏾 +🚵🏿 +🚵 +🚵🏻‍♀️ +🚵🏼‍♀️ +🚵🏽‍♀️ +🚵🏾‍♀️ +🚵🏿‍♀️ +🚵‍♀️ +🚵🏻‍♂️ +🚵🏼‍♂️ +🚵🏽‍♂️ +🚵🏾‍♂️ +🚵🏿‍♂️ +🚵‍♂️ +🚴🏻 +🚴🏼 +🚴🏽 +🚴🏾 +🚴🏿 +🚴 +🚴🏻‍♀️ +🚴🏼‍♀️ +🚴🏽‍♀️ +🚴🏾‍♀️ +🚴🏿‍♀️ +🚴‍♀️ +🚴🏻‍♂️ +🚴🏼‍♂️ +🚴🏽‍♂️ +🚴🏾‍♂️ +🚴🏿‍♂️ +🚴‍♂️ +🏆 +🥇 +🥈 +🥉 +🏅 +🎖️ +🏵️ +🎗️ +🎫 +🎟️ +🎪 +🤹🏻 +🤹🏼 +🤹🏽 +🤹🏾 +🤹🏿 +🤹 +🤹🏻‍♀️ +🤹🏼‍♀️ +🤹🏽‍♀️ +🤹🏾‍♀️ +🤹🏿‍♀️ +🤹‍♀️ +🤹🏻‍♂️ +🤹🏼‍♂️ +🤹🏽‍♂️ +🤹🏾‍♂️ +🤹🏿‍♂️ +🤹‍♂️ +🎭 +🩰 +🎨 +🎬 +🎤 +🎧 +🎼 +🎹 +🪇 +🥁 +🪘 +🎷 +🎺 +🪗 +🎸 +🪕 +🎻 +🪈 +🎲 +♟️ +🎯 +🎳 +🎮 +🎰 +🧩 +🚗 +🚕 +🚙 +🛻 +🚐 +🚌 +🚎 +🏎️ +🚓 +🚑 +🚒 +🚚 +🚛 +🚜 +🦯 +🦽 +🦼 +🩼 +🛴 +🚲 +🛵 +🏍️ +🛺 +🛞 +🚨 +🚔 +🚍 +🚘 +🚖 +🚡 +🚠 +🚟 +🚃 +🚋 +🚞 +🚝 +🚄 +🚅 +🚈 +🚂 +🚆 +🚇 +🚊 +🚉 +✈️ +🛫 +🛬 +🛩️ +💺 +🛰️ +🚀 +🛸 +🚁 +🛶 +⛵ +🚤 +🛥️ +🛳️ +⛴️ +🚢 +🛟 +⚓ +🪝 +⛽ +🚧 +🚦 +🚥 +🚏 +🗺️ +🗿 +🗽 +🗼 +🏰 +🏯 +🏟️ +🎡 +🎢 +🎠 +⛲ +⛱️ +🏖️ +🏝️ +🏜️ +🌋 +⛰️ +🏔️ +🗻 +🏕️ +⛺ +🏠 +🏡 +🏘️ +🏚️ +🛖 +🏗️ +🏭 +🏢 +🏬 +🏣 +🏤 +🏥 +🏦 +🏨 +🏪 +🏫 +🏩 +💒 +🏛️ +⛪ +🕌 +🕍 +🛕 +🕋 +⛩️ +🛤️ +🛣️ +🗾 +🎑 +🏞️ +🌅 +🌄 +🌠 +🎇 +🎆 +🌇 +🌆 +🏙️ +🌃 +🌌 +🌉 +🌁 +⌚ +📱 +📲 +💻 +⌨️ +🖥️ +🖨️ +🖱️ +🖲️ +🕹️ +🗜️ +💽 +💾 +💿 +📀 +📼 +📷 +📸 +📹 +🎥 +📽️ +🎞️ +📞 +☎️ +📟 +📠 +📺 +📻 +🎙️ +🎚️ +🎛️ +🧭 +⏱️ +⏲️ +⏰ +🕰️ +⌛ +⏳ +📡 +🔋 +🪫 +🔌 +💡 +🔦 +🕯️ +🪔 +🧯 +🛢️ +💸 +💵 +💴 +💶 +💷 +🪙 +💰 +💳 +🪪 +💎 +⚖️ +🪜 +🧰 +🪛 +🔧 +🔨 +⚒️ +🛠️ +⛏️ +🪚 +🔩 +⚙️ +🪤 +🧱 +⛓️ +🔗 +⛓️‍💥 +🧲 +🔫 +💣 +🧨 +🪓 +🔪 +🗡️ +⚔️ +🛡️ +🚬 +⚰️ +🪦 +⚱️ +🏺 +🔮 +📿 +🧿 +🪬 +💈 +⚗️ +🔭 +🔬 +🕳️ +🩻 +🩹 +🩺 +💊 +💉 +🩸 +🧬 +🦠 +🧫 +🧪 +🌡️ +🧹 +🪠 +🧺 +🧻 +🚽 +🚰 +🚿 +🛁 +🛀🏻 +🛀🏼 +🛀🏽 +🛀🏾 +🛀🏿 +🛀 +🧼 +🪥 +🪒 +🪮 +🧽 +🪣 +🧴 +🛎️ +🔑 +🗝️ +🚪 +🪑 +🛋️ +🛏️ +🛌🏻 +🛌🏼 +🛌🏽 +🛌🏾 +🛌🏿 +🛌 +🧸 +🪆 +🖼️ +🪞 +🪟 +🛍️ +🛒 +🎁 +🎈 +🎏 +🎀 +🪄 +🪅 +🎊 +🎉 +🎎 +🪭 +🏮 +🎐 +🪩 +🧧 +✉️ +📩 +📨 +📧 +💌 +📥 +📤 +📦 +🏷️ +🪧 +📪 +📫 +📬 +📭 +📮 +📯 +📜 +📃 +📄 +📑 +🧾 +📊 +📈 +📉 +🗒️ +🗓️ +📆 +📅 +🗑️ +📇 +🗃️ +🗳️ +🗄️ +📋 +📁 +📂 +🗂️ +🗞️ +📰 +📓 +📔 +📒 +📕 +📗 +📘 +📙 +📚 +📖 +🔖 +🧷 +📎 +🖇️ +📐 +📏 +🧮 +📌 +📍 +✂️ +🖊️ +🖋️ +✒️ +🖌️ +🖍️ +📝 +✏️ +🔍 +🔎 +🔏 +🔐 +🔒 +🔓 +🩷 +❤️ +🧡 +💛 +💚 +🩵 +💙 +💜 +🖤 +🩶 +🤍 +🤎 +💔 +❣️ +💕 +💞 +💓 +💗 +💖 +💘 +💝 +❤️‍🩹 +❤️‍🔥 +💟 +☮️ +✝️ +☪️ +🕉️ +☸️ +🪯 +✡️ +🔯 +🕎 +☯️ +☦️ +🛐 +⛎ +♈ +♉ +♊ +♋ +♌ +♍ +♎ +♏ +♐ +♑ +♒ +♓ +🆔 +⚛️ +🉑 +☢️ +☣️ +📴 +📳 +🈶 +🈚 +🈸 +🈺 +🈷️ +✴️ +🆚 +💮 +🉐 +㊙️ +㊗️ +🈴 +🈵 +🈹 +🈲 +🅰️ +🅱️ +🆎 +🆑 +🅾️ +🆘 +❌ +⭕ +🛑 +⛔ +📛 +🚫 +💯 +💢 +♨️ +🚷 +🚯 +🚳 +🚱 +🔞 +📵 +🚭 +❗ +❕ +❓ +❔ +‼️ +⁉️ +🔅 +🔆 +〽️ +⚠️ +🚸 +🔱 +⚜️ +🔰 +♻️ +✅ +🈯 +💹 +❇️ +✳️ +❎ +🌐 +💠 +Ⓜ️ +🌀 +💤 +🏧 +🚾 +♿ +🅿️ +🛗 +🈳 +🈂️ +🛂 +🛃 +🛄 +🛅 +🛜 +🚹 +🚺 +🚼 +🚻 +🚮 +🎦 +📶 +🈁 +🔣 +ℹ️ +🔤 +🔡 +🔠 +🆖 +🆗 +🆙 +🆒 +🆕 +🆓 +0️⃣ +1️⃣ +2️⃣ +3️⃣ +4️⃣ +5️⃣ +6️⃣ +7️⃣ +8️⃣ +9️⃣ +🔟 +🔢 +#️⃣ +*️⃣ +⏏️ +▶️ +⏸️ +⏯️ +⏹️ +⏺️ +⏭️ +⏮️ +⏩ +⏪ +⏫ +⏬ +◀️ +🔼 +🔽 +➡️ +⬅️ +⬆️ +⬇️ +↗️ +↘️ +↙️ +↖️ +↕️ +↔️ +↪️ +↩️ +⤴️ +⤵️ +🔀 +🔁 +🔂 +🔄 +🔃 +🎵 +🎶 +➕ +➖ +➗ +✖️ +🟰 +♾️ +💲 +💱 +™️ +©️ +®️ +〰️ +➰ +➿ +🔚 +🔙 +🔛 +🔝 +🔜 +✔️ +☑️ +🔘 +⚪ +⚫ +🔴 +🔵 +🟤 +🟣 +🟢 +🟡 +🟠 +🔺 +🔻 +🔸 +🔹 +🔶 +🔷 +🔳 +🔲 +▪️ +▫️ +◾ +◽ +◼️ +◻️ +⬛ +⬜ +🟧 +🟦 +🟥 +🟫 +🟪 +🟩 +🟨 +🔈 +🔇 +🔉 +🔊 +🔔 +🔕 +📣 +📢 +🗨️ +👁‍🗨 +💬 +💭 +🗯️ +♠️ +♣️ +♥️ +♦️ +🃏 +🎴 +🀄 +🕐 +🕑 +🕒 +🕓 +🕔 +🕕 +🕖 +🕗 +🕘 +🕙 +🕚 +🕛 +🕜 +🕝 +🕞 +🕟 +🕠 +🕡 +🕢 +🕣 +🕤 +🕥 +🕦 +🕧 +♀️ +♂️ +⚧ +⚕️ +🇿 +🇾 +🇽 +🇼 +🇻 +🇺 +🇹 +🇸 +🇷 +🇶 +🇵 +🇴 +🇳 +🇲 +🇱 +🇰 +🇯 +🇮 +🇭 +🇬 +🇫 +🇪 +🇩 +🇨 +🇧 +🇦 +🏳️ +🏴 +🏴‍☠️ +🏁 +🚩 +🏳️‍🌈 +🏳️‍⚧️ +🇺🇳 +🇦🇫 +🇦🇽 +🇦🇱 +🇩🇿 +🇦🇸 +🇦🇩 +🇦🇴 +🇦🇮 +🇦🇶 +🇦🇬 +🇦🇷 +🇦🇲 +🇦🇼 +🇦🇺 +🇦🇹 +🇦🇿 +🇧🇸 +🇧🇭 +🇧🇩 +🇧🇧 +🇧🇾 +🇧🇪 +🇧🇿 +🇧🇯 +🇧🇲 +🇧🇹 +🇧🇴 +🇧🇦 +🇧🇼 +🇧🇷 +🇮🇴 +🇻🇬 +🇧🇳 +🇧🇬 +🇧🇫 +🇧🇮 +🇰🇭 +🇨🇲 +🇨🇦 +🇮🇨 +🇨🇻 +🇧🇶 +🇰🇾 +🇨🇫 +🇹🇩 +🇨🇱 +🇨🇳 +🇨🇽 +🇨🇨 +🇨🇴 +🇰🇲 +🇨🇬 +🇨🇩 +🇨🇰 +🇨🇷 +🇨🇮 +🇭🇷 +🇨🇺 +🇨🇼 +🇨🇾 +🇨🇿 +🇩🇰 +🇩🇯 +🇩🇲 +🇩🇴 +🇪🇨 +🇪🇬 +🇸🇻 +🇬🇶 +🇪🇷 +🇪🇪 +🇪🇹 +🇪🇺 +🇫🇰 +🇫🇴 +🇫🇯 +🇫🇮 +🇫🇷 +🇬🇫 +🇵🇫 +🇹🇫 +🇬🇦 +🇬🇲 +🇬🇪 +🇩🇪 +🇬🇭 +🇬🇮 +🇬🇷 +🇬🇱 +🇬🇩 +🇬🇵 +🇬🇺 +🇬🇹 +🇬🇬 +🇬🇳 +🇬🇼 +🇬🇾 +🇭🇹 +🇭🇳 +🇭🇰 +🇭🇺 +🇮🇸 +🇮🇳 +🇮🇩 +🇮🇷 +🇮🇶 +🇮🇪 +🇮🇲 +🇮🇱 +🇮🇹 +🇯🇲 +🇯🇵 +🎌 +🇯🇪 +🇯🇴 +🇰🇿 +🇰🇪 +🇰🇮 +🇽🇰 +🇰🇼 +🇰🇬 +🇱🇦 +🇱🇻 +🇱🇧 +🇱🇸 +🇱🇷 +🇱🇾 +🇱🇮 +🇱🇹 +🇱🇺 +🇲🇴 +🇲🇰 +🇲🇬 +🇲🇼 +🇲🇾 +🇲🇻 +🇲🇱 +🇲🇹 +🇲🇭 +🇲🇶 +🇲🇷 +🇲🇺 +🇾🇹 +🇲🇽 +🇫🇲 +🇲🇩 +🇲🇨 +🇲🇳 +🇲🇪 +🇲🇸 +🇲🇦 +🇲🇿 +🇲🇲 +🇳🇦 +🇳🇷 +🇳🇵 +🇳🇱 +🇳🇨 +🇳🇿 +🇳🇮 +🇳🇪 +🇳🇬 +🇳🇺 +🇳🇫 +🇰🇵 +🇲🇵 +🇳🇴 +🇴🇲 +🇵🇰 +🇵🇼 +🇵🇸 +🇵🇦 +🇵🇬 +🇵🇾 +🇵🇪 +🇵🇭 +🇵🇳 +🇵🇱 +🇵🇹 +🇵🇷 +🇶🇦 +🇷🇪 +🇷🇴 +🇷🇺 +🇷🇼 +🇼🇸 +🇸🇲 +🇸🇹 +🇸🇦 +🇸🇳 +🇷🇸 +🇸🇨 +🇸🇱 +🇸🇬 +🇸🇽 +🇸🇰 +🇸🇮 +🇬🇸 +🇸🇧 +🇸🇴 +🇿🇦 +🇰🇷 +🇸🇸 +🇪🇸 +🇱🇰 +🇧🇱 +🇸🇭 +🇰🇳 +🇱🇨 +🇵🇲 +🇻🇨 +🇸🇩 +🇸🇷 +🇸🇿 +🇸🇪 +🇨🇭 +🇸🇾 +🇹🇼 +🇹🇯 +🇹🇿 +🇹🇭 +🇹🇱 +🇹🇬 +🇹🇰 +🇹🇴 +🇹🇹 +🇹🇳 +🇹🇷 +🇹🇲 +🇹🇨 +🇻🇮 +🇹🇻 +🇺🇬 +🇺🇦 +🇦🇪 +🇬🇧 +🏴󠁧󠁢󠁥󠁮󠁧󠁿 +🏴󠁧󠁢󠁳󠁣󠁴󠁿 +🏴󠁧󠁢󠁷󠁬󠁳󠁿 +🇺🇸 +🇺🇾 +🇺🇿 +🇻🇺 +🇻🇦 +🇻🇪 +🇻🇳 +🇼🇫 +🇪🇭 +🇾🇪 +🇿🇲 +🇿🇼 +🇦🇨 +🇧🇻 +🇨🇵 +🇪🇦 +🇩🇬 +🇭🇲 +🇲🇫 +🇸🇯 +🇹🇦 +🇺🇲 \ No newline at end of file diff --git a/test/test.js b/test/test.js index 591b64b..69e3d48 100644 --- a/test/test.js +++ b/test/test.js @@ -141,6 +141,7 @@ file._actuallyUploadDiscordFileToMxc = function(url, res) { throw new Error(`Not require("../src/d2m/converters/user-to-mxid.test") require("../src/m2d/converters/diff-pins.test") require("../src/m2d/converters/event-to-message.test") + require("../src/m2d/converters/emoji.test") require("../src/m2d/converters/utils.test") require("../src/m2d/converters/emoji-sheet.test") require("../src/discord/interactions/invite.test") From 5b06d5984ac91d830f161fbbcf72b6ba5ff9d11e Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Mon, 20 Jan 2025 02:33:24 +1300 Subject: [PATCH 004/148] Do cache space members in member_cache --- scripts/setup.js | 4 ++-- src/d2m/actions/register-pk-user.js | 2 +- src/d2m/actions/register-user.js | 2 +- src/d2m/converters/user-to-mxid.js | 2 +- src/db/migrations/0016-foreign-keys.sql | 23 +++------------------ src/m2d/converters/event-to-message.js | 19 ++++++++++------- src/m2d/converters/event-to-message.test.js | 14 ++++++------- src/m2d/event-dispatcher.js | 4 ++-- test/ooye-test-data.sql | 22 ++++++++++---------- 9 files changed, 40 insertions(+), 52 deletions(-) diff --git a/scripts/setup.js b/scripts/setup.js index 3491106..6ee8c19 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 botID = Buffer.from(reg.ooye.discord_token.split(".")[0], "base64").toString() - db.prepare("INSERT OR IGNORE INTO sim (user_id, sim_name, localpart, mxid) VALUES (?, ?, ?, ?)").run(botID, reg.sender_localpart.slice(reg.ooye.namespace_prefix.length), reg.sender_localpart, mxid) + const client = await discord.snow.user.getSelf() + db.prepare("INSERT INTO sim (user_id, username, sim_name, mxid) VALUES (?, ?, ?, ?) ON CONFLICT DO NOTHING").run(client.id, client.username, reg.sender_localpart.slice(reg.ooye.namespace_prefix.length), mxid) console.log("✅ Database is ready...") diff --git a/src/d2m/actions/register-pk-user.js b/src/d2m/actions/register-pk-user.js index 411fcf8..7083006 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, sim_name, localpart, mxid) VALUES (?, ?, ?, ?)").run(pkMessage.member.uuid, simName, localpart, mxid) + db.prepare("INSERT INTO sim (user_id, username, sim_name, mxid) VALUES (?, ?, ?, ?)").run(pkMessage.member.uuid, simName, simName, mxid) // Register matrix user with that name try { diff --git a/src/d2m/actions/register-user.js b/src/d2m/actions/register-user.js index b914d41..c3bd16c 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, sim_name, localpart, mxid) VALUES (?, ?, ?, ?)").run(user.id, simName, localpart, mxid) + db.prepare("INSERT INTO sim (user_id, username, sim_name, mxid) VALUES (?, ?, ?, ?)").run(user.id, user.username, simName, mxid) // Register matrix user with that name try { diff --git a/src/d2m/converters/user-to-mxid.js b/src/d2m/converters/user-to-mxid.js index a619b36..e0ab137 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", "sim_name", {user_id: user.id}).pluck().get() + const existing = select("sim", "user_id", {user_id: user.id}).pluck().get() assert.equal(existing, null, "Shouldn't try to create a new name for an existing sim") // 2. Register based on username (could be new or old format) diff --git a/src/db/migrations/0016-foreign-keys.sql b/src/db/migrations/0016-foreign-keys.sql index c41f329..7a2b26c 100644 --- a/src/db/migrations/0016-foreign-keys.sql +++ b/src/db/migrations/0016-foreign-keys.sql @@ -86,24 +86,6 @@ DROP TABLE guild_space; -- 7 ALTER TABLE new_guild_space RENAME TO guild_space; --- *** member_cache *** - --- 4 -CREATE TABLE "new_member_cache" ( - "room_id" TEXT NOT NULL, - "mxid" TEXT NOT NULL, - "displayname" TEXT, - "avatar_url" TEXT, power_level INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY("room_id","mxid"), - FOREIGN KEY("room_id") REFERENCES "channel_room"("room_id") ON DELETE CASCADE -) WITHOUT ROWID; --- 5 -INSERT INTO new_member_cache (room_id, mxid, displayname, avatar_url) SELECT room_id, mxid, displayname, avatar_url FROM member_cache WHERE room_id IN (SELECT room_id FROM channel_room); --- 6 -DROP TABLE member_cache; --- 7 -ALTER TABLE new_member_cache RENAME TO member_cache; - -- *** reaction *** -- 4 @@ -142,15 +124,16 @@ 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 drop the localpart column. no foreign keys needed +-- 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, sim_name, mxid) SELECT user_id, sim_name, mxid FROM sim; +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 diff --git a/src/m2d/converters/event-to-message.js b/src/m2d/converters/event-to-message.js index c498f1e..9a17817 100644 --- a/src/m2d/converters/event-to-message.js +++ b/src/m2d/converters/event-to-message.js @@ -258,13 +258,18 @@ async function getMemberFromCacheOrHomeserver(roomID, mxid, api) { const row = select("member_cache", ["displayname", "avatar_url"], {room_id: roomID, mxid}).get() if (row) return row return api.getStateEvent(roomID, "m.room.member", mxid).then(event => { - 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 - ) + const room = select("channel_room", "room_id", {room_id: roomID}).get() + if (room) { + // save the member to the cache so we don't have to check with the homeserver next time + // the cache will be kept in sync by the `m.room.member` event listener + const displayname = event?.displayname || null + const avatar_url = event?.avatar_url || null + db.prepare("INSERT INTO member_cache (room_id, mxid, displayname, avatar_url) VALUES (?, ?, ?, ?) ON CONFLICT DO UPDATE SET displayname = ?, avatar_url = ?").run( + roomID, mxid, + displayname, avatar_url, + displayname, avatar_url + ) + } return event }).catch(() => { return {displayname: null, avatar_url: null} diff --git a/src/m2d/converters/event-to-message.test.js b/src/m2d/converters/event-to-message.test.js index 0dc9110..cc3d19a 100644 --- a/src/m2d/converters/event-to-message.test.js +++ b/src/m2d/converters/event-to-message.test.js @@ -3535,8 +3535,8 @@ test("event2message: does not cache the member if the room is not known", async }, event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", origin_server_ts: 1688301929913, - room_id: "!should_be_newly_cached:cadence.moe", - sender: "@should_be_newly_cached:cadence.moe", + room_id: "!not_real:cadence.moe", + sender: "@should_not_be_cached:cadence.moe", type: "m.room.message", unsigned: { age: 405299 @@ -3545,9 +3545,9 @@ test("event2message: does not cache the member if the room is not known", async api: { getStateEvent: async (roomID, type, stateKey) => { called++ - t.equal(roomID, "!should_be_newly_cached:cadence.moe") + t.equal(roomID, "!not_real:cadence.moe") t.equal(type, "m.room.member") - t.equal(stateKey, "@should_be_newly_cached:cadence.moe") + t.equal(stateKey, "@should_not_be_cached:cadence.moe") return { avatar_url: "mxc://cadence.moe/this_is_the_avatar" } @@ -3559,9 +3559,9 @@ test("event2message: does not cache the member if the room is not known", async messagesToDelete: [], messagesToEdit: [], messagesToSend: [{ - username: "should_be_newly_cached", + username: "should_not_be_cached", content: "testing the member state cache", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/this_is_the_avatar", allowed_mentions: { parse: ["users", "roles"] } @@ -3569,7 +3569,7 @@ test("event2message: does not cache the member if the room is not known", async } ) - t.deepEqual(select("member_cache", ["avatar_url", "displayname", "mxid"], {room_id: "!should_be_newly_cached:cadence.moe"}).all(), []) + t.deepEqual(select("member_cache", ["avatar_url", "displayname", "mxid"], {room_id: "!not_real:cadence.moe"}).all(), []) t.equal(called, 1, "getStateEvent should be called once") }) diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index 6cbd6c6..fc2a558 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -205,8 +205,8 @@ async event => { return db.prepare("DELETE FROM member_cache WHERE room_id = ? and mxid = ?").run(event.room_id, event.state_key) } - const room = select("channel_room", "room_id", {room_id: event.room_id}) - if (!room) return // don't cache members in unbridged rooms + 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 diff --git a/test/ooye-test-data.sql b/test/ooye-test-data.sql index 757ef9b..4a7d2f4 100644 --- a/test/ooye-test-data.sql +++ b/test/ooye-test-data.sql @@ -19,17 +19,17 @@ INSERT INTO channel_room (channel_id, room_id, name, nick, thread_parent, custom ('489237891895768942', '!tnedrGVYKFNUdnegvf:tchncs.de', 'ex-room-doesnt-exist-any-more', NULL, NULL, NULL), ('1160894080998461480', '!TqlyQmifxGUggEmdBN:cadence.moe', 'ooyexperiment', NULL, NULL, NULL); -INSERT INTO sim (user_id, sim_name, mxid) VALUES -('0', 'bot', '@_ooye_bot:cadence.moe'), -('820865262526005258', 'crunch_god', '@_ooye_crunch_god:cadence.moe'), -('771520384671416320', 'bojack_horseman', '@_ooye_bojack_horseman:cadence.moe'), -('112890272819507200', '.wing.', '@_ooye_.wing.:cadence.moe'), -('114147806469554185', 'extremity', '@_ooye_extremity:cadence.moe'), -('111604486476181504', 'kyuugryphon', '@_ooye_kyuugryphon:cadence.moe'), -('1109360903096369153', 'amanda', '@_ooye_amanda:cadence.moe'), -('43d378d5-1183-47dc-ab3c-d14e21c3fe58', '_pk_zoego', '@_ooye__pk_zoego:cadence.moe'), -('320067006521147393', 'papiophidian', '@_ooye_papiophidian:cadence.moe'), -('772659086046658620', 'cadence', '@_ooye_cadence:cadence.moe'); +INSERT INTO sim (user_id, username, sim_name, mxid) VALUES +('0', 'Matrix Bridge', 'bot', '@_ooye_bot:cadence.moe'), +('820865262526005258', 'Crunch God', 'crunch_god', '@_ooye_crunch_god:cadence.moe'), +('771520384671416320', 'Bojack Horseman', 'bojack_horseman', '@_ooye_bojack_horseman:cadence.moe'), +('112890272819507200', 'wing', '.wing.', '@_ooye_.wing.:cadence.moe'), +('114147806469554185', 'extremity', 'extremity', '@_ooye_extremity:cadence.moe'), +('111604486476181504', 'kyuugryphon', 'kyuugryphon', '@_ooye_kyuugryphon:cadence.moe'), +('1109360903096369153', 'Amanda', 'amanda', '@_ooye_amanda:cadence.moe'), +('43d378d5-1183-47dc-ab3c-d14e21c3fe58', '_pk_zoego', '_pk_zoego', '@_ooye__pk_zoego:cadence.moe'), +('320067006521147393', 'papiophidian', 'papiophidian', '@_ooye_papiophidian:cadence.moe'), +('772659086046658620', 'cadence.worm', 'cadence', '@_ooye_cadence:cadence.moe'); INSERT INTO sim_member (mxid, room_id, hashed_profile_content) VALUES ('@_ooye_bojack_horseman:cadence.moe', '!hYnGGlPHlbujVVfktC:cadence.moe', NULL), From eadefef6a357840c9cf44d30ca92e459f2c831b9 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Tue, 21 Jan 2025 15:08:12 +1300 Subject: [PATCH 005/148] Clean up member_cache when unbridging --- src/d2m/actions/create-room.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/d2m/actions/create-room.js b/src/d2m/actions/create-room.js index 64e2b68..e0d019f 100644 --- a/src/d2m/actions/create-room.js +++ b/src/d2m/actions/create-room.js @@ -438,6 +438,7 @@ async function unbridgeDeletedChannel(channel, guildID) { } // 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) } From a579b509d31c2fce0d49d343e834e96a38e31043 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Tue, 28 Jan 2025 16:08:43 +1300 Subject: [PATCH 006/148] Catch PK API network errors --- src/d2m/actions/register-pk-user.js | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/d2m/actions/register-pk-user.js b/src/d2m/actions/register-pk-user.js index 7083006..ce1665c 100644 --- a/src/d2m/actions/register-pk-user.js +++ b/src/d2m/actions/register-pk-user.js @@ -146,15 +146,20 @@ async function fetchMessage(messageID) { // Their backend is weird. Sometimes it says "message not found" (code 20006) on the first try, so we make multiple attempts. let attempts = 0 do { - var res = await fetch(`https://api.pluralkit.me/v2/messages/${messageID}`) - if (res.ok) return res.json() + try { + var res = await fetch(`https://api.pluralkit.me/v2/messages/${messageID}`) + if (res.ok) return res.json() + var errorGetter = res.json + } catch (e) { + // Catch any network issues too. + errorGetter = e.toString + } // I think the backend needs some time to update. - await new Promise(resolve => setTimeout(resolve, 2000)) + await new Promise(resolve => setTimeout(resolve, 1500)) } while (++attempts < 3) - const errorMessage = await res.json() - throw new Error(`PK API returned an error after ${attempts} tries: ${JSON.stringify(errorMessage)}`) + throw new Error(`PK API returned an error after ${attempts} tries: ${JSON.stringify(await errorGetter())}`) } module.exports._memberToStateContent = memberToStateContent From 6fe8c60f116cfc3eb3b306d99fe0d0823e7b8951 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Thu, 30 Jan 2025 15:34:29 +1300 Subject: [PATCH 007/148] Add analyze of new data --- src/db/migrations/0017-analyze.sql | 225 +++++++++++++++++++++++++++++ 1 file changed, 225 insertions(+) create mode 100644 src/db/migrations/0017-analyze.sql diff --git a/src/db/migrations/0017-analyze.sql b/src/db/migrations/0017-analyze.sql new file mode 100644 index 0000000..802fca2 --- /dev/null +++ b/src/db/migrations/0017-analyze.sql @@ -0,0 +1,225 @@ +-- https://www.sqlite.org/lang_analyze.html + +BEGIN TRANSACTION; + +ANALYZE sqlite_schema; + +DELETE FROM "sqlite_stat1"; +INSERT INTO "sqlite_stat1" ("tbl","idx","stat") VALUES ('sim','sim','625 1'), + ('reaction','reaction','3242 1'), + ('channel_room','channel_room','389 1'), + ('channel_room','sqlite_autoindex_channel_room_1','389 1'), + ('media_proxy','media_proxy','5068 1'), + ('sim_proxy','sim_proxy','36 1'), + ('webhook','webhook','155 1'), + ('member_cache','member_cache','784 3 1'), + ('member_power','member_power','1 1 1'), + ('file','file','21862 1'), + ('message_channel','message_channel','366884 1'), + ('lottie','lottie','19 1'), + ('event_message','event_message','382920 1 1'), + ('migration',NULL,'1'), + ('sim_member','sim_member','2871 7 1'), + ('guild_space','guild_space','32 1'), + ('guild_active','guild_active','34 1'), + ('emoji','emoji','2563 1'), + ('auto_emoji','auto_emoji','3 1'); + +DELETE FROM "sqlite_stat4"; +INSERT INTO "sqlite_stat4" ("tbl","idx","neq","nlt","ndlt","sample") VALUES ('sim','sim','1','69','69',X'0231313137363631373038303932333039353039'), + ('sim','sim','1','139','139',X'0231313530383936363934333439373931323332'), + ('sim','sim','1','209','209',X'0231323231383737363334373737323139303732'), + ('sim','sim','1','279','279',X'0231333039313431353735353334313136383636'), + ('sim','sim','1','349','349',X'0231333935343433383235363034313635363434'), + ('sim','sim','1','419','419',X'0231353335363239373830383338353134373030'), + ('sim','sim','1','489','489',X'0231363930333339333730353930363636383034'), + ('sim','sim','1','559','559',X'0231383535353736303637393137323137383133'), + ('reaction','reaction','1','360','360',X'020699d5faceefb5fb4f'), + ('reaction','reaction','1','721','721',X'0206b61095e98b6b2fb1'), + ('reaction','reaction','1','1082','1082',X'0206d1dcb418603a5eaa'), + ('reaction','reaction','1','1443','1443',X'0206ef9fc42b9df746ad'), + ('reaction','reaction','1','1804','1804',X'02060f38c1f98f130605'), + ('reaction','reaction','1','2165','2165',X'02062b53df6dab7b1067'), + ('reaction','reaction','1','2526','2526',X'020645dd7e7f60c4aac7'), + ('reaction','reaction','1','2887','2887',X'0206658d2fe735805979'), + ('channel_room','channel_room','1','43','43',X'023331313434393131333330393139333231363330'), + ('channel_room','channel_room','1','87','87',X'023331313835343033343830303934303335393738'), + ('channel_room','channel_room','1','131','131',X'023331323139353036353836343139303638393839'), + ('channel_room','channel_room','1','175','175',X'023331323336353538333034323331303334393630'), + ('channel_room','channel_room','1','219','219',X'023331323933373932323135333930323234343235'), + ('channel_room','channel_room','1','263','263',X'023331333333323139363936393333323038303937'), + ('channel_room','channel_room','1','307','307',X'0231343835363635393733363433333738363938'), + ('channel_room','channel_room','1','351','351',X'0231373039303432313039353632323234363731'), + ('channel_room','sqlite_autoindex_channel_room_1','1 1','6 6','6 6',X'034b3321416a6c4c49464e6248646474424a6d4d73503a636164656e63652e6d6f6531313531333434383735363139343833373233'), + ('channel_room','sqlite_autoindex_channel_room_1','1 1','34 34','34 34',X'034b3321474b4a63424a6b527a47634e4855686c50613a636164656e63652e6d6f6531303237393433323532323237323630343637'), + ('channel_room','sqlite_autoindex_channel_room_1','1 1','43 43','43 43',X'034b3121484b50534d62736d694673506d6268414f513a636164656e63652e6d6f65313931343837343839393433343034353434'), + ('channel_room','sqlite_autoindex_channel_room_1','1 1','58 58','58 58',X'034b33214a4479425a685545706874784f6e6f6569513a636164656e63652e6d6f6531323937323836373434353633313236333532'), + ('channel_room','sqlite_autoindex_channel_room_1','1 1','87 87','87 87',X'034b33214e544d724e686e715271695755654d494d523a636164656e63652e6d6f6531323235323434353738393939373031353536'), + ('channel_room','sqlite_autoindex_channel_room_1','1 1','108 108','108 108',X'034b332151444e44796656674e7657565345656876713a636164656e63652e6d6f6531313432333134303935353535363435343830'), + ('channel_room','sqlite_autoindex_channel_room_1','1 1','131 131','131 131',X'034b3121544171536b575752654b43506f584c6a75483a636164656e63652e6d6f65383737303730363531343733363631393532'), + ('channel_room','sqlite_autoindex_channel_room_1','1 1','175 175','175 175',X'034b3321594249486864714e697255585941587845563a636164656e63652e6d6f6531323335303831373939353936373639333730'), + ('channel_room','sqlite_autoindex_channel_room_1','1 1','177 177','177 177',X'034b3321594b46454e79716667696951686956496b533a636164656e63652e6d6f6531323934363237303431343530333933373034'), + ('channel_room','sqlite_autoindex_channel_room_1','1 1','186 186','186 186',X'034b3321596f54644f55766a53765349767266716c653a636164656e63652e6d6f6531323734313936373733383435333430323933'), + ('channel_room','sqlite_autoindex_channel_room_1','1 1','202 202','202 202',X'034b3121625877616673695372655647676470535a463a636164656e63652e6d6f65373339303137363739373936343336393932'), + ('channel_room','sqlite_autoindex_channel_room_1','1 1','208 208','208 208',X'034b3321634a4b6843764943795377717a47634551423a636164656e63652e6d6f6531323732363632303331323238373331343834'), + ('channel_room','sqlite_autoindex_channel_room_1','1 1','219 219','219 219',X'034b3121656455786a56647a6755765844554951434b3a636164656e63652e6d6f65343937313631333530393334353630373738'), + ('channel_room','sqlite_autoindex_channel_room_1','1 1','242 242','242 242',X'034b31216a4d746e6e6f51414e4278466a486458494d3a636164656e63652e6d6f65373634353135323932303539323731313939'), + ('channel_room','sqlite_autoindex_channel_room_1','1 1','263 263','263 263',X'034b31216c7a776870666a5a6e59797468656a7453483a636164656e63652e6d6f65383838343831373132383438343030343534'), + ('channel_room','sqlite_autoindex_channel_room_1','1 1','264 264','264 264',X'034b33216d454c5846716a426958726d7558796943723a636164656e63652e6d6f6531313936393134373631303430393234373432'), + ('channel_room','sqlite_autoindex_channel_room_1','1 1','268 268','268 268',X'034b33216d557765577571546761574a767769576a653a636164656e63652e6d6f6531323936373131393236333032333830303834'), + ('channel_room','sqlite_autoindex_channel_room_1','1 1','291 291','291 291',X'034b3321704761494e45534643587a634e42497a724e3a636164656e63652e6d6f6531303237343531333333333533313532353232'), + ('channel_room','sqlite_autoindex_channel_room_1','1 1','306 306','306 306',X'034b3321717646656248564f4b6876454e54494563763a636164656e63652e6d6f6531323737373238383139323232303230313436'), + ('channel_room','sqlite_autoindex_channel_room_1','1 1','307 307','307 307',X'034b332171767370666d716f476449634a66794c506c3a636164656e63652e6d6f6531323936393138333638393539343633343735'), + ('channel_room','sqlite_autoindex_channel_room_1','1 1','351 351','351 351',X'034b3321774e7a7741724a47796f4c5168426f544e4b3a636164656e63652e6d6f6531303238303436373930333435333739383930'), + ('channel_room','sqlite_autoindex_channel_room_1','1 1','368 368','368 368',X'034b3321794e6d504c7765654a69756570725a677a733a636164656e63652e6d6f6531323531393631373233373731313632363234'), + ('channel_room','sqlite_autoindex_channel_room_1','1 1','376 376','376 376',X'034b3121796a4879795772466f704c66646878564e423a636164656e63652e6d6f65333336313537353037303734353233313336'), + ('channel_room','sqlite_autoindex_channel_room_1','1 1','379 379','379 379',X'034b31217a4c4f6b62766b44587551465948594555673a636164656e63652e6d6f65393933383838313433373030393330363331'), + ('media_proxy','media_proxy','1','563','563',X'02069e6054680b610946'), + ('media_proxy','media_proxy','1','1127','1127',X'0206bb489b717c9320e4'), + ('media_proxy','media_proxy','1','1691','1691',X'0206d75f602775b7a27c'), + ('media_proxy','media_proxy','1','2255','2255',X'0206f2c705ddca4e2b14'), + ('media_proxy','media_proxy','1','2819','2819',X'02061060db7a5151967b'), + ('media_proxy','media_proxy','1','3383','3383',X'02062cc47366f7550d22'), + ('media_proxy','media_proxy','1','3947','3947',X'020647d275ec0d781fc7'), + ('media_proxy','media_proxy','1','4511','4511',X'02066402024a7ea38249'), + ('sim_proxy','sim_proxy','1','4','4',X'025531316564343731342d636635652d346333372d393331382d376136353266383732636634'), + ('sim_proxy','sim_proxy','1','9','9',X'025533346636333932642d323263372d346337382d393063372d326536323734313535613266'), + ('sim_proxy','sim_proxy','1','14','14',X'025535396662363131392d626133392d346565382d393738612d386432376366303631393633'), + ('sim_proxy','sim_proxy','1','19','19',X'025539373066366536332d646234632d346531342d383063362d336639343938643961363665'), + ('sim_proxy','sim_proxy','1','24','24',X'025561636231613335642d313336662d343362332d626365622d326566646634616265306436'), + ('sim_proxy','sim_proxy','1','29','29',X'025563316635623735392d336136342d343633342d623634632d643461656436316539656632'), + ('sim_proxy','sim_proxy','1','34','34',X'025566323230373135632d633436332d343532622d626233612d373662646662306365353537'), + ('webhook','webhook','1','17','17',X'023331313532383834313435343038373230393936'), + ('webhook','webhook','1','35','35',X'023331313939303936333434333830343631313138'), + ('webhook','webhook','1','53','53',X'023331323331383036353337373032353736313938'), + ('webhook','webhook','1','71','71',X'023331323933373836383939343238383036363536'), + ('webhook','webhook','1','89','89',X'023331333132363031353130363535353537373132'), + ('webhook','webhook','1','107','107',X'0231323937323734313733303636333133373339'), + ('webhook','webhook','1','125','125',X'0231353239313736313536333938363832313137'), + ('webhook','webhook','1','143','143',X'0231363837303238373334333232313437333434'), + ('member_cache','member_cache','4 1','73 74','48 74',X'034b3921496f4866536e67625a6762747061747a494e3a636164656e63652e6d6f65406875636b6c65746f6e3a636164656e63652e6d6f65'), + ('member_cache','member_cache','2 1','86 87','57 87',X'034b2d214b5169714663546e764f6f4f424475746a7a3a636164656e63652e6d6f6540726e6c3a636164656e63652e6d6f65'), + ('member_cache','member_cache','4 1','101 104','68 104',X'034b43214e446249714e704a795076664b526e4e63723a636164656e63652e6d6f6540776f756e6465645f77617272696f723a6d61747269782e6f7267'), + ('member_cache','member_cache','4 1','110 113','73 113',X'034b3b214f485844457370624d485348716c4445614f3a636164656e63652e6d6f6540717561647261646963616c3a6d61747269782e6f7267'), + ('member_cache','member_cache','5 1','171 175','111 175',X'034b3b215450616f6a5454444446444847776c7276743a636164656e63652e6d6f6540766962656973766572796f3a6d61747269782e6f7267'), + ('member_cache','member_cache','39 1','180 208','116 208',X'034b4d2154716c79516d69667847556767456d64424e3a636164656e63652e6d6f6540726f626c6b796f6772653a6372616674696e67636f6d72616465732e6e6574'), + ('member_cache','member_cache','4 1','231 231','126 231',X'034b3b2156624f77675559777146614e4c5345644e413a636164656e63652e6d6f654061666c6f7765723a73796e646963617465642e676179'), + ('member_cache','member_cache','9 1','262 263','141 263',X'034b3b21594b46454e79716667696951686956496b533a636164656e63652e6d6f654062656e6d61633a636861742e62656e6d61632e78797a'), + ('member_cache','member_cache','3 1','283 283','149 283',X'034b35215a615a4d78456f52724d6d4e49554d79446c3a636164656e63652e6d6f6540636164656e63653a636164656e63652e6d6f65'), + ('member_cache','member_cache','88 1','307 351','166 351',X'034b3b2163427874565278446c5a765356684a58564b3a636164656e63652e6d6f65406a61736b617274683a736c656570696e672e746f776e'), + ('member_cache','member_cache','11 1','408 415','177 415',X'034b5121654856655270706e6c6f57587177704a6e553a636164656e63652e6d6f65406a61636b736f6e6368656e3636363a6a61636b736f6e6368656e3636362e636f6d'), + ('member_cache','member_cache','7 1','423 424','181 424',X'034b4b2165724f7079584e465a486a48724568784e583a636164656e63652e6d6f6540616d796973636f6f6c7a3a6d61747269782e6174697573616d792e636f6d'), + ('member_cache','member_cache','96 1','436 439','187 439',X'034b4b21676865544b5a7451666c444e7070684c49673a636164656e63652e6d6f6540616c65783a73706163652e67616d65727374617665726e2e6f6e6c696e65'), + ('member_cache','member_cache','96 1','436 527','187 527',X'034b3121676865544b5a7451666c444e7070684c49673a636164656e63652e6d6f654078796c6f626f6c3a616d6265722e74656c'), + ('member_cache','member_cache','10 1','546 555','197 555',X'0351312169537958674e7851634575586f587073536e3a707573737468656361742e6f726740797562697175653a6e6f70652e63686174'), + ('member_cache','member_cache','13 1','594 601','224 601',X'034b2b216c7570486a715444537a774f744d59476d493a636164656e63652e6d6f6540656c6c69753a68617368692e7265'), + ('member_cache','member_cache','2 1','614 615','229 615',X'034b2f216d584978494644676c4861734e53427371773a636164656e63652e6d6f654077696e673a666561746865722e6f6e6c'), + ('member_cache','member_cache','4 1','616 619','230 619',X'034b2f216d616767455367755a427147425a74536e723a636164656e63652e6d6f654077696e673a666561746865722e6f6e6c'), + ('member_cache','member_cache','4 1','659 660','259 660',X'034b332172454f73706e5971644f414c4149466e69563a636164656e63652e6d6f6540656c797369613a636164656e63652e6d6f65'), + ('member_cache','member_cache','4 1','699 701','284 701',X'034b3521766e717a56767678534a586c5a504f5276533a636164656e63652e6d6f6540636164656e63653a636164656e63652e6d6f65'), + ('member_cache','member_cache','1 1','703 703','285 703',X'034b3521767165714c474851616842464a56566779483a636164656e63652e6d6f654063696465723a6361746769726c2e636c6f7564'), + ('member_cache','member_cache','4 1','705 705','287 705',X'034b35217750454472596b77497a6f744e66706e57503a636164656e63652e6d6f6540636164656e63653a636164656e63652e6d6f65'), + ('member_cache','member_cache','35 1','709 709','288 709',X'034b2d2177574f667376757356486f4e4e567242585a3a636164656e63652e6d6f654061613a6361747669626572732e6d65'), + ('member_cache','member_cache','14 1','747 749','291 749',X'034b3721776c534544496a44676c486d42474b7254703a636164656e63652e6d6f654062616461746e616d65733a62616461742e646576'), + ('member_power','member_power','1 1','0 0','0 0',X'03350f40636164656e63653a636164656e63652e6d6f652a'), + ('file','file','1','2429','2429',X'03815f68747470733a2f2f63646e2e646973636f72646170702e636f6d2f6174746163686d656e74732f313039393033313838373530303033343038382f313333313336303134333238333036303833372f50584c5f32303235303132315f3230323934323137372e6a7067'), + ('file','file','1','4859','4859',X'03817568747470733a2f2f63646e2e646973636f72646170702e636f6d2f6174746163686d656e74732f313134353832313533383832323637323436362f313330323232393131303834373936373331352f53637265656e73686f745f32303234313130325f3034313332365f5265646469742e6a7067'), + ('file','file','1','7289','7289',X'03815968747470733a2f2f63646e2e646973636f72646170702e636f6d2f6174746163686d656e74732f313231393439383932363436363636323433302f313239373634363930353038353636313234362f494d475f32303234313032305f3135323230302e6a7067'), + ('file','file','1','9719','9719',X'03814168747470733a2f2f63646e2e646973636f72646170702e636f6d2f6174746163686d656e74732f3135393136353731343139343735393638302f313236383537333933363531343337313635392f494d475f353433362e6a7067'), + ('file','file','1','12149','12149',X'03813b68747470733a2f2f63646e2e646973636f72646170702e636f6d2f6174746163686d656e74732f3236363736373539303634313233383032372f313237323430333931313939383730313630392f696d6167652e706e67'), + ('file','file','1','14579','14579',X'03816b68747470733a2f2f63646e2e646973636f72646170702e636f6d2f6174746163686d656e74732f3539383730363933323736303434343936392f313237373532343532343330383632373437372f45585445524e414c5f454449545f323032345f4d5f64726166745f322e646f6378'), + ('file','file','1','17009','17009',X'03815768747470733a2f2f63646e2e646973636f72646170702e636f6d2f6174746163686d656e74732f3635353231363137333639363238363734362f313333333630333132383634313036303938342f323032352d30312d32375f31372e30312e31352e706e67'), + ('file','file','1','19439','19439',X'027f68747470733a2f2f63646e2e646973636f72646170702e636f6d2f656d6f6a69732f313230323936303730343936313931323836322e706e67'), + ('message_channel','message_channel','1','40764','40764',X'023331313630333434353733303030383232383735'), + ('message_channel','message_channel','1','81529','81529',X'023331313830323437393130343238393837343333'), + ('message_channel','message_channel','1','122294','122294',X'023331313938303533383732383337363131363230'), + ('message_channel','message_channel','1','163059','163059',X'023331323237373739373330333839303738303537'), + ('message_channel','message_channel','1','203824','203824',X'023331323437303438333039303031313538373137'), + ('message_channel','message_channel','1','244589','244589',X'023331323635363939353734333034303830303034'), + ('message_channel','message_channel','1','285354','285354',X'023331323835343637363238323434313732383234'), + ('message_channel','message_channel','1','326119','326119',X'023331333038373932333935313031333736353732'), + ('lottie','lottie','1','2','2',X'0231373439303534363630373639323138363331'), + ('lottie','lottie','1','5','5',X'0231373534313037353339323030363731373635'), + ('lottie','lottie','1','8','8',X'0231373936313430363338303933343433303932'), + ('lottie','lottie','1','11','11',X'0231373936313431373032363935343835353030'), + ('lottie','lottie','1','14','14',X'0231383136303837373932323931323832393434'), + ('lottie','lottie','1','17','17',X'0231383233393736313032393736323930383636'), + ('event_message','event_message','11 1','14788 14796','14356 14796',X'03336531313532303033373639343137303839303434246d44714a474b3530424a715170394273684e534d64365f3768494354776e70362d793130786d6669766563'), + ('event_message','event_message','11 1','33806 33815','32914 33815',X'033365313135373630333638373035373836363736322459526f7139484b376e55677a397668796f6e3053424a49497978497a5750734e4f6e39756f765644664d45'), + ('event_message','event_message','1 1','42546 42546','41335 42546',X'03336531313630363236383737353835363938393237245033436e4f6d6a35462d6939454e4e79586b70796f5679306b74324a654464764276326e746d33566a4355'), + ('event_message','event_message','2 1','85093 85093','81999 85093',X'0333653131383039393939363531323930343831303424496748666562784533746e623047412d534f7176594a354e4e55385735706c68523159676854636a554734'), + ('event_message','event_message','11 1','116157 116165','111525 116165',X'03336531313933363837333730363137333237363536245a32305734766c737079566e387a6e2d526a6f64694a51745f5a7851644f5f33744e415169453755356477'), + ('event_message','event_message','1 1','127640 127640','122328 127640',X'0333653132303132353637313733373633303332323624702d626f415672476a4b45327a7158664f3738387a5a597a376a42624648717431334d386464705467476f'), + ('event_message','event_message','16 1','140270 140281','134379 140281',X'03336531323039333735363534373138343830343235246c374a4f7543526b7756306d627a69356e5843496b4538416a6951374f67473455456c2d7053445a516649'), + ('event_message','event_message','11 1','162065 162071','154933 162071',X'0333653132323434383135393033313937373537373424674c77513179796e4b6d5859496b5a597a4a55627a66557a55552d714c4b5f524f454e4250325f6e44766b'), + ('event_message','event_message','1 1','170187 170187','162530 170187',X'0333653132323937373533363839343532373038333424656c6c76416a544a5847627936767249767470677a555572787231716f5a75536e50525f474b4e35455945'), + ('event_message','event_message','11 1','178736 178736','170762 178736',X'03336531323333353238303533323338333337353537242d39304668552d36455373594b6435484d7237666d6a414a5f6a576149616e356c4776384e655436564959'), + ('event_message','event_message','10 1','180317 180325','172228 180325',X'03336531323334363237303030393333383130323637246a4679416449665a4f54432d2d735971715472473735374c7a50504f34386439657963485477686d797751'), + ('event_message','event_message','1 1','212734 212734','203278 212734',X'03336531323438303131373930303633373637363734246557493950456f4d576b614b4d33416a7030782d6d455f6c4b4a6c495f6d6d44686f6379464b5170534f59'), + ('event_message','event_message','11 1','228100 228103','217856 228103',X'033365313235333734353736363733373132313336322447326a7a746e5977716a3676304a7a4168576e30725950596470667a5f496f76426771574a4876434c516b'), + ('event_message','event_message','11 1','240172 240181','229123 240181',X'033365313235393239383538323233303630313735382472635679454c5a76647453547a37366f3669434a4f614a316a756f683835535778494d546c5072517a676f'), + ('event_message','event_message','10 1','240259 240264','229132 240264',X'0333653132353933303030343035333535373235323024535849376a465f696e71424d714c4f564b347659746852644b31724747502d435a5f355a354f6b774e3751'), + ('event_message','event_message','2 1','255280 255281','243230 255281',X'03336531323636373837343338353137343234313738246e6e4e576f526a54495757723441463770625454746b7a73784c4d336b312d6d6645665031496b43586e59'), + ('event_message','event_message','1 1','297828 297828','283436 297828',X'0333653132383637303937353334363834323032313024544342465970356f39767a2d4f6b2d33654f4e433772426d354966615934476b48536e58445257474f4767'), + ('event_message','event_message','1 1','340375 340375','323489 340375',X'033365313330393336363936343530353934303030392431664536386d2d50546e786d5474345458584c35754847594139353779396c76582d50797150496d395f30'), + ('event_message','event_message','11 1','343791 343799','326730 343799',X'03336531333131353731333337393331363537323737246731767241315269725951592d3933304f6973587142372d6961686e34684e492d6462374952714176616b'), + ('event_message','event_message','10 1','363207 363216','344868 363216',X'0333653133323434393732323633393438393834393524784f78636e4749364269545941734d7a4f7557316f526e68356b675378544e30466442386a3037695f4f67'), + ('event_message','event_message','10 1','363219 363219','344871 363219',X'033365313332343439373532363733323039393732352432586f7938567937643843375a47767472657966326b4c4f4159397546386b4755364c3642435f446b6d77'), + ('event_message','event_message','10 1','363340 363343','344918 363343',X'03336531333234353037313732333634373530393830244f46645731396649534176706d34646c36367a2d5543337236432d436354506e34752d63444f7036733345'), + ('event_message','event_message','11 1','369452 369456','350712 369456',X'0333653133323736353831313733343439323336383024574d774330644574417277375f4562554a534465546532577174506d3747584347774570646c4f79326d30'), + ('event_message','event_message','10 1','372353 372356','353425 372356',X'0333653133323933313737353039313234353035373224366259364d313472667163486d5854476349716d4a4366467471796839794472375a6a487463715a6d4f34'), + ('sim_member','sim_member','225 1','0 12','0 12',X'034b4721414956694e775a64636b4652764c4f4567433a636164656e63652e6d6f65405f6f6f79655f616b6972615f6e6965723a636164656e63652e6d6f65'), + ('sim_member','sim_member','2 1','319 319','14 319',X'034b43214456706f6e54524d56456570486378744c423a636164656e63652e6d6f65405f6f6f79655f656e746f6c6f6d613a636164656e63652e6d6f65'), + ('sim_member','sim_member','68 1','391 440','26 440',X'034b4921457a54624a496c496d45534f746b4e644e4a3a636164656e63652e6d6f65405f6f6f79655f73617475726461797465643a636164656e63652e6d6f65'), + ('sim_member','sim_member','8 1','638 639','59 639',X'034b3f21497a4f675169446e757346516977796d614c3a636164656e63652e6d6f65405f6f6f79655f636f6f6b69653a636164656e63652e6d6f65'), + ('sim_member','sim_member','31 1','743 771','86 771',X'034b49214d5071594e414a62576b72474f544a7461703a636164656e63652e6d6f65405f6f6f79655f746865666f6f6c323239343a636164656e63652e6d6f65'), + ('sim_member','sim_member','26 1','774 787','87 787',X'034b49214d687950614b4250506f496c7365794d6d743a636164656e63652e6d6f65405f6f6f79655f6b79757567727970686f6e3a636164656e63652e6d6f65'), + ('sim_member','sim_member','27 1','877 881','104 881',X'034b45215063734371724f466a48476f41424270414c3a636164656e63652e6d6f65405f6f6f79655f62696c6c795f626f623a636164656e63652e6d6f65'), + ('sim_member','sim_member','16 1','956 959','117 959',X'034b43215158526f4a777a63506d5047546d454b454d3a636164656e63652e6d6f65405f6f6f79655f626f7472616334723a636164656e63652e6d6f65'), + ('sim_member','sim_member','32 1','997 1012','121 1012',X'034b3f215170676c734e587a4c7751594d4c6c734f503a636164656e63652e6d6f65405f6f6f79655f6a75746f6d693a636164656e63652e6d6f65'), + ('sim_member','sim_member','7 1','1274 1279','157 1279',X'034b4121554d6f6e68556765644d47585a78466658753a636164656e63652e6d6f65405f6f6f79655f6d696e696d75733a636164656e63652e6d6f65'), + ('sim_member','sim_member','27 1','1415 1439','188 1439',X'034b3d21595868717249786d586e47736961796a59783a636164656e63652e6d6f65405f6f6f79655f73746161663a636164656e63652e6d6f65'), + ('sim_member','sim_member','16 1','1597 1599','217 1599',X'034b512163466a4479477274466d48796d794c6652453a636164656e63652e6d6f65405f6f6f79655f626f6a61636b5f686f7273656d616e3a636164656e63652e6d6f65'), + ('sim_member','sim_member','27 1','1758 1761','248 1761',X'034b3d2168665a74624d656f5355564e424850736a743a636164656e63652e6d6f65405f6f6f79655f617a7572653a636164656e63652e6d6f65'), + ('sim_member','sim_member','25 1','1865 1886','270 1886',X'034b3f216b4c52714b4b555158636962494d744f706c3a636164656e63652e6d6f65405f6f6f79655f7361796f72693a636164656e63652e6d6f65'), + ('sim_member','sim_member','19 1','1918 1919','276 1919',X'034b3d216b68497350756c465369736d43646c596e493a636164656e63652e6d6f65405f6f6f79655f617a7572653a636164656e63652e6d6f65'), + ('sim_member','sim_member','33 1','1986 2015','286 2015',X'034b3d216d5451744d736a534c4f646c576f7265594d3a636164656e63652e6d6f65405f6f6f79655f73746161663a636164656e63652e6d6f65'), + ('sim_member','sim_member','37 1','2027 2028','289 2028',X'034b4d216d616767455367755a427147425a74536e723a636164656e63652e6d6f65405f6f6f79655f2e7265616c2e706572736f6e2e3a636164656e63652e6d6f65'), + ('sim_member','sim_member','28 1','2117 2130','297 2130',X'034b3f216e4e595a794b6f4e70797859417a50466f733a636164656e63652e6d6f65405f6f6f79655f6a75746f6d693a636164656e63652e6d6f65'), + ('sim_member','sim_member','20 1','2230 2239','310 2239',X'034b41217046504c7270594879487a784e4c69594b413a636164656e63652e6d6f65405f6f6f79655f686578676f61743a636164656e63652e6d6f65'), + ('sim_member','sim_member','30 1','2381 2398','332 2398',X'034b3b21717a44626c4b6c69444c577a52524f6e465a3a636164656e63652e6d6f65405f6f6f79655f6d6e696b3a636164656e63652e6d6f65'), + ('sim_member','sim_member','38 1','2490 2518','344 2518',X'034b3d2173445250714549546e4f4e57474176496b423a636164656e63652e6d6f65405f6f6f79655f727974686d3a636164656e63652e6d6f65'), + ('sim_member','sim_member','11 1','2555 2559','358 2559',X'034b4321746751436d526b426e6474516362687150583a636164656e63652e6d6f65405f6f6f79655f6a6f7365707065793a636164656e63652e6d6f65'), + ('sim_member','sim_member','47 1','2633 2666','377 2666',X'034b4b217750454472596b77497a6f744e66706e57503a636164656e63652e6d6f65405f6f6f79655f6e61706f6c656f6e333038393a636164656e63652e6d6f65'), + ('sim_member','sim_member','52 1','2817 2837','414 2837',X'034b43217a66654e574d744b4f764f48766f727979563a636164656e63652e6d6f65405f6f6f79655f696e736f676e69613a636164656e63652e6d6f65'), + ('guild_space','guild_space','1','3','3',X'0231313132373630363639313738323431303234'), + ('guild_space','guild_space','1','7','7',X'023331313534383638343234373234343633363837'), + ('guild_space','guild_space','1','11','11',X'023331323139303338323637383430393235383138'), + ('guild_space','guild_space','1','15','15',X'023331323839353939363232353930383930313335'), + ('guild_space','guild_space','1','19','19',X'0231323733383737363437323234393935383431'), + ('guild_space','guild_space','1','23','23',X'0231353239313736313536333938363832313135'), + ('guild_space','guild_space','1','27','27',X'0231373535303134333534373334313533383138'), + ('guild_space','guild_space','1','31','31',X'0231393933383838313432343535323130303834'), + ('guild_active','guild_active','1','3','3',X'0231313132373630363639313738323431303234'), + ('guild_active','guild_active','1','7','7',X'023331313534383638343234373234343633363837'), + ('guild_active','guild_active','1','11','11',X'023331323139303338323637383430393235383138'), + ('guild_active','guild_active','1','15','15',X'023331323839353939363232353930383930313335'), + ('guild_active','guild_active','1','19','19',X'023331333333323139363936393333323038303934'), + ('guild_active','guild_active','1','23','23',X'0231343735353939303338353336373434393630'), + ('guild_active','guild_active','1','27','27',X'022f3636313932393535373737343836383438'), + ('guild_active','guild_active','1','31','31',X'0231383737303635303431393930353136373637'), + ('emoji','emoji','1','284','284',X'023331313132323031303430303637303339333332'), + ('emoji','emoji','1','569','569',X'023331323334393032303131393436383630363638'), + ('emoji','emoji','1','854','854',X'0231323735313734373438353034313935303732'), + ('emoji','emoji','1','1139','1139',X'0231333837343730383630303134393737303235'), + ('emoji','emoji','1','1424','1424',X'0231353435363639393734303130383838313932'), + ('emoji','emoji','1','1709','1709',X'0231363339313034333030393139393437323634'), + ('emoji','emoji','1','1994','1994',X'0231373532363932363230303638373832313231'), + ('emoji','emoji','1','2279','2279',X'0231383935343737353331303434363138323430'), + ('auto_emoji','auto_emoji','1','0','0',X'02114c31'), + ('auto_emoji','auto_emoji','1','1','1',X'02114c32'), + ('auto_emoji','auto_emoji','1','2','2',X'020f5f'); + +ANALYZE sqlite_schema; + +COMMIT; From d4a50cb8aaf6637bbdab6db676fd54cf32d10fba Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Thu, 30 Jan 2025 22:25:25 +1300 Subject: [PATCH 008/148] Do not run as root --- readme.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/readme.md b/readme.md index 6f2f477..1f01d01 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,6 +78,8 @@ Follow these steps: 1. [Get Node.js version 20 or later](https://nodejs.org/en/download/prebuilt-installer) +1. Switch to a normal user account. (i.e. do not run any of the following commands as root or sudo.) + 1. Clone this repo and checkout a specific tag. (Development happens on main. Stable versions are tagged.) * The latest release tag is ![](https://img.shields.io/gitea/v/release/cadence/out-of-your-element?gitea_url=https%3A%2F%2Fgitdab.com&style=flat-square&label=%20&color=black). From 5c0e83065809c1828c0aec8a22ff0dbe51381492 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Fri, 31 Jan 2025 15:07:48 +1300 Subject: [PATCH 009/148] Display XHR errors --- src/web/pug/includes/template.pug | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/web/pug/includes/template.pug b/src/web/pug/includes/template.pug index 6a0fb76..17b7847 100644 --- a/src/web/pug/includes/template.pug +++ b/src/web/pug/includes/template.pug @@ -57,8 +57,10 @@ html(lang="en") 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", () => { @@ -69,3 +71,19 @@ html(lang="en") }) }) script(src=rel("/static/htmx.min.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 + }) From b1b9124052d7ef8ba6563cd8aa4f29226fe9ad4a Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Fri, 31 Jan 2025 15:09:01 +1300 Subject: [PATCH 010/148] Fully support unlinking channels --- docs/foreign-keys.md | 2 +- src/d2m/actions/create-room.js | 74 ++++++++++++++++++++++++---------- src/web/pug/guild.pug | 5 ++- src/web/routes/link.js | 29 ++++++++++++- 4 files changed, 84 insertions(+), 26 deletions(-) diff --git a/docs/foreign-keys.md b/docs/foreign-keys.md index 9940ed0..1e5e21c 100644 --- a/docs/foreign-keys.md +++ b/docs/foreign-keys.md @@ -45,7 +45,7 @@ Here are some tables that could potentially have foreign keys added between them * 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 should remain in the room even after it's been unlinked. So no referential integrity is desirable here. + * 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` ✅ diff --git a/src/d2m/actions/create-room.js b/src/d2m/actions/create-room.js index e0d019f..690ca2a 100644 --- a/src/d2m/actions/create-room.js +++ b/src/d2m/actions/create-room.js @@ -6,7 +6,7 @@ const Ty = require("../../types") const {reg} = require("../../matrix/read-registration") const passthrough = require("../../passthrough") -const {discord, sync, db, select} = passthrough +const {discord, sync, db, select, from} = passthrough /** @type {import("../../matrix/file")} */ const file = sync.require("../../matrix/file") /** @type {import("../../matrix/api")} */ @@ -14,7 +14,9 @@ const api = sync.require("../../matrix/api") /** @type {import("../../matrix/kstate")} */ const ks = sync.require("../../matrix/kstate") /** @type {import("../../discord/utils")} */ -const utils = sync.require("../../discord/utils") +const dUtils = sync.require("../../discord/utils") +/** @type {import("../../m2d/converters/utils")} */ +const mUtils = sync.require("../../m2d/converters/utils") /** @type {import("./create-space")} */ const createSpace = sync.require("./create-space") @@ -114,8 +116,8 @@ async function channelToKState(channel, guild, di) { join_rules = {join_rule: PRIVACY_ENUMS.ROOM_JOIN_RULES[privacyLevel]} } - const everyonePermissions = utils.getPermissions([], guild.roles, undefined, channel.permission_overwrites) - const everyoneCanMentionEveryone = utils.hasAllPermissions(everyonePermissions, ["MentionEveryone"]) + const everyonePermissions = dUtils.getPermissions([], guild.roles, undefined, channel.permission_overwrites) + const everyoneCanMentionEveryone = dUtils.hasAllPermissions(everyonePermissions, ["MentionEveryone"]) const globalAdmins = select("member_power", ["mxid", "power_level"], {room_id: "*"}).all() const globalAdminPower = globalAdmins.reduce((a, c) => (a[c.mxid] = c.power_level, a), {}) @@ -392,7 +394,7 @@ function syncRoom(channelID) { return _syncRoom(channelID, true) } -async function _unbridgeRoom(channelID) { +async function unbridgeChannel(channelID) { /** @ts-ignore @type {DiscordTypes.APIGuildChannel} */ const channel = discord.channels.get(channelID) assert.ok(channel) @@ -407,12 +409,8 @@ async function _unbridgeRoom(channelID) { async function unbridgeDeletedChannel(channel, guildID) { const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get() assert.ok(roomID) - const spaceID = select("guild_space", "space_id", {guild_id: guildID}).pluck().get() - assert.ok(spaceID) - - // remove room from being a space member - await api.sendState(roomID, "m.space.parent", spaceID, {}) - await api.sendState(spaceID, "m.space.child", roomID, {}) + const row = from("guild_space").join("guild_active", "guild_id").select("space_id", "autocreate").get() + assert.ok(row) // remove declaration that the room is bridged await api.sendState(roomID, "uk.half-shot.bridge", `moe.cadence.ooye://discord/${guildID}/${channel.id}`, {}) @@ -421,15 +419,6 @@ async function unbridgeDeletedChannel(channel, guildID) { await api.sendState(roomID, "m.room.topic", "", {topic: channel.topic || ""}) } - // send a notification in the room - await api.sendEvent(roomID, "m.room.message", { - msgtype: "m.notice", - body: "⚠️ This room was removed from the bridge." - }) - - // leave room - await api.leaveRoom(roomID) - // delete webhook on discord const webhook = select("webhook", ["webhook_id", "webhook_token"], {channel_id: channel.id}).get() if (webhook) { @@ -439,7 +428,48 @@ async function unbridgeDeletedChannel(channel, guildID) { // 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) + db.prepare("DELETE FROM channel_room WHERE room_id = ? AND channel_id = ?").run(roomID, channel.id) // cascades to most other tables, like messages + + // 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 (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) + } + + // 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) + } + } } /** @@ -488,7 +518,7 @@ module.exports.createAllForGuild = createAllForGuild module.exports.channelToKState = channelToKState module.exports.postApplyPowerLevels = postApplyPowerLevels module.exports._convertNameAndTopic = convertNameAndTopic -module.exports._unbridgeRoom = _unbridgeRoom +module.exports.unbridgeChannel = unbridgeChannel module.exports.unbridgeDeletedChannel = unbridgeDeletedChannel module.exports.existsOrAutocreatable = existsOrAutocreatable module.exports.assertExistsOrAutocreatable = assertExistsOrAutocreatable diff --git a/src/web/pug/guild.pug b/src/web/pug/guild.pug index 34178bf..51b2c99 100644 --- a/src/web/pug/guild.pug +++ b/src/web/pug/guild.pug @@ -78,12 +78,13 @@ block body h3.mt32.fs-category Linked channels .s-card.bs-sm.p0 - .s-table-container + form.s-table-container(method="post" action="/api/unlink" hx-post="/api/unlink" hx-trigger="submit" hx-disabled-elt="this" hx-confirm="Unlink these channels?\nIt may take a moment to clean up Matrix resources.") + input(type="hidden" name="guild_id" value=guild_id) table.s-table.s-table__bx-simple each row in linkedChannelsWithDetails tr td.w40: +discord(row.channel) - td.p2: button.s-btn.s-btn__muted.s-btn__xs!= icons.Icons.IconLinkSm + td.p2: button.s-btn.s-btn__muted.s-btn__xs(name="channel_id" value=row.channel.id)!= icons.Icons.IconLinkSm td: +matrix(row) else tr diff --git a/src/web/routes/link.js b/src/web/routes/link.js index e63c01b..52c750c 100644 --- a/src/web/routes/link.js +++ b/src/web/routes/link.js @@ -1,7 +1,7 @@ // @ts-check const {z} = require("zod") -const {defineEventHandler, useSession, createError, readValidatedBody} = require("h3") +const {defineEventHandler, useSession, createError, readValidatedBody, setResponseHeader} = require("h3") const Ty = require("../../types") const {discord, db, as, sync, select, from} = require("../../passthrough") @@ -19,6 +19,10 @@ const schema = { guild_id: z.string(), matrix: z.string(), discord: z.string() + }), + unlink: z.object({ + guild_id: z.string(), + channel_id: z.string() }) } @@ -61,3 +65,26 @@ as.router.post("/api/link", defineEventHandler(async event => { 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 || []).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 +})) From a459ee1d1cce03b6d7dc5fe8dd872f8c81a28fee Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Fri, 31 Jan 2025 16:42:15 +1300 Subject: [PATCH 011/148] Use htmx.js instead of htmx.min.js This wastes 30 kB gzipped, which I think is acceptable in exchange for having method names in the debugger. --- src/web/pug/includes/template.pug | 178 +- src/web/server.js | 4 +- src/web/static/htmx.js | 5261 +++++++++++++++++++++++++++++ src/web/static/htmx.min.js | 1 - 4 files changed, 5352 insertions(+), 92 deletions(-) create mode 100644 src/web/static/htmx.js delete mode 100644 src/web/static/htmx.min.js diff --git a/src/web/pug/includes/template.pug b/src/web/pug/includes/template.pug index 17b7847..909244f 100644 --- a/src/web/pug/includes/template.pug +++ b/src/web/pug/includes/template.pug @@ -1,89 +1,89 @@ -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) - //- Body - .mx-auto.w100.wmx9.py24.px8.fs-body1#content - block body - //- Guild list popover - script. - document.querySelectorAll("[popovertarget]").forEach(e => { - e.addEventListener("click", () => { - const rect = e.getBoundingClientRect() - const t = `:popover-open { position: absolute; top: ${Math.floor(rect.bottom)}px; left: ${Math.floor(rect.left + rect.width / 2)}px; width: ${Math.floor(rect.width)}px; transform: translateX(-50%); box-sizing: content-box; margin: 0 }` - // console.log(t) - document.styleSheets[0].insertRule(t) - }) - }) - script(src=rel("/static/htmx.min.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) + //- Body + .mx-auto.w100.wmx9.py24.px8.fs-body1#content + block body + //- Guild list popover + script. + document.querySelectorAll("[popovertarget]").forEach(e => { + e.addEventListener("click", () => { + const rect = e.getBoundingClientRect() + const t = `:popover-open { position: absolute; top: ${Math.floor(rect.bottom)}px; left: ${Math.floor(rect.left + rect.width / 2)}px; width: ${Math.floor(rect.width)}px; transform: translateX(-50%); box-sizing: content-box; margin: 0 }` + // console.log(t) + document.styleSheets[0].insertRule(t) + }) + }) + 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 + }) diff --git a/src/web/server.js b/src/web/server.js index 9373947..39c0a68 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -49,12 +49,12 @@ as.router.get("/static/stacks.min.css", defineEventHandler({ } })) -as.router.get("/static/htmx.min.js", defineEventHandler({ +as.router.get("/static/htmx.js", defineEventHandler({ onBeforeResponse: compressResponse, handler: async event => { handleCacheHeaders(event, {maxAge: 86400}) defaultContentType(event, "text/javascript") - return fs.promises.readFile(join(__dirname, "static", "htmx.min.js"), "utf-8") + return fs.promises.readFile(join(__dirname, "static", "htmx.js"), "utf-8") } })) diff --git a/src/web/static/htmx.js b/src/web/static/htmx.js new file mode 100644 index 0000000..370cc0f --- /dev/null +++ b/src/web/static/htmx.js @@ -0,0 +1,5261 @@ +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