Skip to content

Commit

Permalink
faster and more verbose role sync
Browse files Browse the repository at this point in the history
  • Loading branch information
WhatCats committed Mar 6, 2024
1 parent 7020b7a commit 685f924
Show file tree
Hide file tree
Showing 8 changed files with 99 additions and 41 deletions.
19 changes: 13 additions & 6 deletions src/lib/db/DocumentCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,17 +29,24 @@ export class DocumentCache<T extends Document> extends Map<string, T> {

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
}
Expand Down
2 changes: 1 addition & 1 deletion src/lib/db/models/Config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ const schema = getSchemaFromClass(ConfigSchema)
export const Config = modelSchemaWithCache(schema, ConfigSchema)
export type Config = SchemaDocument<typeof schema>

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]]))
})
Expand Down
7 changes: 6 additions & 1 deletion src/lib/db/models/PositionRole.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) ?? [])]
}
Expand Down Expand Up @@ -71,7 +76,7 @@ const schema = getSchemaFromClass(PositionRoleSchema)
export const PositionRole = modelSchemaWithCache(schema, PositionRoleSchema)
export type PositionRole = SchemaDocument<typeof schema>

PositionRole.cache.on("set", (posRole) => {
PositionRole.cache.on("add", (posRole) => {
let guildMap = mapped.get(posRole.guildId)
if (!guildMap) {
guildMap = new Map()
Expand Down
2 changes: 1 addition & 1 deletion src/lib/db/models/Vouch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export type Vouch = SchemaDocument<typeof schema>

const REGEX = /(.+) (Vouch|Devouch) Expiration/g
const durations = new Map<string, number>()
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))
}
Expand Down
4 changes: 4 additions & 0 deletions src/lib/discord/BotModule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}

Expand All @@ -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() {}
}
5 changes: 2 additions & 3 deletions src/lib/discord/CommandInstaller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -238,8 +237,8 @@ export interface Command<
interaction: T extends string
? ComponentInteraction
: T extends ContextMenuCommandBuilder
? ContextMenuInteraction
: SlashCommandInteraction,
? ContextMenuInteraction
: SlashCommandInteraction,
) => Promise<unknown>
handleComponent?: (interaction: ComponentInteraction) => Promise<unknown>
handleAutocomplete?: (interaction: AutocompleteInteraction) => Promise<unknown>
Expand Down
4 changes: 2 additions & 2 deletions src/lib/utils/DynamicallyConfiguredCollection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export class DynamicallyConfiguredCollection<T> {
protected readonly removeCall: (obj: T) => unknown,
protected readonly created: Record<string, T> = {},
) {
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))
}

Expand All @@ -32,7 +32,7 @@ export class DynamicallyConfiguredCollection<T> {
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)
Expand Down
97 changes: 70 additions & 27 deletions src/modules/role-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
SlashCommandBuilder,
User,
} from "discord.js"

import {
AuditedGuildBan,
AuditedRoleUpdate,
Expand All @@ -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<string>()

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() {
Expand All @@ -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() {
Expand All @@ -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<string>()
const allowed = new Set<string>()

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(
Expand Down Expand Up @@ -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 })
},
})

Expand Down

0 comments on commit 685f924

Please sign in to comment.