From 685f924c63b50510f72476543b2ae29e370db917 Mon Sep 17 00:00:00 2001 From: WhatCats Date: Wed, 6 Mar 2024 12:01:38 +0100 Subject: [PATCH] faster and more verbose role sync --- src/lib/db/DocumentCache.ts | 19 ++-- src/lib/db/models/Config.ts | 2 +- src/lib/db/models/PositionRole.ts | 7 +- src/lib/db/models/Vouch.ts | 2 +- src/lib/discord/BotModule.ts | 4 + src/lib/discord/CommandInstaller.ts | 5 +- .../utils/DynamicallyConfiguredCollection.ts | 4 +- src/modules/role-sync.ts | 97 +++++++++++++------ 8 files changed, 99 insertions(+), 41 deletions(-) diff --git a/src/lib/db/DocumentCache.ts b/src/lib/db/DocumentCache.ts index cee4a27..1623ae9 100644 --- a/src/lib/db/DocumentCache.ts +++ b/src/lib/db/DocumentCache.ts @@ -29,17 +29,24 @@ export class DocumentCache extends Map { set(key: string, value: T): this { const old = this.get(key) - if (old && old !== value) this.events.emit("delete", old) - this.events.emit("set", value) - return super.set(key, value) + super.set(key, value) + if (old !== value) { + if (old !== undefined) this.events.emit("delete", old) + this.events.emit("add", value) + } + return this } delete(key: string): boolean { - if (this.has(key)) this.events.emit("delete", this.get(key)!) - return super.delete(key) + const value = this.get(key) + if (super.delete(key)) { + this.events.emit("delete", value) + return true + } + return false } - on(event: "set" | "delete", listener: (doc: T) => unknown): this { + on(event: "add" | "delete", listener: (doc: T) => unknown): this { this.events.on(event, listener) return this } diff --git a/src/lib/db/models/Config.ts b/src/lib/db/models/Config.ts index 1e24d60..4338544 100644 --- a/src/lib/db/models/Config.ts +++ b/src/lib/db/models/Config.ts @@ -82,7 +82,7 @@ const schema = getSchemaFromClass(ConfigSchema) export const Config = modelSchemaWithCache(schema, ConfigSchema) export type Config = SchemaDocument -Config.cache.on("set", (value) => { +Config.cache.on("add", (value) => { if (!mapped.get(value.type)?.set(value.guildId, value)) mapped.set(value.type, new Map([[value.guildId, value]])) }) diff --git a/src/lib/db/models/PositionRole.ts b/src/lib/db/models/PositionRole.ts index 042e303..0ba0371 100644 --- a/src/lib/db/models/PositionRole.ts +++ b/src/lib/db/models/PositionRole.ts @@ -35,6 +35,11 @@ class PositionRoleSchema { .filter((v): v is Role => v !== undefined) } + static getGuildRoles(guildId: string) { + if (!mapped.has(guildId)) return [] + return Array.from(mapped.get(guildId)!.values()).flatMap((v) => Array.from(v)) + } + static getPositionRoles(position: string, guildId: string) { return [...(mapped.get(guildId)?.get(position) ?? [])] } @@ -71,7 +76,7 @@ const schema = getSchemaFromClass(PositionRoleSchema) export const PositionRole = modelSchemaWithCache(schema, PositionRoleSchema) export type PositionRole = SchemaDocument -PositionRole.cache.on("set", (posRole) => { +PositionRole.cache.on("add", (posRole) => { let guildMap = mapped.get(posRole.guildId) if (!guildMap) { guildMap = new Map() diff --git a/src/lib/db/models/Vouch.ts b/src/lib/db/models/Vouch.ts index d0c54cc..b9fb5b3 100644 --- a/src/lib/db/models/Vouch.ts +++ b/src/lib/db/models/Vouch.ts @@ -80,7 +80,7 @@ export type Vouch = SchemaDocument const REGEX = /(.+) (Vouch|Devouch) Expiration/g const durations = new Map() -Config.cache.on("set", (config) => { +Config.cache.on("add", (config) => { if (config.type.match(REGEX) && config.guildId === ROLE_APP_HUB) { durations.set(config.type, TimeUtil.parseDuration(config.value)) } diff --git a/src/lib/discord/BotModule.ts b/src/lib/discord/BotModule.ts index 49eb71a..f37da70 100644 --- a/src/lib/discord/BotModule.ts +++ b/src/lib/discord/BotModule.ts @@ -17,6 +17,7 @@ export class BotModule { private setBot(bot: ScrimsBot) { Object.defineProperty(this, "bot", { value: bot }) this.bot.on("ready", () => this.onReady()) + this.bot.on("initialized", () => this.onInitialized()) this.addListeners() } @@ -25,4 +26,7 @@ export class BotModule { // eslint-disable-next-line @typescript-eslint/no-empty-function protected async onReady() {} + + // eslint-disable-next-line @typescript-eslint/no-empty-function + protected async onInitialized() {} } diff --git a/src/lib/discord/CommandInstaller.ts b/src/lib/discord/CommandInstaller.ts index 5acf73e..5b3c728 100644 --- a/src/lib/discord/CommandInstaller.ts +++ b/src/lib/discord/CommandInstaller.ts @@ -46,7 +46,6 @@ export class CommandInstaller { async initialize() { if (!this.bot.application) throw new TypeError("ClientApplication does not exist...") this.installCommands() - this.bot.off(Events.InteractionCreate, this.handler.handler) this.bot.on(Events.InteractionCreate, this.handler.handler) this.appCommands = await this.bot.application.commands.fetch({ withLocalizations: true }) await this.update() @@ -238,8 +237,8 @@ export interface Command< interaction: T extends string ? ComponentInteraction : T extends ContextMenuCommandBuilder - ? ContextMenuInteraction - : SlashCommandInteraction, + ? ContextMenuInteraction + : SlashCommandInteraction, ) => Promise handleComponent?: (interaction: ComponentInteraction) => Promise handleAutocomplete?: (interaction: AutocompleteInteraction) => Promise diff --git a/src/lib/utils/DynamicallyConfiguredCollection.ts b/src/lib/utils/DynamicallyConfiguredCollection.ts index accaf44..d0a1e8f 100644 --- a/src/lib/utils/DynamicallyConfiguredCollection.ts +++ b/src/lib/utils/DynamicallyConfiguredCollection.ts @@ -8,7 +8,7 @@ export class DynamicallyConfiguredCollection { protected readonly removeCall: (obj: T) => unknown, protected readonly created: Record = {}, ) { - Config.cache.on("set", (v) => this.onCacheSet(v).catch(console.error)) + Config.cache.on("add", (v) => this.onCacheAdd(v).catch(console.error)) Config.cache.on("delete", (v) => this.onCacheDelete(v).catch(console.error)) } @@ -32,7 +32,7 @@ export class DynamicallyConfiguredCollection { return entry.type === this.type } - protected async onCacheSet(entry: Config) { + protected async onCacheAdd(entry: Config) { if (this.isCorrectHandler(entry)) { this.remove(entry.guildId) this.created[entry.guildId] = await this.createCall(entry) diff --git a/src/modules/role-sync.ts b/src/modules/role-sync.ts index 3fe5a6b..5dcd13f 100644 --- a/src/modules/role-sync.ts +++ b/src/modules/role-sync.ts @@ -7,6 +7,7 @@ import { SlashCommandBuilder, User, } from "discord.js" + import { AuditedGuildBan, AuditedRoleUpdate, @@ -16,11 +17,23 @@ import { DiscordUtil, MessageOptionsBuilder, PositionRole, + UserError, } from "lib" -import { Positions, RANKS } from "@Constants" +import { HOST_GUILD_ID, Positions, RANKS } from "@Constants" const LOG_CHANNEL = Config.declareType("Positions Log Channel") +const CONFIGURED_POSITIONS = new Set() + +PositionRole.cache.on("add", (posRole) => { + CONFIGURED_POSITIONS.add(posRole.position) +}) + +PositionRole.cache.on("delete", (posRole) => { + if (!PositionRole.cache.find((v) => v.position === posRole.position)) { + CONFIGURED_POSITIONS.delete(posRole.position) + } +}) export class RoleSyncModule extends BotModule { addListeners() { @@ -29,8 +42,6 @@ export class RoleSyncModule extends BotModule { this.bot.auditedEvents.on(AuditLogEvent.MemberBanAdd, (action) => this.onBanChange(action)) this.bot.auditedEvents.on(AuditLogEvent.MemberBanRemove, (action) => this.onBanChange(action)) this.bot.auditedEvents.on(AuditLogEvent.MemberRoleUpdate, (action) => this.onRolesChange(action)) - - this.bot.on("initialized", () => this.onInitialized()) } async onReady() { @@ -48,69 +59,91 @@ export class RoleSyncModule extends BotModule { } async onInitialized() { - if (this.bot.host) { - await Promise.all(this.bot.users.cache.map((user) => this.syncUserRoles(user).catch(() => null))) - } + this.syncRoles().catch(console.error) + setInterval(() => this.syncRoles(), 20 * 60 * 1000) } - get hostGuildId() { - return this.bot.hostGuildId + async syncRoles() { + const host = this.bot.host + if (!host) { + console.warn(`[Role Sync] Host guild (${HOST_GUILD_ID}) not in cache!`) + return + } + + const expected = host.memberCount + const before = host.members.cache.size + const fetched = (await host.members.fetch()).size + if (fetched !== before) { + console.warn( + `[Role Sync] Host members needed to be refreshed (${before} -> ${fetched}/${expected})!`, + ) + } + + await Promise.all(this.bot.users.cache.map((user) => this.syncUserRoles(user).catch(console.error))) } async onMemberAdd(member: GuildMember | PartialGuildMember) { - if (member.guild.id !== this.hostGuildId) await this.syncMemberRoles(member) + if (member.guild.id !== HOST_GUILD_ID) await this.syncMemberRoles(member) } async onMemberRemove(member: GuildMember | PartialGuildMember) { - if (member.guild.id === this.hostGuildId) await this.syncUserRoles(member.user, null) + if (member.guild.id === HOST_GUILD_ID) await this.syncUserRoles(member.user, null) } async onBanChange({ guild, user, executor }: AuditedGuildBan) { - if (guild.id === this.hostGuildId) await this.syncUserRoles(user, executor) + if (guild.id === HOST_GUILD_ID) await this.syncUserRoles(user, executor) } async onRolesChange({ guild, memberId, executor, added, removed }: AuditedRoleUpdate) { - if (guild.id !== this.hostGuildId && executor.id === this.bot.user?.id) return + if (guild.id !== HOST_GUILD_ID && executor.id === this.bot.user?.id) return - const positionRoles = PositionRole.cache.filter((v) => v.guildId === guild.id) - if (!positionRoles.find((v) => added.includes(v.roleId) || removed.includes(v.roleId))) return + const positionRoles = new Set(PositionRole.getGuildRoles(guild.id).map((v) => v.roleId)) + if (!added.concat(removed).find((v) => positionRoles.has(v))) return const member = await guild.members.fetch(memberId) - if (member.guild.id === this.hostGuildId) await this.syncUserRoles(member.user, executor) + if (member.guild.id === HOST_GUILD_ID) await this.syncUserRoles(member.user, executor) else await this.syncMemberRoles(member) } async syncUserRoles(user: User, executor?: User | null) { await Promise.all( Array.from(this.bot.guilds.cache.values()).map(async (guild) => { - if (guild.id !== this.hostGuildId && guild.members.resolve(user)) + if (guild.id !== HOST_GUILD_ID && guild.members.resolve(user)) await this.syncMemberRoles(guild.members.resolve(user)!, executor) }), ) } async syncMemberRoles(member: GuildMember | PartialGuildMember, executor?: User | null) { - if (member.guild.id === this.hostGuildId) return + if (member.guild.id === HOST_GUILD_ID) return - const posRoles = PositionRole.cache.filter((v) => v.guildId === member.guild.id) - const positions = this.bot.permissions.getUsersPositions(member.user) - const forbidden = this.bot.permissions.getUsersForbiddenPositions(member.user) + const forbidden = new Set() + const allowed = new Set() + + for (const position of CONFIGURED_POSITIONS) { + const permission = this.bot.permissions.hasPosition(member.user, position) + if (permission === false) forbidden.add(position) + else if (permission) allowed.add(position) + } for (const rank of Object.values(RANKS).reverse()) { - if (positions.has(rank)) { + if (allowed.has(rank)) { Object.values(RANKS) .filter((v) => v !== rank) .forEach((v) => { - positions.delete(v) + allowed.delete(v) forbidden.add(v) }) break } } - const add = PositionRole.resolvePermittedRoles(posRoles.filter((p) => positions.has(p.position))) + const add = PositionRole.resolvePermittedRoles( + Array.from(allowed).flatMap((pos) => PositionRole.getPositionRoles(pos, member.guild.id)), + ) + const remove = PositionRole.resolvePermittedRoles( - posRoles.filter((p) => forbidden.has(p.position)), + Array.from(forbidden).flatMap((pos) => PositionRole.getPositionRoles(pos, member.guild.id)), ).filter((v) => !add.includes(v)) const removeResults = await Promise.all( @@ -173,9 +206,19 @@ Command({ config: { permissions: { positionLevel: Positions.Staff } }, async handler(interaction) { - await interaction.deferReply({ ephemeral: true }) - await RoleSyncModule.getInstance().onInitialized() - await interaction.editReply({ content: "Role sync finished." }) + const host = interaction.client.host + if (!host) throw new UserError(`Host guild (${HOST_GUILD_ID}) not in cache!`) + + const guilds = Array.from(interaction.client.guilds.cache.filter((v) => v !== host).values()) + const members = guilds.reduce((pv, cv) => pv + cv.members.cache.size, 0) + await interaction.reply({ + content: `Syncing ${members} member(s) over ${guilds.length} guild(s)...`, + ephemeral: true, + }) + + const start = Date.now() + await RoleSyncModule.getInstance().syncRoles() + await interaction.followUp({ content: `Finished after ${Date.now() - start}ms`, ephemeral: true }) }, })