Rewrite presence code
This commit is contained in:
		
							parent
							
								
									b6e12044a8
								
							
						
					
					
						commit
						cfaada6797
					
				
					 4 changed files with 94 additions and 85 deletions
				
			
		|  | @ -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", | ||||
|  |  | |||
|  | @ -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<string, NodeJS.Timeout>} 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<string>} */ | ||||
| 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<string, {data: {presence: "online" | "offline" | "unavailable", status_msg?: string}, delay: number}>} 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<string>} */ 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<string, Presence>} 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 | ||||
|  |  | |||
|  | @ -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) | ||||
| 	} | ||||
| } | ||||
|  |  | |||
|  | @ -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
 | ||||
| })) | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue