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.
|
||||
*/
|
||||
|
||||
// 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
|
||||
|
||||
// ***** 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())
|
||||
// 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()
|
||||
}
|
||||
update() {
|
||||
this.guilds = new Set(select("guild_space", "guild_id", {presence: 1}).pluck().all())
|
||||
}
|
||||
isEnabled(guildID) {
|
||||
return this.guilds.has(guildID)
|
||||
}
|
||||
}
|
||||
checkPresenceEnabledGuilds()
|
||||
|
||||
/**
|
||||
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()
|
||||
|
||||
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
|
||||
*/
|
||||
function setPresence(userID, guildID, status) {
|
||||
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 (!presenceEnabledGuilds.has(guildID)) return
|
||||
// cancel existing timer if one is already set
|
||||
if (presenceDelayMap.has(userID)) {
|
||||
clearTimeout(presenceDelayMap.get(userID))
|
||||
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)
|
||||
}
|
||||
// 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
|
||||
/** @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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ***** 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.
|
||||
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)
|
||||
}
|
||||
}, presenceLoopInterval)
|
||||
|
||||
|
||||
module.exports.setPresence = setPresence
|
||||
module.exports.checkPresenceEnabledGuilds = checkPresenceEnabledGuilds
|
||||
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