diff --git a/package.json b/package.json index 12ac82a..1ee12ad 100644 --- a/package.json +++ b/package.json @@ -60,8 +60,8 @@ "supertape": "^10.4.0" }, "scripts": { - "start": "node start.js", - "setup": "node scripts/setup.js", + "start": "node --enable-source-maps start.js", + "setup": "node --enable-source-maps scripts/setup.js", "addbot": "node addbot.js", "test": "cross-env FORCE_COLOR=true supertape --no-check-assertions-count --format tap --no-worker test/test.js | tap-dot", "test-slow": "cross-env FORCE_COLOR=true supertape --no-check-assertions-count --format tap --no-worker test/test.js -- --slow | tap-dot", diff --git a/src/d2m/actions/set-presence.js b/src/d2m/actions/set-presence.js index 497ffe3..a9f072a 100644 --- a/src/d2m/actions/set-presence.js +++ b/src/d2m/actions/set-presence.js @@ -14,91 +14,100 @@ const api = sync.require("../../matrix/api") The first phase stores Discord presence data in memory. The second phase loops over the memory and sends it on to Matrix. - In the first phase, for optimisation reasons, we want to do as little work as possible if the presence doesn't actually need to be sent all the way through. - * Presence can be deactivated per-guild in OOYE settings. If it's deactivated for all of a user's guilds, we shouldn't send them to the second phase. - * Presence can be sent for users without sims. In this case, we shouldn't send them to the second phase. - * Presence can be sent multiple times in a row for the same user for each guild we share. We want to batch these up so we only query the mxid and enter the second phase once per user. + Optimisations: + * Presence can be deactivated per-guild in OOYE settings. If the user doesn't share any presence-enabled-guilds with us, we don't need to do anything. + * Presence can be sent for users without sims. In this case, they will be discarded from memory when the next loop begins. + * Matrix ID is cached in memory on the Presence class. The alternative to this is querying it every time we receive a presence change event in a valid guild. + * Presence can be sent multiple times in a row for the same user for each guild we share. The loop timer prevents these "changes" from individually reaching the homeserver. */ - -// ***** first phase ***** - - -// Delay before querying user details and putting them in memory. -const presenceDelay = 1500 - -/** @type {Map} user ID -> cancelable timeout */ -const presenceDelayMap = new Map() - -// Access the list of enabled guilds as needed rather than like multiple times per second when a user changes presence -/** @type {Set} */ -let presenceEnabledGuilds -function checkPresenceEnabledGuilds() { - presenceEnabledGuilds = new Set(select("guild_space", "guild_id", {presence: 1}).pluck().all()) -} -checkPresenceEnabledGuilds() - -/** - * This function is called for each Discord presence packet. - * @param {string} userID Discord user ID - * @param {string} guildID Discord guild ID that this presence applies to (really, the same presence applies to every single guild, but is delivered separately by Discord for some reason) - * @param {string} status status field from Discord's PRESENCE_UPDATE event - */ -function setPresence(userID, guildID, status) { - // check if we care about this guild - if (!presenceEnabledGuilds.has(guildID)) return - // cancel existing timer if one is already set - if (presenceDelayMap.has(userID)) { - clearTimeout(presenceDelayMap.get(userID)) - } - // new timer, which will run if nothing else comes in soon - presenceDelayMap.set(userID, setTimeout(setPresenceCallback, presenceDelay, userID, status).unref()) -} - -/** - * @param {string} user_id Discord user ID - * @param {string} status status field from Discord's PRESENCE_UPDATE event - */ -function setPresenceCallback(user_id, status) { - presenceDelayMap.delete(user_id) - const mxid = select("sim", "mxid", {user_id}).pluck().get() - if (!mxid) return - const presence = - ( status === "online" ? "online" - : status === "offline" ? "offline" - : "unavailable") // idle, dnd, and anything else they dream up in the future - if (presence === "offline") { - userPresence.delete(mxid) // stop syncing next cycle - } else { - const delay = userPresence.get(mxid)?.delay || presenceLoopInterval * Math.random() // distribute the updates across the presence loop - userPresence.set(mxid, {data: {presence}, delay}) // will be synced next cycle - } -} - - -// ***** second phase ***** - - -// Synapse expires each user's presence after 30 seconds and makes them offline, so we have loop every 28 seconds and update each user again. +// Synapse expires each user's presence after 30 seconds and makes them offline, so we have to loop every 28 seconds and update each user again. const presenceLoopInterval = 28e3 -/** @type {Map} mxid -> presence data to send to api */ -const userPresence = new Map() - -sync.addTemporaryInterval(() => { - for (const [mxid, memory] of userPresence.entries()) { - // I haven't tried, but assuming Synapse explodes if you try to update too many presences at the same time, - // I'll space them out over the whole 28 second cycle. - setTimeout(() => { - const d = new Date().toISOString().slice(0, 19) - api.setPresence(memory.data, mxid).catch(e => { - console.error("d->m: Skipping presence update failure:") - console.error(e) - }) - }, memory.delay) +// Cache the list of enabled guilds rather than accessing it like multiple times per second when any user changes presence +const guildPresenceSetting = new class { + /** @private @type {Set} */ guilds + constructor() { + this.update() } -}, presenceLoopInterval) + update() { + this.guilds = new Set(select("guild_space", "guild_id", {presence: 1}).pluck().all()) + } + isEnabled(guildID) { + return this.guilds.has(guildID) + } +} +class Presence { + /** @type {string} */ userID + /** @type {{presence: "online" | "offline" | "unavailable", status_msg?: string}} */ data + /** @private @type {?string | undefined} */ mxid + /** @private @type {number} */ delay = Math.random() -module.exports.setPresence = setPresence -module.exports.checkPresenceEnabledGuilds = checkPresenceEnabledGuilds + constructor(userID) { + this.userID = userID + } + + /** + * @param {string} status status field from Discord's PRESENCE_UPDATE event + */ + setData(status) { + const presence = + ( status === "online" ? "online" + : status === "offline" ? "offline" + : "unavailable") + this.data = {presence} + } + + sync(presences) { + const mxid = this.mxid ??= select("sim", "mxid", {user_id: this.userID}).pluck().get() + if (!mxid) return presences.delete(this.userID) + // I haven't tried, but I assume Synapse explodes if you try to update too many presences at the same time. + // This random delay will space them out over the whole 28 second cycle. + setTimeout(() => { + api.setPresence(this.data, mxid).catch(() => {}) + }, this.delay) + } +} + +const presenceTracker = new class { + /** @private @type {Map} userID -> Presence */ presences + + constructor() { + sync.addTemporaryInterval(() => this.syncPresences(), presenceLoopInterval) + } + + /** + * This function is called for each Discord presence packet. + * @param {string} userID Discord user ID + * @param {string} guildID Discord guild ID that this presence applies to (really, the same presence applies to every single guild, but is delivered separately by Discord for some reason) + * @param {string} status status field from Discord's PRESENCE_UPDATE event + */ + incomingPresence(userID, guildID, status) { + // stop tracking offline presence objects - they will naturally expire and fall offline on the homeserver + if (status === "offline") return this.presences.delete(userID) + // check if we care about this guild + if (!guildPresenceSetting.isEnabled(guildID)) return + // start tracking presence for user (we'll check if they have a sim in the next sync loop) + this.getOrCreatePresence(userID).setData(status) + } + + /** @private */ + getOrCreatePresence(userID) { + return this.presences.get(userID) || (() => { + const presence = new Presence(userID) + this.presences.set(userID, presence) + return presence + })() + } + + /** @private */ + syncPresences() { + for (const presence of this.presences.values()) { + presence.sync(this.presences) + } + } +} + +module.exports.presenceTracker = presenceTracker +module.exports.guildPresenceSetting = guildPresenceSetting diff --git a/src/d2m/event-dispatcher.js b/src/d2m/event-dispatcher.js index 889e047..766c5b0 100644 --- a/src/d2m/event-dispatcher.js +++ b/src/d2m/event-dispatcher.js @@ -380,6 +380,6 @@ module.exports = { async onPresenceUpdate(client, data) { const status = data.status if (!status) return - setPresence.setPresence(data.user.id, data.guild_id, status) + setPresence.presenceTracker.incomingPresence(data.user.id, data.guild_id, status) } } diff --git a/src/web/routes/guild-settings.js b/src/web/routes/guild-settings.js index 7f2cb1d..4e36059 100644 --- a/src/web/routes/guild-settings.js +++ b/src/web/routes/guild-settings.js @@ -63,7 +63,7 @@ as.router.post("/api/presence", defineEventHandler(async event => { if (!managed.has(parsedBody.guild_id)) throw createError({status: 403, message: "Forbidden", data: "Can't change settings for a guild you don't have Manage Server permissions in"}) db.prepare("UPDATE guild_space SET presence = ? WHERE guild_id = ?").run(+!!parsedBody.presence, parsedBody.guild_id) - setPresence.checkPresenceEnabledGuilds() + setPresence.guildPresenceSetting.update() return null // 204 }))