Original payload
${util.inspect(gatewayMessage.d, false, 4, false)} `)
		api.sendEvent(roomID, "m.room.message", {
			...builder.get(),
			"moe.cadence.ooye.error": {
				source: "discord",
				payload: gatewayMessage
			},
			"m.mentions": {
				user_ids: ["@cadence:cadence.moe"]
			}
		})
	},
	/**
	 * When logging back in, check if we missed any conversations in any channels. Bridge up to 49 missed messages per channel.
	 * If more messages were missed, only the latest missed message will be posted. TODO: Consider bridging more, or post a warning when skipping history?
	 * This can ONLY detect new messages, not any other kind of event. Any missed edits, deletes, reactions, etc will not be bridged.
	 * @param {import("./discord-client")} client
	 * @param {DiscordTypes.GatewayGuildCreateDispatchData} guild
	 */
	async checkMissedMessages(client, guild) {
		if (guild.unavailable) return
		const bridgedChannels = select("channel_room", "channel_id").pluck().all()
		const prepared = select("event_message", "event_id", {}, "WHERE message_id = ?").pluck()
		for (const channel of guild.channels.concat(guild.threads)) {
			if (!bridgedChannels.includes(channel.id)) continue
			if (!("last_message_id" in channel) || !channel.last_message_id) continue
			const latestWasBridged = prepared.get(channel.last_message_id)
			if (latestWasBridged) continue
			// Permissions check
			const member = guild.members.find(m => m.user?.id === client.user.id)
			if (!member) return
			if (!("permission_overwrites" in channel)) continue
			const permissions = dUtils.getPermissions(member.roles, guild.roles, client.user.id, channel.permission_overwrites)
			const wants = BigInt(1 << 10) | BigInt(1 << 16) // VIEW_CHANNEL + READ_MESSAGE_HISTORY
			if ((permissions & wants) !== wants) continue // We don't have permission to look back in this channel
			/** More recent messages come first. */
			// console.log(`[check missed messages] in ${channel.id} (${guild.name} / ${channel.name}) because its last message ${channel.last_message_id} is not in the database`)
			let messages
			try {
				messages = await client.snow.channel.getChannelMessages(channel.id, {limit: 50})
			} catch (e) {
				if (e.message === `{"message": "Missing Access", "code": 50001}`) { // pathetic error handling from SnowTransfer
					console.log(`[check missed messages] no permissions to look back in channel ${channel.name} (${channel.id})`)
					continue // Sucks.
				} else {
					throw e // Sucks more.
				}
			}
			let latestBridgedMessageIndex = messages.findIndex(m => {
				return prepared.get(m.id)
			})
			// console.log(`[check missed messages] got ${messages.length} messages; last message that IS bridged is at position ${latestBridgedMessageIndex} in the channel`)
			if (latestBridgedMessageIndex === -1) latestBridgedMessageIndex = 1 // rather than crawling the ENTIRE channel history, let's just bridge the most recent 1 message to make it up to date.
			for (let i = Math.min(messages.length, latestBridgedMessageIndex)-1; i >= 0; i--) {
				const simulatedGatewayDispatchData = {
					guild_id: guild.id,
					backfill: true,
					...messages[i]
				}
				await module.exports.onMessageCreate(client, simulatedGatewayDispatchData)
			}
		}
	},
	/**
	 * When logging back in, check if the pins on Matrix-side are up to date. If they aren't, update all pins.
	 * Rather than query every room on Matrix-side, we cache the latest pinned message in the database and compare against that.
	 * @param {import("./discord-client")} client
	 * @param {DiscordTypes.GatewayGuildCreateDispatchData} guild
	 */
	async checkMissedPins(client, guild) {
		if (guild.unavailable) return
		const member = guild.members.find(m => m.user?.id === client.user.id)
		if (!member) return
		for (const channel of guild.channels) {
			if (!("last_pin_timestamp" in channel) || !channel.last_pin_timestamp) continue // Only care about channels that have pins
			if (!("permission_overwrites" in channel)) continue
			const lastPin = updatePins.convertTimestamp(channel.last_pin_timestamp)
			// Permissions check
			const permissions = dUtils.getPermissions(member.roles, guild.roles, client.user.id, channel.permission_overwrites)
			const wants = BigInt(1 << 10) | BigInt(1 << 16) // VIEW_CHANNEL + READ_MESSAGE_HISTORY
			if ((permissions & wants) !== wants) continue // We don't have permission to look up the pins in this channel
			const row = select("channel_room", ["room_id", "last_bridged_pin_timestamp"], {channel_id: channel.id}).get()
			if (!row) continue // Only care about already bridged channels
			if (row.last_bridged_pin_timestamp == null || lastPin > row.last_bridged_pin_timestamp) {
				checkMissedPinsSema.request(() => updatePins.updatePins(channel.id, row.room_id, lastPin))
			}
		}
	},
	/**
	 * When logging back in, check if we missed any changes to emojis or stickers. Apply the changes if so.
	 * @param {DiscordTypes.GatewayGuildCreateDispatchData} guild
	 */
	async checkMissedExpressions(guild) {
		const data = {guild_id: guild.id, ...guild}
		createSpace.syncSpaceExpressions(data, true)
	},
	/**
	 * Announces to the parent room that the thread room has been created.
	 * See notes.md, "Ignore MESSAGE_UPDATE and bridge THREAD_CREATE as the announcement"
	 * @param {import("./discord-client")} client
	 * @param {DiscordTypes.APIThreadChannel} thread
	 */
	async onThreadCreate(client, thread) {
		const channelID = thread.parent_id || undefined
		const parentRoomID = select("channel_room", "room_id", {channel_id: channelID}).pluck().get()
		if (!parentRoomID) return // Not interested in a thread if we aren't interested in its wider channel
		const threadRoomID = await createRoom.syncRoom(thread.id) // Create room (will share the same inflight as the initial message to the thread)
		await announceThread.announceThread(parentRoomID, threadRoomID, thread)
	},
	/**
	 * @param {import("./discord-client")} client
	 * @param {DiscordTypes.GatewayGuildUpdateDispatchData} guild
	 */
	async onGuildUpdate(client, guild) {
		const spaceID = select("guild_space", "space_id", {guild_id: guild.id}).pluck().get()
		if (!spaceID) return
		await createSpace.syncSpace(guild)
	},
	/**
	 * @param {import("./discord-client")} client
	 * @param {DiscordTypes.GatewayChannelUpdateDispatchData} channelOrThread
	 * @param {boolean} isThread
	 */
	async onChannelOrThreadUpdate(client, channelOrThread, isThread) {
		const roomID = select("channel_room", "room_id", {channel_id: channelOrThread.id}).pluck().get()
		if (!roomID) return // No target room to update the data on
		await createRoom.syncRoom(channelOrThread.id)
	},
	/**
	 * @param {import("./discord-client")} client
	 * @param {DiscordTypes.GatewayChannelPinsUpdateDispatchData} data
	 */
	async onChannelPinsUpdate(client, data) {
		const roomID = select("channel_room", "room_id", {channel_id: data.channel_id}).pluck().get()
		if (!roomID) return // No target room to update pins in
		const convertedTimestamp = updatePins.convertTimestamp(data.last_pin_timestamp)
		await updatePins.updatePins(data.channel_id, roomID, convertedTimestamp)
	},
	/**
	 * @param {import("./discord-client")} client
	 * @param {DiscordTypes.GatewayMessageCreateDispatchData} message
	 */
	async onMessageCreate(client, message) {
		if (message.author.username === "Deleted User") return // Nothing we can do for deleted users.
		if (message.webhook_id) {
			const row = select("webhook", "webhook_id", {webhook_id: message.webhook_id}).pluck().get()
			if (row) {
				// The message was sent by the bridge's own webhook on discord. We don't want to reflect this back, so just drop it.
				return
			}
		}
		const channel = client.channels.get(message.channel_id)
		if (!channel || !("guild_id" in channel) || !channel.guild_id) return // Nothing we can do in direct messages.
		const guild = client.guilds.get(channel.guild_id)
		assert(guild)
		await sendMessage.sendMessage(message, guild),
		await discordCommandHandler.execute(message, channel, guild)
	},
	/**
	 * @param {import("./discord-client")} client
	 * @param {DiscordTypes.GatewayMessageUpdateDispatchData} data
	 */
	async onMessageUpdate(client, data) {
		if (data.webhook_id) {
			const row = select("webhook", "webhook_id", {webhook_id: data.webhook_id}).pluck().get()
			if (row) {
				// The update was sent by the bridge's own webhook on discord. We don't want to reflect this back, so just drop it.
				return
			}
		}
		// Based on looking at data they've sent me over the gateway, this is the best way to check for meaningful changes.
		// If the message content is a string then it includes all interesting fields and is meaningful.
		if (typeof data.content === "string") {
			/** @type {DiscordTypes.GatewayMessageCreateDispatchData} */
			// @ts-ignore
			const message = data
			const channel = client.channels.get(message.channel_id)
			if (!channel || !("guild_id" in channel) || !channel.guild_id) return // Nothing we can do in direct messages.
			const guild = client.guilds.get(channel.guild_id)
			assert(guild)
			await editMessage.editMessage(message, guild)
		}
	},
	/**
	 * @param {import("./discord-client")} client
	 * @param {DiscordTypes.GatewayMessageReactionAddDispatchData} data
	 */
	async onReactionAdd(client, data) {
		if (data.user_id === client.user.id) return // m2d reactions are added by the discord bot user - do not reflect them back to matrix.
		discordCommandHandler.onReactionAdd(data)
		await addReaction.addReaction(data)
	},
	/**
	 * @param {import("./discord-client")} client
	 * @param {DiscordTypes.GatewayMessageReactionRemoveDispatchData | DiscordTypes.GatewayMessageReactionRemoveEmojiDispatchData | DiscordTypes.GatewayMessageReactionRemoveAllDispatchData} data
	 */
	async onSomeReactionsRemoved(client, data) {
		await removeReaction.removeSomeReactions(data)
	},
	/**
	 * @param {import("./discord-client")} client
	 * @param {DiscordTypes.GatewayMessageDeleteDispatchData} data
	 */
	async onMessageDelete(client, data) {
		await deleteMessage.deleteMessage(data)
	},
		/**
	 * @param {import("./discord-client")} client
	 * @param {DiscordTypes.GatewayMessageDeleteBulkDispatchData} data
	 */
		async onMessageDeleteBulk(client, data) {
			await deleteMessage.deleteMessageBulk(data)
		},
	/**
	 * @param {import("./discord-client")} client
	 * @param {DiscordTypes.GatewayTypingStartDispatchData} data
	 */
	async onTypingStart(client, data) {
		const roomID = select("channel_room", "room_id", {channel_id: data.channel_id}).pluck().get()
		if (!roomID) return
		const mxid = from("sim").join("sim_member", "mxid").where({user_id: data.user_id, room_id: roomID}).pluck("mxid").get()
		if (!mxid) return
		// Each Discord user triggers the notification every 8 seconds as long as they remain typing.
		// Discord does not send typing stopped events, so typing only stops if the timeout is reached or if the user sends their message.
		// (We have to manually stop typing on Matrix-side when the message is sent. This is part of the send action.)
		await api.sendTyping(roomID, true, mxid, 10000)
	},
	/**
	 * @param {import("./discord-client")} client
	 * @param {DiscordTypes.GatewayGuildEmojisUpdateDispatchData | DiscordTypes.GatewayGuildStickersUpdateDispatchData} data
	 */
	async onExpressionsUpdate(client, data) {
		await createSpace.syncSpaceExpressions(data, false)
	}
}