diff --git a/backend/ormconfig.js b/backend/ormconfig.js index f49cfb58..a3a8f122 100644 --- a/backend/ormconfig.js +++ b/backend/ormconfig.js @@ -1,6 +1,5 @@ const fs = require("fs"); const path = require("path"); -const pkgUp = require("pkg-up"); const { backendDir } = require("./dist/backend/src/paths"); const { env } = require("./dist/backend/src/env"); diff --git a/backend/src/Blocker.ts b/backend/src/Blocker.ts new file mode 100644 index 00000000..7cae2c77 --- /dev/null +++ b/backend/src/Blocker.ts @@ -0,0 +1,40 @@ +export type Block = { + count: number; + unblock: () => void; + getPromise: () => Promise; +}; + +export class Blocker { + #blocks: Map = new Map(); + + block(key: string): void { + if (!this.#blocks.has(key)) { + const promise = new Promise((resolve) => { + this.#blocks.set(key, { + count: 0, // Incremented to 1 further below + unblock() { + this.count--; + if (this.count === 0) { + resolve(); + } + }, + getPromise: () => promise, // :d + }); + }); + } + this.#blocks.get(key)!.count++; + } + + unblock(key: string): void { + if (this.#blocks.has(key)) { + this.#blocks.get(key)!.unblock(); + } + } + + async waitToBeUnblocked(key: string): Promise { + if (!this.#blocks.has(key)) { + return; + } + await this.#blocks.get(key)!.getPromise(); + } +} diff --git a/backend/src/api/index.ts b/backend/src/api/index.ts index 649a3c0b..503b717e 100644 --- a/backend/src/api/index.ts +++ b/backend/src/api/index.ts @@ -1,3 +1,7 @@ +// KEEP THIS AS FIRST IMPORT +// See comment in module for details +import "../threadsSignalFix"; + import { connect } from "../data/db"; import { env } from "../env"; import { setIsAPI } from "../globals"; diff --git a/backend/src/data/DefaultLogMessages.json b/backend/src/data/DefaultLogMessages.json index 173067d1..e6ad770b 100644 --- a/backend/src/data/DefaultLogMessages.json +++ b/backend/src/data/DefaultLogMessages.json @@ -13,8 +13,8 @@ "MEMBER_SOFTBAN": "{timestamp} 🔨 {userMention(member)} was softbanned by {userMention(mod)}", "MEMBER_JOIN": "{timestamp} 📥 {new} {userMention(member)} joined (created {account_age} ago)", "MEMBER_LEAVE": "{timestamp} 📤 {userMention(member)} left the server", - "MEMBER_ROLE_ADD": "{timestamp} 🔑 {userMention(member)} received roles: **{roles}**", - "MEMBER_ROLE_REMOVE": "{timestamp} 🔑 {userMention(member)} lost roles: **{roles}**", + "MEMBER_ROLE_ADD": "{timestamp} 🔑 {userMention(mod)} added roles for {userMention(member)}: **{roles}**", + "MEMBER_ROLE_REMOVE": "{timestamp} 🔑 {userMention(mod)} removed roles from {userMention(member)}: **{roles}**", "MEMBER_ROLE_CHANGES": "{timestamp} 🔑 {userMention(member)} had role changes: received **{addedRoles}**, lost **{removedRoles}**", "MEMBER_NICK_CHANGE": "{timestamp} ✏ {userMention(member)}: nickname changed from **{oldNick}** to **{newNick}**", "MEMBER_USERNAME_CHANGE": "{timestamp} ✏ {userMention(user)}: username changed from **{oldName}** to **{newName}**", diff --git a/backend/src/data/GuildMemberCache.ts b/backend/src/data/GuildMemberCache.ts new file mode 100644 index 00000000..39ef9b78 --- /dev/null +++ b/backend/src/data/GuildMemberCache.ts @@ -0,0 +1,101 @@ +import moment from "moment-timezone"; +import { getRepository, Repository } from "typeorm"; +import { Blocker } from "../Blocker"; +import { DBDateFormat, MINUTES } from "../utils"; +import { BaseGuildRepository } from "./BaseGuildRepository"; +import { MemberCacheItem } from "./entities/MemberCacheItem"; + +const SAVE_PENDING_BLOCKER_KEY = "save-pending" as const; + +const DELETION_DELAY = 5 * MINUTES; + +type UpdateData = Pick; + +export class GuildMemberCache extends BaseGuildRepository { + #memberCache: Repository; + + #pendingUpdates: Map>; + + #blocker: Blocker; + + constructor(guildId: string) { + super(guildId); + this.#memberCache = getRepository(MemberCacheItem); + this.#pendingUpdates = new Map(); + this.#blocker = new Blocker(); + } + + async savePendingUpdates(): Promise { + await this.#blocker.waitToBeUnblocked(SAVE_PENDING_BLOCKER_KEY); + + if (this.#pendingUpdates.size === 0) { + return; + } + + this.#blocker.block(SAVE_PENDING_BLOCKER_KEY); + + const entitiesToSave = Array.from(this.#pendingUpdates.values()); + this.#pendingUpdates.clear(); + + await this.#memberCache.upsert(entitiesToSave, ["guild_id", "user_id"]).finally(() => { + this.#blocker.unblock(SAVE_PENDING_BLOCKER_KEY); + }); + } + + async getCachedMemberData(userId: string): Promise { + await this.#blocker.waitToBeUnblocked(SAVE_PENDING_BLOCKER_KEY); + + const dbItem = await this.#memberCache.findOne({ + where: { + guild_id: this.guildId, + user_id: userId, + }, + }); + const pendingItem = this.#pendingUpdates.get(userId); + if (!dbItem && !pendingItem) { + return null; + } + + const item = new MemberCacheItem(); + Object.assign(item, dbItem ?? {}); + Object.assign(item, pendingItem ?? {}); + return item; + } + + async setCachedMemberData(userId: string, data: UpdateData): Promise { + await this.#blocker.waitToBeUnblocked(SAVE_PENDING_BLOCKER_KEY); + + if (!this.#pendingUpdates.has(userId)) { + const newItem = new MemberCacheItem(); + newItem.guild_id = this.guildId; + newItem.user_id = userId; + this.#pendingUpdates.set(userId, newItem); + } + Object.assign(this.#pendingUpdates.get(userId)!, data); + this.#pendingUpdates.get(userId)!.last_seen = moment().format("YYYY-MM-DD"); + } + + async markMemberForDeletion(userId: string): Promise { + await this.#memberCache.update( + { + guild_id: this.guildId, + user_id: userId, + }, + { + delete_at: moment().add(DELETION_DELAY, "ms").format(DBDateFormat), + }, + ); + } + + async unmarkMemberForDeletion(userId: string): Promise { + await this.#memberCache.update( + { + guild_id: this.guildId, + user_id: userId, + }, + { + delete_at: null, + }, + ); + } +} diff --git a/backend/src/data/GuildPersistedData.ts b/backend/src/data/GuildPersistedData.ts index 4dd8cffd..22eef70b 100644 --- a/backend/src/data/GuildPersistedData.ts +++ b/backend/src/data/GuildPersistedData.ts @@ -2,11 +2,6 @@ import { getRepository, Repository } from "typeorm"; import { BaseGuildRepository } from "./BaseGuildRepository"; import { PersistedData } from "./entities/PersistedData"; -export interface IPartialPersistData { - roles?: string[]; - nickname?: string; -} - export class GuildPersistedData extends BaseGuildRepository { private persistedData: Repository; @@ -24,11 +19,7 @@ export class GuildPersistedData extends BaseGuildRepository { }); } - async set(userId: string, data: IPartialPersistData = {}) { - const finalData: any = {}; - if (data.roles) finalData.roles = data.roles.join(","); - if (data.nickname) finalData.nickname = data.nickname; - + async set(userId: string, data: Partial = {}) { const existing = await this.find(userId); if (existing) { await this.persistedData.update( @@ -36,11 +27,11 @@ export class GuildPersistedData extends BaseGuildRepository { guild_id: this.guildId, user_id: userId, }, - finalData, + data, ); } else { await this.persistedData.insert({ - ...finalData, + ...data, guild_id: this.guildId, user_id: userId, }); diff --git a/backend/src/data/MemberCache.ts b/backend/src/data/MemberCache.ts new file mode 100644 index 00000000..29cc61aa --- /dev/null +++ b/backend/src/data/MemberCache.ts @@ -0,0 +1,29 @@ +import moment from "moment-timezone"; +import { getRepository, Repository } from "typeorm"; +import { DAYS } from "../utils"; +import { BaseRepository } from "./BaseRepository"; +import { MemberCacheItem } from "./entities/MemberCacheItem"; + +const STALE_PERIOD = 90 * DAYS; + +export class MemberCache extends BaseRepository { + #memberCache: Repository; + + constructor() { + super(); + this.#memberCache = getRepository(MemberCacheItem); + } + + async deleteStaleData(): Promise { + const cutoff = moment().subtract(STALE_PERIOD, "ms").format("YYYY-MM-DD"); + await this.#memberCache.createQueryBuilder().where("last_seen < :cutoff", { cutoff }).delete().execute(); + } + + async deleteMarkedToBeDeletedEntries(): Promise { + await this.#memberCache + .createQueryBuilder() + .where("delete_at IS NOT NULL AND delete_at <= NOW()") + .delete() + .execute(); + } +} diff --git a/backend/src/data/entities/MemberCacheItem.ts b/backend/src/data/entities/MemberCacheItem.ts new file mode 100644 index 00000000..9e5d8dab --- /dev/null +++ b/backend/src/data/entities/MemberCacheItem.ts @@ -0,0 +1,20 @@ +import { Column, Entity, PrimaryGeneratedColumn } from "typeorm"; + +@Entity("member_cache") +export class MemberCacheItem { + @PrimaryGeneratedColumn() id: number; + + @Column() guild_id: string; + + @Column() user_id: string; + + @Column() username: string; + + @Column({ type: String, nullable: true }) nickname: string | null; + + @Column("simple-json") roles: string[]; + + @Column() last_seen: string; + + @Column({ type: String, nullable: true }) delete_at: string | null; +} diff --git a/backend/src/data/entities/PersistedData.ts b/backend/src/data/entities/PersistedData.ts index 03c196b4..56706052 100644 --- a/backend/src/data/entities/PersistedData.ts +++ b/backend/src/data/entities/PersistedData.ts @@ -14,5 +14,5 @@ export class PersistedData { @Column() nickname: string; - @Column() is_voice_muted: number; + @Column({ type: "boolean" }) is_voice_muted: boolean; } diff --git a/backend/src/data/loops/expiredMemberCacheDeletionLoop.ts b/backend/src/data/loops/expiredMemberCacheDeletionLoop.ts new file mode 100644 index 00000000..13c60da4 --- /dev/null +++ b/backend/src/data/loops/expiredMemberCacheDeletionLoop.ts @@ -0,0 +1,13 @@ +// tslint:disable:no-console + +import { HOURS, lazyMemoize } from "../../utils"; +import { MemberCache } from "../MemberCache"; + +const LOOP_INTERVAL = 6 * HOURS; +const getMemberCacheRepository = lazyMemoize(() => new MemberCache()); + +export async function runExpiredMemberCacheDeletionLoop() { + console.log("[EXPIRED MEMBER CACHE DELETION LOOP] Deleting stale member cache entries"); + await getMemberCacheRepository().deleteStaleData(); + setTimeout(() => runExpiredMemberCacheDeletionLoop(), LOOP_INTERVAL); +} diff --git a/backend/src/data/loops/memberCacheDeletionLoop.ts b/backend/src/data/loops/memberCacheDeletionLoop.ts new file mode 100644 index 00000000..ff1a6cab --- /dev/null +++ b/backend/src/data/loops/memberCacheDeletionLoop.ts @@ -0,0 +1,13 @@ +// tslint:disable:no-console + +import { lazyMemoize, MINUTES } from "../../utils"; +import { MemberCache } from "../MemberCache"; + +const LOOP_INTERVAL = 5 * MINUTES; +const getMemberCacheRepository = lazyMemoize(() => new MemberCache()); + +export async function runMemberCacheDeletionLoop() { + console.log("[MEMBER CACHE DELETION LOOP] Deleting entries marked to be deleted"); + await getMemberCacheRepository().deleteMarkedToBeDeletedEntries(); + setTimeout(() => runMemberCacheDeletionLoop(), LOOP_INTERVAL); +} diff --git a/backend/src/index.ts b/backend/src/index.ts index 44ea7e1d..c0f2a822 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,3 +1,7 @@ +// KEEP THIS AS FIRST IMPORT +// See comment in module for details +import "./threadsSignalFix"; + import { Client, Events, @@ -12,31 +16,34 @@ import { EventEmitter } from "events"; import { Knub, PluginError, PluginLoadError, PluginNotLoadedError } from "knub"; import moment from "moment-timezone"; import { performance } from "perf_hooks"; +import process from "process"; +import { DiscordJSError } from "./DiscordJSError"; +import { RecoverablePluginError } from "./RecoverablePluginError"; +import { SimpleError } from "./SimpleError"; import { AllowedGuilds } from "./data/AllowedGuilds"; import { Configs } from "./data/Configs"; -import { connect } from "./data/db"; import { GuildLogs } from "./data/GuildLogs"; import { LogType } from "./data/LogType"; +import { hasPhishermanMasterAPIKey } from "./data/Phisherman"; +import { connect } from "./data/db"; import { runExpiredArchiveDeletionLoop } from "./data/loops/expiredArchiveDeletionLoop"; +import { runExpiredMemberCacheDeletionLoop } from "./data/loops/expiredMemberCacheDeletionLoop"; import { runExpiringMutesLoop } from "./data/loops/expiringMutesLoop"; import { runExpiringTempbansLoop } from "./data/loops/expiringTempbansLoop"; import { runExpiringVCAlertsLoop } from "./data/loops/expiringVCAlertsLoop"; +import { runMemberCacheDeletionLoop } from "./data/loops/memberCacheDeletionLoop"; import { runPhishermanCacheCleanupLoop, runPhishermanReportingLoop } from "./data/loops/phishermanLoops"; import { runSavedMessageCleanupLoop } from "./data/loops/savedMessageCleanupLoop"; import { runUpcomingRemindersLoop } from "./data/loops/upcomingRemindersLoop"; import { runUpcomingScheduledPostsLoop } from "./data/loops/upcomingScheduledPostsLoop"; -import { hasPhishermanMasterAPIKey } from "./data/Phisherman"; import { consumeQueryStats } from "./data/queryLogger"; -import { DiscordJSError } from "./DiscordJSError"; import { env } from "./env"; import { logger } from "./logger"; import { baseGuildPlugins, globalPlugins, guildPlugins } from "./plugins/availablePlugins"; import { setProfiler } from "./profiler"; import { logRateLimit } from "./rateLimitStats"; -import { RecoverablePluginError } from "./RecoverablePluginError"; -import { SimpleError } from "./SimpleError"; import { startUptimeCounter } from "./uptime"; -import { errorMessage, isDiscordAPIError, isDiscordHTTPError, MINUTES, SECONDS, sleep, successMessage } from "./utils"; +import { MINUTES, SECONDS, errorMessage, isDiscordAPIError, isDiscordHTTPError, sleep, successMessage } from "./utils"; import { DecayingCounter } from "./utils/DecayingCounter"; import { enableProfiling } from "./utils/easyProfiler"; import { loadYamlSafely } from "./utils/loadYamlSafely"; @@ -191,7 +198,7 @@ setInterval(() => { }, 5 * 60 * 1000); logger.info("Connecting to database"); -connect().then(async () => { +connect().then(async (connection) => { const client = new Client({ partials: [Partials.User, Partials.Channel, Partials.GuildMember, Partials.Message, Partials.Reaction], @@ -365,6 +372,11 @@ connect().then(async () => { }); bot.on("loadingFinished", async () => { + setProfiler(bot.profiler); + if (process.env.PROFILING === "true") { + enableProfiling(); + } + runExpiringMutesLoop(); await sleep(10 * SECONDS); runExpiringTempbansLoop(); @@ -378,6 +390,10 @@ connect().then(async () => { runExpiredArchiveDeletionLoop(); await sleep(10 * SECONDS); runSavedMessageCleanupLoop(); + await sleep(10 * SECONDS); + runExpiredMemberCacheDeletionLoop(); + await sleep(10 * SECONDS); + runMemberCacheDeletionLoop(); if (hasPhishermanMasterAPIKey()) { await sleep(10 * SECONDS); @@ -387,11 +403,6 @@ connect().then(async () => { } }); - setProfiler(bot.profiler); - if (process.env.PROFILING === "true") { - enableProfiling(); - } - let lowestGlobalRemaining = Infinity; setInterval(() => { lowestGlobalRemaining = Math.min(lowestGlobalRemaining, (client as any).rest.globalRemaining); @@ -422,4 +433,28 @@ connect().then(async () => { logger.info("Bot Initialized"); logger.info("Logging in..."); await client.login(env.BOT_TOKEN); + + let stopping = false; + const cleanupAndStop = async (code) => { + if (stopping) { + return; + } + stopping = true; + logger.info("Cleaning up before exit..."); + // Force exit after 10sec + setTimeout(() => process.exit(code), 10 * SECONDS); + await bot.stop(); + await connection.close(); + logger.info("Done! Exiting now."); + process.exit(code); + }; + process.on("beforeExit", () => cleanupAndStop(0)); + process.on("SIGINT", () => { + logger.info("Received SIGINT, exiting..."); + cleanupAndStop(0); + }); + process.on("SIGTERM", (code) => { + logger.info("Received SIGTERM, exiting..."); + cleanupAndStop(0); + }); }); diff --git a/backend/src/migrations/1682788165866-CreateMemberCacheTable.ts b/backend/src/migrations/1682788165866-CreateMemberCacheTable.ts new file mode 100644 index 00000000..01fca205 --- /dev/null +++ b/backend/src/migrations/1682788165866-CreateMemberCacheTable.ts @@ -0,0 +1,69 @@ +import { MigrationInterface, QueryRunner, Table } from "typeorm"; + +export class CreateMemberCacheTable1682788165866 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + name: "member_cache", + columns: [ + { + name: "id", + type: "int", + isPrimary: true, + isGenerated: true, + generationStrategy: "increment", + }, + { + name: "guild_id", + type: "bigint", + }, + { + name: "user_id", + type: "bigint", + }, + { + name: "username", + type: "varchar", + length: "255", + }, + { + name: "nickname", + type: "varchar", + length: "255", + isNullable: true, + }, + { + name: "roles", + type: "text", + }, + { + name: "last_seen", + type: "date", + }, + { + name: "delete_at", + type: "datetime", + isNullable: true, + default: null, + }, + ], + indices: [ + { + columnNames: ["guild_id", "user_id"], + isUnique: true, + }, + { + columnNames: ["last_seen"], + }, + { + columnNames: ["delete_at"], + }, + ], + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable("member_cache"); + } +} diff --git a/backend/src/plugins/Automod/AutomodPlugin.ts b/backend/src/plugins/Automod/AutomodPlugin.ts index 7b664bf9..306a5ad1 100644 --- a/backend/src/plugins/Automod/AutomodPlugin.ts +++ b/backend/src/plugins/Automod/AutomodPlugin.ts @@ -15,6 +15,7 @@ import { LogsPlugin } from "../Logs/LogsPlugin"; import { ModActionsPlugin } from "../ModActions/ModActionsPlugin"; import { MutesPlugin } from "../Mutes/MutesPlugin"; import { PhishermanPlugin } from "../Phisherman/PhishermanPlugin"; +import { RoleManagerPlugin } from "../RoleManager/RoleManagerPlugin"; import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint"; import { availableActions } from "./actions/availableActions"; import { AntiraidClearCmd } from "./commands/AntiraidClearCmd"; @@ -196,6 +197,7 @@ export const AutomodPlugin = zeppelinGuildPlugin()({ CountersPlugin, PhishermanPlugin, InternalPosterPlugin, + RoleManagerPlugin, ], defaultOptions, diff --git a/backend/src/plugins/Automod/actions/addRoles.ts b/backend/src/plugins/Automod/actions/addRoles.ts index ce2f02d9..53b00005 100644 --- a/backend/src/plugins/Automod/actions/addRoles.ts +++ b/backend/src/plugins/Automod/actions/addRoles.ts @@ -3,9 +3,9 @@ import * as t from "io-ts"; import { nonNullish, unique } from "../../../utils"; import { canAssignRole } from "../../../utils/canAssignRole"; import { getMissingPermissions } from "../../../utils/getMissingPermissions"; -import { memberRolesLock } from "../../../utils/lockNameHelpers"; import { missingPermissionError } from "../../../utils/missingPermissionError"; import { LogsPlugin } from "../../Logs/LogsPlugin"; +import { RoleManagerPlugin } from "../../RoleManager/RoleManagerPlugin"; import { ignoreRoleChange } from "../functions/ignoredRoleChanges"; import { automodAction } from "../helpers"; @@ -52,25 +52,14 @@ export const AddRolesAction = automodAction({ await Promise.all( members.map(async (member) => { - const memberRoles = new Set(member.roles.cache.keys()); + const currentMemberRoles = new Set(member.roles.cache.keys()); for (const roleId of rolesToAssign) { - memberRoles.add(roleId as Snowflake); - ignoreRoleChange(pluginData, member.id, roleId); + if (!currentMemberRoles.has(roleId)) { + pluginData.getPlugin(RoleManagerPlugin).addRole(member.id, roleId); + // TODO: Remove this and just ignore bot changes in general? + ignoreRoleChange(pluginData, member.id, roleId); + } } - - if (memberRoles.size === member.roles.cache.size) { - // No role changes - return; - } - - const memberRoleLock = await pluginData.locks.acquire(memberRolesLock(member)); - - const rolesArr = Array.from(memberRoles.values()); - await member.edit({ - roles: rolesArr, - }); - - memberRoleLock.unlock(); }), ); }, diff --git a/backend/src/plugins/GuildMemberCache/GuildMemberCachePlugin.ts b/backend/src/plugins/GuildMemberCache/GuildMemberCachePlugin.ts new file mode 100644 index 00000000..d29b342d --- /dev/null +++ b/backend/src/plugins/GuildMemberCache/GuildMemberCachePlugin.ts @@ -0,0 +1,53 @@ +import * as t from "io-ts"; +import { GuildMemberCache } from "../../data/GuildMemberCache"; +import { makeIoTsConfigParser, mapToPublicFn } from "../../pluginUtils"; +import { SECONDS } from "../../utils"; +import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint"; +import { cancelDeletionOnMemberJoin } from "./events/cancelDeletionOnMemberJoin"; +import { removeMemberCacheOnMemberLeave } from "./events/removeMemberCacheOnMemberLeave"; +import { updateMemberCacheOnMemberUpdate } from "./events/updateMemberCacheOnMemberUpdate"; +import { updateMemberCacheOnMessage } from "./events/updateMemberCacheOnMessage"; +import { updateMemberCacheOnRoleChange } from "./events/updateMemberCacheOnRoleChange"; +import { updateMemberCacheOnVoiceStateUpdate } from "./events/updateMemberCacheOnVoiceStateUpdate"; +import { getCachedMemberData } from "./functions/getCachedMemberData"; +import { GuildMemberCachePluginType } from "./types"; + +const PENDING_SAVE_INTERVAL = 30 * SECONDS; + +export const GuildMemberCachePlugin = zeppelinGuildPlugin()({ + name: "guild_member_cache", + showInDocs: false, + + configParser: makeIoTsConfigParser(t.type({})), + + events: [ + updateMemberCacheOnMemberUpdate, + updateMemberCacheOnMessage, + updateMemberCacheOnVoiceStateUpdate, + updateMemberCacheOnRoleChange, + removeMemberCacheOnMemberLeave, + cancelDeletionOnMemberJoin, + ], + + public: { + getCachedMemberData: mapToPublicFn(getCachedMemberData), + }, + + beforeLoad(pluginData) { + pluginData.state.memberCache = GuildMemberCache.getGuildInstance(pluginData.guild.id); + // This won't leak memory... too much #trust + pluginData.state.initialUpdatedMembers = new Set(); + }, + + afterLoad(pluginData) { + pluginData.state.saveInterval = setInterval( + () => pluginData.state.memberCache.savePendingUpdates(), + PENDING_SAVE_INTERVAL, + ); + }, + + async beforeUnload(pluginData) { + clearInterval(pluginData.state.saveInterval); + await pluginData.state.memberCache.savePendingUpdates(); + }, +}); diff --git a/backend/src/plugins/GuildMemberCache/events/cancelDeletionOnMemberJoin.ts b/backend/src/plugins/GuildMemberCache/events/cancelDeletionOnMemberJoin.ts new file mode 100644 index 00000000..158922d3 --- /dev/null +++ b/backend/src/plugins/GuildMemberCache/events/cancelDeletionOnMemberJoin.ts @@ -0,0 +1,9 @@ +import { guildPluginEventListener } from "knub"; +import { GuildMemberCachePluginType } from "../types"; + +export const cancelDeletionOnMemberJoin = guildPluginEventListener()({ + event: "guildMemberAdd", + async listener({ pluginData, args: { member } }) { + pluginData.state.memberCache.unmarkMemberForDeletion(member.id); + }, +}); diff --git a/backend/src/plugins/GuildMemberCache/events/removeMemberCacheOnMemberLeave.ts b/backend/src/plugins/GuildMemberCache/events/removeMemberCacheOnMemberLeave.ts new file mode 100644 index 00000000..336e86c8 --- /dev/null +++ b/backend/src/plugins/GuildMemberCache/events/removeMemberCacheOnMemberLeave.ts @@ -0,0 +1,12 @@ +import { guildPluginEventListener } from "knub"; +import { MINUTES } from "../../../utils"; +import { GuildMemberCachePluginType } from "../types"; + +const DELETION_DELAY = 2 * MINUTES; + +export const removeMemberCacheOnMemberLeave = guildPluginEventListener()({ + event: "guildMemberRemove", + async listener({ pluginData, args: { member } }) { + pluginData.state.memberCache.markMemberForDeletion(member.id); + }, +}); diff --git a/backend/src/plugins/GuildMemberCache/events/updateMemberCacheOnMemberUpdate.ts b/backend/src/plugins/GuildMemberCache/events/updateMemberCacheOnMemberUpdate.ts new file mode 100644 index 00000000..22549145 --- /dev/null +++ b/backend/src/plugins/GuildMemberCache/events/updateMemberCacheOnMemberUpdate.ts @@ -0,0 +1,15 @@ +import { AuditLogEvent } from "discord.js"; +import { guildPluginEventListener } from "knub"; +import { updateMemberCacheForMember } from "../functions/updateMemberCacheForMember"; +import { GuildMemberCachePluginType } from "../types"; + +export const updateMemberCacheOnMemberUpdate = guildPluginEventListener()({ + event: "guildAuditLogEntryCreate", + async listener({ pluginData, args: { auditLogEntry } }) { + if (auditLogEntry.action !== AuditLogEvent.MemberUpdate) { + return; + } + + updateMemberCacheForMember(pluginData, auditLogEntry.targetId!); + }, +}); diff --git a/backend/src/plugins/GuildMemberCache/events/updateMemberCacheOnMessage.ts b/backend/src/plugins/GuildMemberCache/events/updateMemberCacheOnMessage.ts new file mode 100644 index 00000000..22850651 --- /dev/null +++ b/backend/src/plugins/GuildMemberCache/events/updateMemberCacheOnMessage.ts @@ -0,0 +1,16 @@ +import { guildPluginEventListener } from "knub"; +import { updateMemberCacheForMember } from "../functions/updateMemberCacheForMember"; +import { GuildMemberCachePluginType } from "../types"; + +export const updateMemberCacheOnMessage = guildPluginEventListener()({ + event: "messageCreate", + listener({ pluginData, args }) { + // Update each member once per guild load when we see a message from them + const key = `${pluginData.guild.id}-${args.message.author.id}`; + if (pluginData.state.initialUpdatedMembers.has(key)) { + return; + } + updateMemberCacheForMember(pluginData, args.message.author.id); + pluginData.state.initialUpdatedMembers.add(key); + }, +}); diff --git a/backend/src/plugins/GuildMemberCache/events/updateMemberCacheOnRoleChange.ts b/backend/src/plugins/GuildMemberCache/events/updateMemberCacheOnRoleChange.ts new file mode 100644 index 00000000..147cb9ab --- /dev/null +++ b/backend/src/plugins/GuildMemberCache/events/updateMemberCacheOnRoleChange.ts @@ -0,0 +1,15 @@ +import { AuditLogEvent } from "discord.js"; +import { guildPluginEventListener } from "knub"; +import { updateMemberCacheForMember } from "../functions/updateMemberCacheForMember"; +import { GuildMemberCachePluginType } from "../types"; + +export const updateMemberCacheOnRoleChange = guildPluginEventListener()({ + event: "guildAuditLogEntryCreate", + async listener({ pluginData, args: { auditLogEntry } }) { + if (auditLogEntry.action !== AuditLogEvent.MemberRoleUpdate) { + return; + } + + updateMemberCacheForMember(pluginData, auditLogEntry.targetId!); + }, +}); diff --git a/backend/src/plugins/GuildMemberCache/events/updateMemberCacheOnVoiceStateUpdate.ts b/backend/src/plugins/GuildMemberCache/events/updateMemberCacheOnVoiceStateUpdate.ts new file mode 100644 index 00000000..e613d520 --- /dev/null +++ b/backend/src/plugins/GuildMemberCache/events/updateMemberCacheOnVoiceStateUpdate.ts @@ -0,0 +1,20 @@ +import { guildPluginEventListener } from "knub"; +import { updateMemberCacheForMember } from "../functions/updateMemberCacheForMember"; +import { GuildMemberCachePluginType } from "../types"; + +export const updateMemberCacheOnVoiceStateUpdate = guildPluginEventListener()({ + event: "voiceStateUpdate", + listener({ pluginData, args }) { + const memberId = args.newState.member?.id; + if (!memberId) { + return; + } + // Update each member once per guild load when we see a message from them + const key = `${pluginData.guild.id}-${memberId}`; + if (pluginData.state.initialUpdatedMembers.has(key)) { + return; + } + updateMemberCacheForMember(pluginData, memberId); + pluginData.state.initialUpdatedMembers.add(key); + }, +}); diff --git a/backend/src/plugins/GuildMemberCache/functions/getCachedMemberData.ts b/backend/src/plugins/GuildMemberCache/functions/getCachedMemberData.ts new file mode 100644 index 00000000..add7cae1 --- /dev/null +++ b/backend/src/plugins/GuildMemberCache/functions/getCachedMemberData.ts @@ -0,0 +1,10 @@ +import { GuildPluginData } from "knub"; +import { MemberCacheItem } from "../../../data/entities/MemberCacheItem"; +import { GuildMemberCachePluginType } from "../types"; + +export function getCachedMemberData( + pluginData: GuildPluginData, + userId: string, +): Promise { + return pluginData.state.memberCache.getCachedMemberData(userId); +} diff --git a/backend/src/plugins/GuildMemberCache/functions/updateMemberCacheForMember.ts b/backend/src/plugins/GuildMemberCache/functions/updateMemberCacheForMember.ts new file mode 100644 index 00000000..242ff406 --- /dev/null +++ b/backend/src/plugins/GuildMemberCache/functions/updateMemberCacheForMember.ts @@ -0,0 +1,17 @@ +import { GuildPluginData } from "knub"; +import { GuildMemberCachePluginType } from "../types"; + +export async function updateMemberCacheForMember( + pluginData: GuildPluginData, + userId: string, +) { + const upToDateMember = await pluginData.guild.members.fetch(userId); + const roles = Array.from(upToDateMember.roles.cache.keys()) + // Filter out @everyone role + .filter((roleId) => roleId !== pluginData.guild.id); + pluginData.state.memberCache.setCachedMemberData(upToDateMember.id, { + username: upToDateMember.user.username, + nickname: upToDateMember.nickname, + roles, + }); +} diff --git a/backend/src/plugins/GuildMemberCache/types.ts b/backend/src/plugins/GuildMemberCache/types.ts new file mode 100644 index 00000000..03f75147 --- /dev/null +++ b/backend/src/plugins/GuildMemberCache/types.ts @@ -0,0 +1,10 @@ +import { BasePluginType } from "knub"; +import { GuildMemberCache } from "../../data/GuildMemberCache"; + +export interface GuildMemberCachePluginType extends BasePluginType { + state: { + memberCache: GuildMemberCache; + saveInterval: NodeJS.Timeout; + initialUpdatedMembers: Set; + }; +} diff --git a/backend/src/plugins/Logs/LogsPlugin.ts b/backend/src/plugins/Logs/LogsPlugin.ts index 4543a33a..bd970160 100644 --- a/backend/src/plugins/Logs/LogsPlugin.ts +++ b/backend/src/plugins/Logs/LogsPlugin.ts @@ -8,7 +8,7 @@ import { LogType } from "../../data/LogType"; import { logger } from "../../logger"; import { makeIoTsConfigParser, mapToPublicFn } from "../../pluginUtils"; import { discardRegExpRunner, getRegExpRunner } from "../../regExpRunners"; -import { createTypedTemplateSafeValueContainer, TypedTemplateSafeValueContainer } from "../../templateFormatter"; +import { TypedTemplateSafeValueContainer, createTypedTemplateSafeValueContainer } from "../../templateFormatter"; import { TimeAndDatePlugin } from "../TimeAndDate/TimeAndDatePlugin"; import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint"; import { LogsChannelCreateEvt, LogsChannelDeleteEvt, LogsChannelUpdateEvt } from "./events/LogsChannelModifyEvts"; @@ -40,6 +40,7 @@ import { onMessageUpdate } from "./util/onMessageUpdate"; import { escapeCodeBlock } from "discord.js"; import { InternalPosterPlugin } from "../InternalPoster/InternalPosterPlugin"; +import { LogsGuildMemberRoleChangeEvt } from "./events/LogsGuildMemberRoleChangeEvt"; import { logAutomodAction } from "./logFunctions/logAutomodAction"; import { logBotAlert } from "./logFunctions/logBotAlert"; import { logCaseCreate } from "./logFunctions/logCaseCreate"; @@ -173,6 +174,7 @@ export const LogsPlugin = zeppelinGuildPlugin()({ LogsStickerCreateEvt, LogsStickerDeleteEvt, LogsStickerUpdateEvt, + LogsGuildMemberRoleChangeEvt, ], public: { diff --git a/backend/src/plugins/Logs/events/LogsGuildMemberRoleChangeEvt.ts b/backend/src/plugins/Logs/events/LogsGuildMemberRoleChangeEvt.ts new file mode 100644 index 00000000..ad1ef2d1 --- /dev/null +++ b/backend/src/plugins/Logs/events/LogsGuildMemberRoleChangeEvt.ts @@ -0,0 +1,58 @@ +import { APIRole, AuditLogChange, AuditLogEvent } from "discord.js"; +import { guildPluginEventListener } from "knub"; +import { resolveRole } from "../../../utils"; +import { logMemberRoleAdd } from "../logFunctions/logMemberRoleAdd"; +import { logMemberRoleRemove } from "../logFunctions/logMemberRoleRemove"; +import { LogsPluginType } from "../types"; + +type RoleAddChange = AuditLogChange & { + key: "$add"; + new: Array>; +}; + +function isRoleAddChange(change: AuditLogChange): change is RoleAddChange { + return change.key === "$add"; +} + +type RoleRemoveChange = AuditLogChange & { + key: "$remove"; + new: Array>; +}; + +function isRoleRemoveChange(change: AuditLogChange): change is RoleRemoveChange { + return change.key === "$remove"; +} + +export const LogsGuildMemberRoleChangeEvt = guildPluginEventListener()({ + event: "guildAuditLogEntryCreate", + async listener({ pluginData, args: { auditLogEntry } }) { + // Ignore the bot's own audit log events + if (auditLogEntry.executorId === pluginData.client.user?.id) { + return; + } + if (auditLogEntry.action !== AuditLogEvent.MemberRoleUpdate) { + return; + } + + const member = await pluginData.guild.members.fetch(auditLogEntry.targetId!); + const mod = await pluginData.client.users.fetch(auditLogEntry.executorId!); + for (const change of auditLogEntry.changes) { + if (isRoleAddChange(change)) { + const addedRoles = change.new.map((r) => resolveRole(pluginData.guild, r.id)); + logMemberRoleAdd(pluginData, { + member, + mod, + roles: addedRoles, + }); + } + if (isRoleRemoveChange(change)) { + const removedRoles = change.new.map((r) => resolveRole(pluginData.guild, r.id)); + logMemberRoleRemove(pluginData, { + member, + mod, + roles: removedRoles, + }); + } + } + }, +}); diff --git a/backend/src/plugins/Logs/events/LogsUserUpdateEvts.ts b/backend/src/plugins/Logs/events/LogsUserUpdateEvts.ts index 2fee32ab..f5da56fc 100644 --- a/backend/src/plugins/Logs/events/LogsUserUpdateEvts.ts +++ b/backend/src/plugins/Logs/events/LogsUserUpdateEvts.ts @@ -1,10 +1,4 @@ -import diff from "lodash.difference"; -import isEqual from "lodash.isequal"; -import { LogType } from "../../../data/LogType"; import { logMemberNickChange } from "../logFunctions/logMemberNickChange"; -import { logMemberRoleAdd } from "../logFunctions/logMemberRoleAdd"; -import { logMemberRoleChanges } from "../logFunctions/logMemberRoleChanges"; -import { logMemberRoleRemove } from "../logFunctions/logMemberRoleRemove"; import { logsEvt } from "../types"; export const LogsGuildMemberUpdateEvt = logsEvt({ @@ -14,8 +8,6 @@ export const LogsGuildMemberUpdateEvt = logsEvt({ const pluginData = meta.pluginData; const oldMember = meta.args.oldMember; const member = meta.args.newMember; - const oldRoles = [...oldMember.roles.cache.keys()]; - const currentRoles = [...member.roles.cache.keys()]; if (!oldMember || oldMember.partial) { return; @@ -28,62 +20,5 @@ export const LogsGuildMemberUpdateEvt = logsEvt({ newNick: member.nickname != null ? member.nickname : "", }); } - - if (!isEqual(oldRoles, currentRoles)) { - const addedRoles = diff(currentRoles, oldRoles); - const removedRoles = diff(oldRoles, currentRoles); - let skip = false; - - if ( - addedRoles.length && - removedRoles.length && - pluginData.state.guildLogs.isLogIgnored(LogType.MEMBER_ROLE_CHANGES, member.id) - ) { - skip = true; - } else if (addedRoles.length && pluginData.state.guildLogs.isLogIgnored(LogType.MEMBER_ROLE_ADD, member.id)) { - skip = true; - } else if ( - removedRoles.length && - pluginData.state.guildLogs.isLogIgnored(LogType.MEMBER_ROLE_REMOVE, member.id) - ) { - skip = true; - } - - if (!skip) { - if (addedRoles.length && removedRoles.length) { - // Roles added *and* removed - logMemberRoleChanges(pluginData, { - member, - addedRoles: addedRoles.map( - (roleId) => pluginData.guild.roles.cache.get(roleId) ?? { id: roleId, name: `Unknown (${roleId})` }, - ), - removedRoles: removedRoles.map( - (roleId) => pluginData.guild.roles.cache.get(roleId) ?? { id: roleId, name: `Unknown (${roleId})` }, - ), - mod: null, - }); - } else if (addedRoles.length) { - // Roles added - logMemberRoleAdd(pluginData, { - member, - roles: addedRoles.map( - (roleId) => pluginData.guild.roles.cache.get(roleId) ?? { id: roleId, name: `Unknown (${roleId})` }, - ), - mod: null, - }); - } else if (removedRoles.length && !addedRoles.length) { - // Roles removed - logMemberRoleRemove(pluginData, { - member, - roles: removedRoles.map( - (roleId) => pluginData.guild.roles.cache.get(roleId) ?? { id: roleId, name: `Unknown (${roleId})` }, - ), - mod: null, - }); - } - } - } }, }); - -// TODO: Reimplement USERNAME_CHANGE diff --git a/backend/src/plugins/Logs/logFunctions/logMemberRoleAdd.ts b/backend/src/plugins/Logs/logFunctions/logMemberRoleAdd.ts index e5175e3d..bb6255df 100644 --- a/backend/src/plugins/Logs/logFunctions/logMemberRoleAdd.ts +++ b/backend/src/plugins/Logs/logFunctions/logMemberRoleAdd.ts @@ -2,6 +2,7 @@ import { GuildMember, Role, User } from "discord.js"; import { GuildPluginData } from "knub"; import { LogType } from "../../../data/LogType"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter"; +import { UnknownRole } from "../../../utils"; import { memberToTemplateSafeMember, userToTemplateSafeUser } from "../../../utils/templateSafeObjects"; import { LogsPluginType } from "../types"; import { log } from "../util/log"; @@ -9,7 +10,7 @@ import { log } from "../util/log"; interface LogMemberRoleAddData { mod: User | null; member: GuildMember; - roles: Role[]; + roles: Array; } export function logMemberRoleAdd(pluginData: GuildPluginData, data: LogMemberRoleAddData) { diff --git a/backend/src/plugins/Logs/logFunctions/logMemberRoleChanges.ts b/backend/src/plugins/Logs/logFunctions/logMemberRoleChanges.ts index 3af76352..f548fe2d 100644 --- a/backend/src/plugins/Logs/logFunctions/logMemberRoleChanges.ts +++ b/backend/src/plugins/Logs/logFunctions/logMemberRoleChanges.ts @@ -14,6 +14,9 @@ interface LogMemberRoleChangesData { removedRoles: Role[]; } +/** + * @deprecated Use logMemberRoleAdd() and logMemberRoleRemove() instead + */ export function logMemberRoleChanges(pluginData: GuildPluginData, data: LogMemberRoleChangesData) { return log( pluginData, diff --git a/backend/src/plugins/Logs/logFunctions/logMemberRoleRemove.ts b/backend/src/plugins/Logs/logFunctions/logMemberRoleRemove.ts index 67ce1c38..4e620eec 100644 --- a/backend/src/plugins/Logs/logFunctions/logMemberRoleRemove.ts +++ b/backend/src/plugins/Logs/logFunctions/logMemberRoleRemove.ts @@ -2,6 +2,7 @@ import { GuildMember, Role, User } from "discord.js"; import { GuildPluginData } from "knub"; import { LogType } from "../../../data/LogType"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter"; +import { UnknownRole } from "../../../utils"; import { memberToTemplateSafeMember, userToTemplateSafeUser } from "../../../utils/templateSafeObjects"; import { LogsPluginType } from "../types"; import { log } from "../util/log"; @@ -9,7 +10,7 @@ import { log } from "../util/log"; interface LogMemberRoleRemoveData { mod: User | null; member: GuildMember; - roles: Role[]; + roles: Array; } export function logMemberRoleRemove(pluginData: GuildPluginData, data: LogMemberRoleRemoveData) { diff --git a/backend/src/plugins/Mutes/events/ReapplyActiveMuteOnJoinEvt.ts b/backend/src/plugins/Mutes/events/ReapplyActiveMuteOnJoinEvt.ts index ccc60c55..a859f7ad 100644 --- a/backend/src/plugins/Mutes/events/ReapplyActiveMuteOnJoinEvt.ts +++ b/backend/src/plugins/Mutes/events/ReapplyActiveMuteOnJoinEvt.ts @@ -1,8 +1,7 @@ -import { Snowflake } from "discord.js"; import moment from "moment-timezone"; import { MuteTypes } from "../../../data/MuteTypes"; -import { memberRolesLock } from "../../../utils/lockNameHelpers"; import { LogsPlugin } from "../../Logs/LogsPlugin"; +import { RoleManagerPlugin } from "../../RoleManager/RoleManagerPlugin"; import { getTimeoutExpiryTime } from "../functions/getTimeoutExpiryTime"; import { mutesEvt } from "../types"; @@ -18,15 +17,9 @@ export const ReapplyActiveMuteOnJoinEvt = mutesEvt({ } if (mute.type === MuteTypes.Role) { - const muteRole = pluginData.config.get().mute_role; - - if (muteRole) { - const memberRoleLock = await pluginData.locks.acquire(memberRolesLock(member)); - try { - await member.roles.add(muteRole as Snowflake); - } finally { - memberRoleLock.unlock(); - } + const muteRoleId = pluginData.config.get().mute_role; + if (muteRoleId) { + pluginData.getPlugin(RoleManagerPlugin).addPriorityRole(member.id, muteRoleId); } } else { if (!member.isCommunicationDisabled()) { diff --git a/backend/src/plugins/Mutes/functions/clearMute.ts b/backend/src/plugins/Mutes/functions/clearMute.ts index 2fe99576..9156c9e8 100644 --- a/backend/src/plugins/Mutes/functions/clearMute.ts +++ b/backend/src/plugins/Mutes/functions/clearMute.ts @@ -1,11 +1,12 @@ import { GuildMember } from "discord.js"; import { GuildPluginData } from "knub"; +import { MuteTypes } from "../../../data/MuteTypes"; import { Mute } from "../../../data/entities/Mute"; import { clearExpiringMute } from "../../../data/loops/expiringMutesLoop"; -import { MuteTypes } from "../../../data/MuteTypes"; import { resolveMember, verboseUserMention } from "../../../utils"; import { memberRolesLock } from "../../../utils/lockNameHelpers"; import { LogsPlugin } from "../../Logs/LogsPlugin"; +import { RoleManagerPlugin } from "../../RoleManager/RoleManagerPlugin"; import { MutesPluginType } from "../types"; export async function clearMute( @@ -23,15 +24,16 @@ export async function clearMute( if (member) { const lock = await pluginData.locks.acquire(memberRolesLock(member)); + const roleManagerPlugin = pluginData.getPlugin(RoleManagerPlugin); try { const defaultMuteRole = pluginData.config.get().mute_role; if (mute) { - const muteRole = mute.mute_role || pluginData.config.get().mute_role; + const muteRoleId = mute.mute_role || pluginData.config.get().mute_role; if (mute.type === MuteTypes.Role) { - if (muteRole) { - await member.roles.remove(muteRole); + if (muteRoleId) { + roleManagerPlugin.removePriorityRole(member.id, muteRoleId); } } else { await member.timeout(null); @@ -39,19 +41,18 @@ export async function clearMute( if (mute.roles_to_restore) { const guildRoles = pluginData.guild.roles.cache; - const newRoles = [...member.roles.cache.keys()].filter((roleId) => roleId !== muteRole); - for (const toRestore of mute?.roles_to_restore) { - if (guildRoles.has(toRestore) && toRestore !== muteRole && !newRoles.includes(toRestore)) { - newRoles.push(toRestore); + const newRoles = [...member.roles.cache.keys()].filter((roleId) => roleId !== muteRoleId); + for (const roleIdToRestore of mute?.roles_to_restore) { + if (guildRoles.has(roleIdToRestore) && roleIdToRestore !== muteRoleId) { + roleManagerPlugin.addRole(member.id, roleIdToRestore); } } - await member.roles.set(newRoles); } } else { // Unmuting someone without an active mute -> remove timeouts and/or mute role const muteRole = pluginData.config.get().mute_role; if (muteRole && member.roles.cache.has(muteRole)) { - await member.roles.remove(muteRole); + roleManagerPlugin.removePriorityRole(member.id, muteRole); } if (member.isCommunicationDisabled()) { await member.timeout(null); diff --git a/backend/src/plugins/Mutes/functions/muteUser.ts b/backend/src/plugins/Mutes/functions/muteUser.ts index 5d23a916..12db3e8f 100644 --- a/backend/src/plugins/Mutes/functions/muteUser.ts +++ b/backend/src/plugins/Mutes/functions/muteUser.ts @@ -1,26 +1,27 @@ import { Snowflake } from "discord.js"; import humanizeDuration from "humanize-duration"; import { GuildPluginData } from "knub"; +import { ERRORS, RecoverablePluginError } from "../../../RecoverablePluginError"; import { CaseTypes } from "../../../data/CaseTypes"; +import { AddMuteParams } from "../../../data/GuildMutes"; +import { MuteTypes } from "../../../data/MuteTypes"; import { Case } from "../../../data/entities/Case"; import { Mute } from "../../../data/entities/Mute"; -import { AddMuteParams } from "../../../data/GuildMutes"; import { registerExpiringMute } from "../../../data/loops/expiringMutesLoop"; -import { MuteTypes } from "../../../data/MuteTypes"; import { LogsPlugin } from "../../../plugins/Logs/LogsPlugin"; -import { ERRORS, RecoverablePluginError } from "../../../RecoverablePluginError"; -import { renderTemplate, TemplateSafeValueContainer } from "../../../templateFormatter"; +import { TemplateSafeValueContainer, renderTemplate } from "../../../templateFormatter"; import { + UserNotificationMethod, + UserNotificationResult, notifyUser, resolveMember, resolveUser, ucfirst, - UserNotificationMethod, - UserNotificationResult, } from "../../../utils"; import { muteLock } from "../../../utils/lockNameHelpers"; import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects"; import { CasesPlugin } from "../../Cases/CasesPlugin"; +import { RoleManagerPlugin } from "../../RoleManager/RoleManagerPlugin"; import { MuteOptions, MutesPluginType } from "../types"; import { getDefaultMuteType } from "./getDefaultMuteType"; import { getTimeoutExpiryTime } from "./getTimeoutExpiryTime"; @@ -91,38 +92,29 @@ export async function muteUser( } if (muteType === MuteTypes.Role) { - // Apply mute role if it's missing - if (!currentUserRoles.includes(muteRole!)) { - try { - await member.roles.add(muteRole!); - } catch (e) { - const actualMuteRole = pluginData.guild.roles.cache.get(muteRole!); - if (!actualMuteRole) { - lock.unlock(); - logs.logBotAlert({ - body: `Cannot mute users, specified mute role Id is invalid`, - }); - throw new RecoverablePluginError(ERRORS.INVALID_MUTE_ROLE_ID); - } + // Verify the configured mute role is valid + const actualMuteRole = pluginData.guild.roles.cache.get(muteRole!); + if (!actualMuteRole) { + lock.unlock(); + logs.logBotAlert({ + body: `Cannot mute users, specified mute role Id is invalid`, + }); + throw new RecoverablePluginError(ERRORS.INVALID_MUTE_ROLE_ID); + } - const zep = await resolveMember(pluginData.client, pluginData.guild, pluginData.client.user!.id); - const zepRoles = pluginData.guild.roles.cache.filter((x) => zep!.roles.cache.has(x.id)); - // If we have roles and one of them is above the muted role, throw generic error - if (zepRoles.size >= 0 && zepRoles.some((zepRole) => zepRole.position > actualMuteRole.position)) { - lock.unlock(); - logs.logBotAlert({ - body: `Cannot mute user ${member.id}: ${e}`, - }); - throw e; - } else { - // Otherwise, throw error that mute role is above zeps roles - lock.unlock(); - logs.logBotAlert({ - body: `Cannot mute users, specified mute role is above Zeppelin in the role hierarchy`, - }); - throw new RecoverablePluginError(ERRORS.MUTE_ROLE_ABOVE_ZEP, pluginData.guild); - } - } + // Verify the mute role is not above Zep's roles + const zep = await pluginData.guild.members.fetchMe(); + const zepRoles = pluginData.guild.roles.cache.filter((x) => zep.roles.cache.has(x.id)); + if (zepRoles.size === 0 || !zepRoles.some((zepRole) => zepRole.position > actualMuteRole.position)) { + lock.unlock(); + logs.logBotAlert({ + body: `Cannot mute users, specified mute role is above Zeppelin in the role hierarchy`, + }); + throw new RecoverablePluginError(ERRORS.MUTE_ROLE_ABOVE_ZEP, pluginData.guild); + } + + if (!currentUserRoles.includes(muteRole!)) { + pluginData.getPlugin(RoleManagerPlugin).addPriorityRole(member.id, muteRole!); } } else { await member.disableCommunicationUntil(timeoutUntil); diff --git a/backend/src/plugins/Persist/PersistPlugin.ts b/backend/src/plugins/Persist/PersistPlugin.ts index 8b53d9f2..ecd50067 100644 --- a/backend/src/plugins/Persist/PersistPlugin.ts +++ b/backend/src/plugins/Persist/PersistPlugin.ts @@ -3,7 +3,9 @@ import { GuildLogs } from "../../data/GuildLogs"; import { GuildPersistedData } from "../../data/GuildPersistedData"; import { makeIoTsConfigParser } from "../../pluginUtils"; import { trimPluginDescription } from "../../utils"; +import { GuildMemberCachePlugin } from "../GuildMemberCache/GuildMemberCachePlugin"; import { LogsPlugin } from "../Logs/LogsPlugin"; +import { RoleManagerPlugin } from "../RoleManager/RoleManagerPlugin"; import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint"; import { LoadDataEvt } from "./events/LoadDataEvt"; import { StoreDataEvt } from "./events/StoreDataEvt"; @@ -29,7 +31,7 @@ export const PersistPlugin = zeppelinGuildPlugin()({ configSchema: ConfigSchema, }, - dependencies: () => [LogsPlugin], + dependencies: () => [LogsPlugin, RoleManagerPlugin, GuildMemberCachePlugin], configParser: makeIoTsConfigParser(ConfigSchema), defaultOptions, diff --git a/backend/src/plugins/Persist/events/LoadDataEvt.ts b/backend/src/plugins/Persist/events/LoadDataEvt.ts index ef97c616..7db23931 100644 --- a/backend/src/plugins/Persist/events/LoadDataEvt.ts +++ b/backend/src/plugins/Persist/events/LoadDataEvt.ts @@ -2,9 +2,9 @@ import { GuildMemberEditOptions, PermissionFlagsBits } from "discord.js"; import intersection from "lodash.intersection"; import { canAssignRole } from "../../../utils/canAssignRole"; import { getMissingPermissions } from "../../../utils/getMissingPermissions"; -import { memberRolesLock } from "../../../utils/lockNameHelpers"; import { missingPermissionError } from "../../../utils/missingPermissionError"; import { LogsPlugin } from "../../Logs/LogsPlugin"; +import { RoleManagerPlugin } from "../../RoleManager/RoleManagerPlugin"; import { persistEvt } from "../types"; const p = PermissionFlagsBits; @@ -16,13 +16,11 @@ export const LoadDataEvt = persistEvt({ const member = meta.args.member; const pluginData = meta.pluginData; - const memberRoleLock = await pluginData.locks.acquire(memberRolesLock(member)); - const persistedData = await pluginData.state.persistedData.find(member.id); if (!persistedData) { - memberRoleLock.unlock(); return; } + await pluginData.state.persistedData.clear(member.id); const toRestore: GuildMemberEditOptions = { reason: "Restored upon rejoin", @@ -59,29 +57,29 @@ export const LoadDataEvt = persistEvt({ const persistedRoles = config.persisted_roles; if (persistedRoles.length) { + const roleManager = pluginData.getPlugin(RoleManagerPlugin); const rolesToRestore = intersection(persistedRoles, persistedData.roles, guildRoles); if (rolesToRestore.length) { restoredData.push("roles"); - toRestore.roles = Array.from(new Set([...rolesToRestore, ...member.roles.cache.keys()])); + for (const roleId of rolesToRestore) { + roleManager.addRole(member.id, roleId); + } } } if (config.persist_nicknames && persistedData.nickname) { restoredData.push("nickname"); - toRestore.nick = persistedData.nickname; + await member.edit({ + nick: persistedData.nickname, + }); } if (restoredData.length) { - await member.edit(toRestore); - await pluginData.state.persistedData.clear(member.id); - pluginData.getPlugin(LogsPlugin).logMemberRestore({ member, restoredData: restoredData.join(", "), }); } - - memberRoleLock.unlock(); }, }); diff --git a/backend/src/plugins/Persist/events/StoreDataEvt.ts b/backend/src/plugins/Persist/events/StoreDataEvt.ts index 45dbda4b..537c7b62 100644 --- a/backend/src/plugins/Persist/events/StoreDataEvt.ts +++ b/backend/src/plugins/Persist/events/StoreDataEvt.ts @@ -1,34 +1,41 @@ -import { GuildMember } from "discord.js"; -import intersection from "lodash.intersection"; -import { IPartialPersistData } from "../../../data/GuildPersistedData"; +import { PersistedData } from "../../../data/entities/PersistedData"; +import { GuildMemberCachePlugin } from "../../GuildMemberCache/GuildMemberCachePlugin"; import { persistEvt } from "../types"; export const StoreDataEvt = persistEvt({ event: "guildMemberRemove", - async listener(meta) { - const member = meta.args.member as GuildMember; - const pluginData = meta.pluginData; - - let persist = false; - const persistData: IPartialPersistData = {}; + async listener({ pluginData, args: { member } }) { const config = await pluginData.config.getForUser(member.user); + const persistData: Partial = {}; - const persistedRoles = config.persisted_roles; - if (persistedRoles.length && member.roles) { - const rolesToPersist = intersection(persistedRoles, [...member.roles.cache.keys()]); + if (member.partial) { + // Djs hasn't cached member data => use db cache + const data = await pluginData.getPlugin(GuildMemberCachePlugin).getCachedMemberData(member.id); + if (!data) { + return; + } + + const rolesToPersist = config.persisted_roles.filter((roleId) => data.roles.includes(roleId)); if (rolesToPersist.length) { - persist = true; persistData.roles = rolesToPersist; } + if (config.persist_nicknames && data.nickname) { + persistData.nickname = data.nickname; + } + } else { + // Djs has cached member data => use that + const memberRoles = Array.from(member.roles.cache.keys()); + const rolesToPersist = config.persisted_roles.filter((roleId) => memberRoles.includes(roleId)); + if (rolesToPersist.length) { + persistData.roles = rolesToPersist; + } + if (config.persist_nicknames && member.nickname) { + persistData.nickname = member.nickname as any; + } } - if (config.persist_nicknames && member.nickname) { - persist = true; - persistData.nickname = member.nickname; - } - - if (persist) { + if (Object.keys(persistData).length) { pluginData.state.persistedData.set(member.id, persistData); } }, diff --git a/backend/src/plugins/RoleButtons/events/buttonInteraction.ts b/backend/src/plugins/RoleButtons/events/buttonInteraction.ts index 6fcc27e6..bc8c7632 100644 --- a/backend/src/plugins/RoleButtons/events/buttonInteraction.ts +++ b/backend/src/plugins/RoleButtons/events/buttonInteraction.ts @@ -1,10 +1,13 @@ import { GuildMember } from "discord.js"; import { guildPluginEventListener } from "knub"; +import { SECONDS } from "../../../utils"; import { parseCustomId } from "../../../utils/parseCustomId"; import { RoleManagerPlugin } from "../../RoleManager/RoleManagerPlugin"; import { getAllRolesInButtons } from "../functions/getAllRolesInButtons"; import { RoleButtonsPluginType, TRoleButtonOption } from "../types"; +const ROLE_BUTTON_CD = 5 * SECONDS; + export const onButtonInteraction = guildPluginEventListener()({ event: "interactionCreate", async listener({ pluginData, args }) { @@ -33,6 +36,16 @@ export const onButtonInteraction = guildPluginEventListener) { if (pluginData.state.roleAssignmentLoopRunning || pluginData.state.abortRoleAssignmentLoop) { @@ -30,40 +29,25 @@ export async function runRoleAssignmentLoop(pluginData: GuildPluginData(); for (const assignment of nextAssignments) { - const key = `${assignment.should_add ? 1 : 0}|${assignment.user_id}|${assignment.role_id}`; - const oppositeKey = `${assignment.should_add ? 0 : 1}|${assignment.user_id}|${assignment.role_id}`; - if (validAssignments.has(oppositeKey)) { - validAssignments.delete(oppositeKey); - continue; + const member = await pluginData.guild.members.fetch(assignment.user_id).catch(() => null); + if (!member) { + return; } - validAssignments.set(key, assignment); - } - // Apply batch in parallel - await Promise.all( - Array.from(validAssignments.values()).map(async (assignment) => { - const member = await pluginData.guild.members.fetch(assignment.user_id).catch(() => null); - if (!member) { - return; - } + const operation = assignment.should_add + ? member.roles.add(assignment.role_id) + : member.roles.remove(assignment.role_id); - const operation = assignment.should_add - ? member.roles.add(assignment.role_id) - : member.roles.remove(assignment.role_id); - - await operation.catch((err) => { - logger.warn(err); - pluginData.getPlugin(LogsPlugin).logBotAlert({ - body: `Could not ${assignment.should_add ? "assign" : "remove"} role <@&${assignment.role_id}> (\`${ - assignment.role_id - }\`) ${assignment.should_add ? "to" : "from"} <@!${assignment.user_id}> (\`${assignment.user_id}\`)`, - }); + await operation.catch((err) => { + logger.warn(err); + pluginData.getPlugin(LogsPlugin).logBotAlert({ + body: `Could not ${assignment.should_add ? "assign" : "remove"} role <@&${assignment.role_id}> (\`${ + assignment.role_id + }\`) ${assignment.should_add ? "to" : "from"} <@!${assignment.user_id}> (\`${assignment.user_id}\`)`, }); - }), - ); + }); + } })()); } } diff --git a/backend/src/plugins/Roles/RolesPlugin.ts b/backend/src/plugins/Roles/RolesPlugin.ts index c173ddd1..6c810940 100644 --- a/backend/src/plugins/Roles/RolesPlugin.ts +++ b/backend/src/plugins/Roles/RolesPlugin.ts @@ -3,6 +3,7 @@ import { GuildLogs } from "../../data/GuildLogs"; import { makeIoTsConfigParser } from "../../pluginUtils"; import { trimPluginDescription } from "../../utils"; import { LogsPlugin } from "../Logs/LogsPlugin"; +import { RoleManagerPlugin } from "../RoleManager/RoleManagerPlugin"; import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint"; import { AddRoleCmd } from "./commands/AddRoleCmd"; import { MassAddRoleCmd } from "./commands/MassAddRoleCmd"; @@ -43,7 +44,7 @@ export const RolesPlugin = zeppelinGuildPlugin()({ configSchema: ConfigSchema, }, - dependencies: () => [LogsPlugin], + dependencies: () => [LogsPlugin, RoleManagerPlugin], configParser: makeIoTsConfigParser(ConfigSchema), defaultOptions, diff --git a/backend/src/plugins/Roles/commands/AddRoleCmd.ts b/backend/src/plugins/Roles/commands/AddRoleCmd.ts index 49c21d23..ccd64325 100644 --- a/backend/src/plugins/Roles/commands/AddRoleCmd.ts +++ b/backend/src/plugins/Roles/commands/AddRoleCmd.ts @@ -1,9 +1,9 @@ import { GuildChannel } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes"; -import { LogType } from "../../../data/LogType"; import { canActOn, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils"; import { resolveRoleId, verboseUserMention } from "../../../utils"; import { LogsPlugin } from "../../Logs/LogsPlugin"; +import { RoleManagerPlugin } from "../../RoleManager/RoleManagerPlugin"; import { rolesCmd } from "../types"; export const AddRoleCmd = rolesCmd({ @@ -49,9 +49,7 @@ export const AddRoleCmd = rolesCmd({ return; } - pluginData.state.logs.ignoreLog(LogType.MEMBER_ROLE_ADD, args.member.id); - - await args.member.roles.add(roleId); + pluginData.getPlugin(RoleManagerPlugin).addRole(args.member.id, roleId); pluginData.getPlugin(LogsPlugin).logMemberRoleAdd({ mod: msg.author, diff --git a/backend/src/plugins/Roles/commands/MassAddRoleCmd.ts b/backend/src/plugins/Roles/commands/MassAddRoleCmd.ts index 4c3f1ab6..9030e45c 100644 --- a/backend/src/plugins/Roles/commands/MassAddRoleCmd.ts +++ b/backend/src/plugins/Roles/commands/MassAddRoleCmd.ts @@ -1,10 +1,10 @@ import { GuildMember } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes"; -import { LogType } from "../../../data/LogType"; import { logger } from "../../../logger"; import { canActOn, sendErrorMessage } from "../../../pluginUtils"; import { resolveMember, resolveRoleId, successMessage } from "../../../utils"; import { LogsPlugin } from "../../Logs/LogsPlugin"; +import { RoleManagerPlugin } from "../../RoleManager/RoleManagerPlugin"; import { rolesCmd } from "../types"; export const MassAddRoleCmd = rolesCmd({ @@ -72,8 +72,7 @@ export const MassAddRoleCmd = rolesCmd({ for (const member of membersWithoutTheRole) { try { - pluginData.state.logs.ignoreLog(LogType.MEMBER_ROLE_ADD, member.id); - await member.roles.add(roleId); + pluginData.getPlugin(RoleManagerPlugin).addRole(member.id, roleId); pluginData.getPlugin(LogsPlugin).logMemberRoleAdd({ member, roles: [role], diff --git a/backend/src/plugins/Roles/commands/MassRemoveRoleCmd.ts b/backend/src/plugins/Roles/commands/MassRemoveRoleCmd.ts index 2bc4cfe3..de4591b4 100644 --- a/backend/src/plugins/Roles/commands/MassRemoveRoleCmd.ts +++ b/backend/src/plugins/Roles/commands/MassRemoveRoleCmd.ts @@ -1,10 +1,9 @@ import { GuildMember } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes"; -import { LogType } from "../../../data/LogType"; -import { logger } from "../../../logger"; import { canActOn, sendErrorMessage } from "../../../pluginUtils"; import { resolveMember, resolveRoleId, successMessage } from "../../../utils"; import { LogsPlugin } from "../../Logs/LogsPlugin"; +import { RoleManagerPlugin } from "../../RoleManager/RoleManagerPlugin"; import { rolesCmd } from "../types"; export const MassRemoveRoleCmd = rolesCmd({ @@ -71,19 +70,13 @@ export const MassRemoveRoleCmd = rolesCmd({ ); for (const member of membersWithTheRole) { - try { - pluginData.state.logs.ignoreLog(LogType.MEMBER_ROLE_REMOVE, member.id); - await member.roles.remove(roleId); - pluginData.getPlugin(LogsPlugin).logMemberRoleRemove({ - member, - roles: [role], - mod: msg.author, - }); - assigned++; - } catch (e) { - logger.warn(`Error when removing role via !massremoverole: ${e.message}`); - failed.push(member.id); - } + pluginData.getPlugin(RoleManagerPlugin).removeRole(member.id, roleId); + pluginData.getPlugin(LogsPlugin).logMemberRoleRemove({ + member, + roles: [role], + mod: msg.author, + }); + assigned++; } let resultMessage = `Removed role **${role.name}** from ${assigned} ${assigned === 1 ? "member" : "members"}!`; diff --git a/backend/src/plugins/Roles/commands/RemoveRoleCmd.ts b/backend/src/plugins/Roles/commands/RemoveRoleCmd.ts index 589b9980..8cd6f306 100644 --- a/backend/src/plugins/Roles/commands/RemoveRoleCmd.ts +++ b/backend/src/plugins/Roles/commands/RemoveRoleCmd.ts @@ -1,9 +1,9 @@ import { GuildChannel } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes"; -import { LogType } from "../../../data/LogType"; import { canActOn, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils"; import { resolveRoleId, verboseUserMention } from "../../../utils"; import { LogsPlugin } from "../../Logs/LogsPlugin"; +import { RoleManagerPlugin } from "../../RoleManager/RoleManagerPlugin"; import { rolesCmd } from "../types"; export const RemoveRoleCmd = rolesCmd({ @@ -49,10 +49,7 @@ export const RemoveRoleCmd = rolesCmd({ return; } - pluginData.state.logs.ignoreLog(LogType.MEMBER_ROLE_REMOVE, args.member.id); - - await args.member.roles.remove(roleId); - + pluginData.getPlugin(RoleManagerPlugin).removeRole(args.member.id, roleId); pluginData.getPlugin(LogsPlugin).logMemberRoleRemove({ mod: msg.author, member: args.member, diff --git a/backend/src/plugins/availablePlugins.ts b/backend/src/plugins/availablePlugins.ts index ab8bfc1e..58af41fc 100644 --- a/backend/src/plugins/availablePlugins.ts +++ b/backend/src/plugins/availablePlugins.ts @@ -1,6 +1,6 @@ import { AutoDeletePlugin } from "./AutoDelete/AutoDeletePlugin"; -import { AutomodPlugin } from "./Automod/AutomodPlugin"; import { AutoReactionsPlugin } from "./AutoReactions/AutoReactionsPlugin"; +import { AutomodPlugin } from "./Automod/AutomodPlugin"; import { BotControlPlugin } from "./BotControl/BotControlPlugin"; import { CasesPlugin } from "./Cases/CasesPlugin"; import { CensorPlugin } from "./Censor/CensorPlugin"; @@ -12,6 +12,7 @@ import { CustomEventsPlugin } from "./CustomEvents/CustomEventsPlugin"; import { GuildAccessMonitorPlugin } from "./GuildAccessMonitor/GuildAccessMonitorPlugin"; import { GuildConfigReloaderPlugin } from "./GuildConfigReloader/GuildConfigReloaderPlugin"; import { GuildInfoSaverPlugin } from "./GuildInfoSaver/GuildInfoSaverPlugin"; +import { GuildMemberCachePlugin } from "./GuildMemberCache/GuildMemberCachePlugin"; import { InternalPosterPlugin } from "./InternalPoster/InternalPosterPlugin"; import { LocateUserPlugin } from "./LocateUser/LocateUserPlugin"; import { LogsPlugin } from "./Logs/LogsPlugin"; @@ -53,6 +54,7 @@ export const guildPlugins: Array> = [ PostPlugin, ReactionRolesPlugin, MessageSaverPlugin, + GuildMemberCachePlugin, ModActionsPlugin, NameHistoryPlugin, RemindersPlugin, @@ -91,6 +93,7 @@ export const baseGuildPlugins: Array> = [ GuildInfoSaverPlugin, MessageSaverPlugin, NameHistoryPlugin, + GuildMemberCachePlugin, CasesPlugin, MutesPlugin, TimeAndDatePlugin, diff --git a/backend/src/threadsSignalFix.ts b/backend/src/threadsSignalFix.ts new file mode 100644 index 00000000..655d960b --- /dev/null +++ b/backend/src/threadsSignalFix.ts @@ -0,0 +1,10 @@ +/** + * Hack for wiping out the threads signal handlers + * See: https://github.com/andywer/threads.js/issues/388 + * Make sure: + * - This is imported before any real imports from "threads" + * - This is imported as early as possible to avoid removing our own signal handlers + */ +import "threads"; +process.removeAllListeners("SIGINT"); +process.removeAllListeners("SIGTERM"); diff --git a/backend/src/utils.ts b/backend/src/utils.ts index e075f5e4..a79ef928 100644 --- a/backend/src/utils.ts +++ b/backend/src/utils.ts @@ -20,6 +20,7 @@ import { MessageMentionOptions, PartialChannelData, PartialMessage, + RoleResolvable, Snowflake, Sticker, TextBasedChannel, @@ -680,7 +681,7 @@ export function parseInviteCodeInput(str: string): string { return getInviteCodesInString(str)[0]; } -export function isNotNull(value): value is Exclude { +export function isNotNull(value: T): value is Exclude { return value != null; } @@ -1367,6 +1368,22 @@ export async function resolveRoleId(bot: Client, guildId: string, value: string) return null; } +export class UnknownRole { + public id: string; + public name: string; + + constructor(props = {}) { + for (const key in props) { + this[key] = props[key]; + } + } +} + +export function resolveRole(guild: Guild, roleResolvable: RoleResolvable) { + const roleId = guild.roles.resolveId(roleResolvable); + return guild.roles.resolve(roleId) ?? new UnknownRole({ id: roleId, name: roleId }); +} + const inviteCache = new SimpleCache>(10 * MINUTES, 200); type ResolveInviteReturnType = Promise; diff --git a/backend/src/utils/crypt.ts b/backend/src/utils/crypt.ts index 026892ec..5d18e028 100644 --- a/backend/src/utils/crypt.ts +++ b/backend/src/utils/crypt.ts @@ -1,5 +1,6 @@ import { Pool, spawn, Worker } from "threads"; import { env } from "../env"; +import "../threadsSignalFix"; import { MINUTES } from "../utils"; const pool = Pool(() => spawn(new Worker("./cryptWorker"), { timeout: 10 * MINUTES }), 8);