diff --git a/backend/src/data/GuildEvents.ts b/backend/src/data/GuildEvents.ts index 72c6177c..24f14858 100644 --- a/backend/src/data/GuildEvents.ts +++ b/backend/src/data/GuildEvents.ts @@ -1,11 +1,15 @@ import { Mute } from "./entities/Mute"; import { ScheduledPost } from "./entities/ScheduledPost"; import { Reminder } from "./entities/Reminder"; +import { Tempban } from "./entities/Tempban"; +import { VCAlert } from "./entities/VCAlert"; interface GuildEventArgs extends Record { - expiredMutes: [Mute[]]; - scheduledPosts: [ScheduledPost[]]; - reminders: [Reminder[]]; + expiredMute: [Mute]; + scheduledPost: [ScheduledPost]; + reminder: [Reminder]; + expiredTempban: [Tempban]; + expiredVCAlert: [VCAlert]; } type GuildEvent = keyof GuildEventArgs; @@ -52,3 +56,14 @@ export function emitGuildEvent(guildId: string, eventName: listener(...args); } } + +export function hasGuildEventListener(guildId: string, eventName: K): boolean { + if (!guildListeners.has(guildId)) { + return false; + } + const listenerMap = guildListeners.get(guildId)!; + if (listenerMap[eventName] == null || listenerMap[eventName]!.length === 0) { + return false; + } + return true; +} diff --git a/backend/src/data/GuildMutes.ts b/backend/src/data/GuildMutes.ts index f95571a0..ac857a57 100644 --- a/backend/src/data/GuildMutes.ts +++ b/backend/src/data/GuildMutes.ts @@ -47,11 +47,11 @@ export class GuildMutes extends BaseGuildRepository { return (await this.mutes.findOne({ where: result.identifiers[0] }))!; } - async updateExpiryTime(userId, newExpiryTime, rolesToRestore?: string[]) { + async updateExpiryTime(userId, newExpiryTime, rolesToRestore?: string[]): Promise { const expiresAt = newExpiryTime ? moment.utc().add(newExpiryTime, "ms").format("YYYY-MM-DD HH:mm:ss") : null; if (rolesToRestore && rolesToRestore.length) { - return this.mutes.update( + await this.mutes.update( { guild_id: this.guildId, user_id: userId, @@ -62,7 +62,7 @@ export class GuildMutes extends BaseGuildRepository { }, ); } else { - return this.mutes.update( + await this.mutes.update( { guild_id: this.guildId, user_id: userId, diff --git a/backend/src/data/GuildReminders.ts b/backend/src/data/GuildReminders.ts index b665569f..fac42c0f 100644 --- a/backend/src/data/GuildReminders.ts +++ b/backend/src/data/GuildReminders.ts @@ -27,6 +27,10 @@ export class GuildReminders extends BaseGuildRepository { }); } + find(id: number) { + return this.reminders.findOne({ id }); + } + async delete(id) { await this.reminders.delete({ guild_id: this.guildId, @@ -35,7 +39,7 @@ export class GuildReminders extends BaseGuildRepository { } async add(userId: string, channelId: string, remindAt: string, body: string, created_at: string) { - await this.reminders.insert({ + const result = await this.reminders.insert({ guild_id: this.guildId, user_id: userId, channel_id: channelId, @@ -43,5 +47,7 @@ export class GuildReminders extends BaseGuildRepository { body, created_at, }); + + return (await this.find(result.identifiers[0].id))!; } } diff --git a/backend/src/data/GuildScheduledPosts.ts b/backend/src/data/GuildScheduledPosts.ts index b2f32d54..3f10b712 100644 --- a/backend/src/data/GuildScheduledPosts.ts +++ b/backend/src/data/GuildScheduledPosts.ts @@ -22,6 +22,10 @@ export class GuildScheduledPosts extends BaseGuildRepository { .getMany(); } + find(id: number) { + return this.scheduledPosts.findOne({ id }); + } + async delete(id) { await this.scheduledPosts.delete({ guild_id: this.guildId, @@ -30,10 +34,12 @@ export class GuildScheduledPosts extends BaseGuildRepository { } async create(data: Partial) { - await this.scheduledPosts.insert({ + const result = await this.scheduledPosts.insert({ ...data, guild_id: this.guildId, }); + + return (await this.find(result.identifiers[0].id))!; } async update(id: number, data: Partial) { diff --git a/backend/src/data/GuildVCAlerts.ts b/backend/src/data/GuildVCAlerts.ts index 4736204a..210a91c9 100644 --- a/backend/src/data/GuildVCAlerts.ts +++ b/backend/src/data/GuildVCAlerts.ts @@ -40,6 +40,10 @@ export class GuildVCAlerts extends BaseGuildRepository { }); } + find(id: number) { + return this.allAlerts.findOne({ id }); + } + async delete(id) { await this.allAlerts.delete({ guild_id: this.guildId, @@ -48,7 +52,7 @@ export class GuildVCAlerts extends BaseGuildRepository { } async add(requestorId: string, userId: string, channelId: string, expiresAt: string, body: string, active: boolean) { - await this.allAlerts.insert({ + const result = await this.allAlerts.insert({ guild_id: this.guildId, requestor_id: requestorId, user_id: userId, @@ -57,5 +61,7 @@ export class GuildVCAlerts extends BaseGuildRepository { body, active, }); + + return (await this.find(result.identifiers[0].id))!; } } diff --git a/backend/src/data/Mutes.ts b/backend/src/data/Mutes.ts new file mode 100644 index 00000000..06674717 --- /dev/null +++ b/backend/src/data/Mutes.ts @@ -0,0 +1,23 @@ +import moment from "moment-timezone"; +import { Brackets, getRepository, Repository } from "typeorm"; +import { Mute } from "./entities/Mute"; +import { DBDateFormat } from "../utils"; +import { BaseRepository } from "./BaseRepository"; + +export class Mutes extends BaseRepository { + private mutes: Repository; + + constructor() { + super(); + this.mutes = getRepository(Mute); + } + + async getSoonExpiringMutes(threshold: number): Promise { + const thresholdDateStr = moment.utc().add(threshold, "ms").format(DBDateFormat); + return this.mutes + .createQueryBuilder("mutes") + .andWhere("expires_at IS NOT NULL") + .andWhere("expires_at <= :date", { date: thresholdDateStr }) + .getMany(); + } +} diff --git a/backend/src/data/Reminders.ts b/backend/src/data/Reminders.ts new file mode 100644 index 00000000..7f8d2f1a --- /dev/null +++ b/backend/src/data/Reminders.ts @@ -0,0 +1,19 @@ +import { getRepository, Repository } from "typeorm"; +import { Reminder } from "./entities/Reminder"; +import { BaseRepository } from "./BaseRepository"; +import moment from "moment-timezone"; +import { DBDateFormat } from "../utils"; + +export class Reminders extends BaseRepository { + private reminders: Repository; + + constructor() { + super(); + this.reminders = getRepository(Reminder); + } + + async getRemindersDueSoon(threshold: number): Promise { + const thresholdDateStr = moment.utc().add(threshold, "ms").format(DBDateFormat); + return this.reminders.createQueryBuilder().andWhere("remind_at <= :date", { date: thresholdDateStr }).getMany(); + } +} diff --git a/backend/src/data/ScheduledPosts.ts b/backend/src/data/ScheduledPosts.ts new file mode 100644 index 00000000..72f1dda4 --- /dev/null +++ b/backend/src/data/ScheduledPosts.ts @@ -0,0 +1,19 @@ +import { getRepository, Repository } from "typeorm"; +import { ScheduledPost } from "./entities/ScheduledPost"; +import { BaseRepository } from "./BaseRepository"; +import moment from "moment-timezone"; +import { DBDateFormat } from "../utils"; + +export class ScheduledPosts extends BaseRepository { + private scheduledPosts: Repository; + + constructor() { + super(); + this.scheduledPosts = getRepository(ScheduledPost); + } + + getScheduledPostsDueSoon(threshold: number): Promise { + const thresholdDateStr = moment.utc().add(threshold, "ms").format(DBDateFormat); + return this.scheduledPosts.createQueryBuilder().andWhere("post_at <= :date", { date: thresholdDateStr }).getMany(); + } +} diff --git a/backend/src/data/Tempbans.ts b/backend/src/data/Tempbans.ts new file mode 100644 index 00000000..5a78c30e --- /dev/null +++ b/backend/src/data/Tempbans.ts @@ -0,0 +1,19 @@ +import moment from "moment-timezone"; +import { getRepository, Repository } from "typeorm"; +import { Tempban } from "./entities/Tempban"; +import { BaseRepository } from "./BaseRepository"; +import { DBDateFormat } from "../utils"; + +export class Tempbans extends BaseRepository { + private tempbans: Repository; + + constructor() { + super(); + this.tempbans = getRepository(Tempban); + } + + getSoonExpiringTempbans(threshold: number): Promise { + const thresholdDateStr = moment.utc().add(threshold, "ms").format(DBDateFormat); + return this.tempbans.createQueryBuilder().where("expires_at <= :date", { date: thresholdDateStr }).getMany(); + } +} diff --git a/backend/src/data/VCAlerts.ts b/backend/src/data/VCAlerts.ts new file mode 100644 index 00000000..27e85f90 --- /dev/null +++ b/backend/src/data/VCAlerts.ts @@ -0,0 +1,19 @@ +import { getRepository, Repository } from "typeorm"; +import { VCAlert } from "./entities/VCAlert"; +import { BaseRepository } from "./BaseRepository"; +import moment from "moment-timezone"; +import { DBDateFormat } from "../utils"; + +export class VCAlerts extends BaseRepository { + private allAlerts: Repository; + + constructor() { + super(); + this.allAlerts = getRepository(VCAlert); + } + + async getSoonExpiringAlerts(threshold: number): Promise { + const thresholdDateStr = moment.utc().add(threshold, "ms").format(DBDateFormat); + return this.allAlerts.createQueryBuilder().andWhere("expires_at <= :date", { date: thresholdDateStr }).getMany(); + } +} diff --git a/backend/src/data/entities/Reminder.ts b/backend/src/data/entities/Reminder.ts index 344b3090..e7ce506b 100644 --- a/backend/src/data/entities/Reminder.ts +++ b/backend/src/data/entities/Reminder.ts @@ -1,9 +1,8 @@ -import { Column, Entity, PrimaryColumn } from "typeorm"; +import { Column, Entity, PrimaryColumn, PrimaryGeneratedColumn } from "typeorm"; @Entity("reminders") export class Reminder { - @Column() - @PrimaryColumn() + @PrimaryGeneratedColumn() id: number; @Column() guild_id: string; diff --git a/backend/src/data/entities/ScheduledPost.ts b/backend/src/data/entities/ScheduledPost.ts index 8b7d4088..1729bca7 100644 --- a/backend/src/data/entities/ScheduledPost.ts +++ b/backend/src/data/entities/ScheduledPost.ts @@ -1,11 +1,10 @@ import { MessageAttachment } from "discord.js"; -import { Column, Entity, PrimaryColumn } from "typeorm"; +import { Column, Entity, PrimaryColumn, PrimaryGeneratedColumn } from "typeorm"; import { StrictMessageContent } from "../../utils"; @Entity("scheduled_posts") export class ScheduledPost { - @Column() - @PrimaryColumn() + @PrimaryGeneratedColumn() id: number; @Column() guild_id: string; diff --git a/backend/src/data/entities/VCAlert.ts b/backend/src/data/entities/VCAlert.ts index 81b6bc81..5f0011d7 100644 --- a/backend/src/data/entities/VCAlert.ts +++ b/backend/src/data/entities/VCAlert.ts @@ -1,9 +1,8 @@ -import { Column, Entity, PrimaryColumn } from "typeorm"; +import { Column, Entity, PrimaryColumn, PrimaryGeneratedColumn } from "typeorm"; @Entity("vc_alerts") export class VCAlert { - @Column() - @PrimaryColumn() + @PrimaryGeneratedColumn() id: number; @Column() guild_id: string; diff --git a/backend/src/data/loops/expiringMutesLoop.ts b/backend/src/data/loops/expiringMutesLoop.ts new file mode 100644 index 00000000..a3b5b7e8 --- /dev/null +++ b/backend/src/data/loops/expiringMutesLoop.ts @@ -0,0 +1,72 @@ +import { lazyMemoize, memoize, MINUTES } from "../../utils"; +import { Mutes } from "../Mutes"; +import Timeout = NodeJS.Timeout; +import moment from "moment-timezone"; +import { Mute } from "../entities/Mute"; +import { emitGuildEvent, hasGuildEventListener } from "../GuildEvents"; + +const LOOP_INTERVAL = 15 * MINUTES; +const MAX_TRIES_PER_SERVER = 3; +const getMutesRepository = lazyMemoize(() => new Mutes()); +const timeouts = new Map(); + +function muteToKey(mute: Mute) { + return `${mute.guild_id}/${mute.user_id}`; +} + +function broadcastExpiredMute(mute: Mute, tries = 0) { + console.log(`[EXPIRING MUTES LOOP] Broadcasting expired mute: ${mute.guild_id}/${mute.user_id}`); + if (!hasGuildEventListener(mute.guild_id, "expiredMute")) { + // If there are no listeners registered for the server yet, try again in a bit + if (tries < MAX_TRIES_PER_SERVER) { + timeouts.set( + muteToKey(mute), + setTimeout(() => broadcastExpiredMute(mute, tries + 1), 1 * MINUTES), + ); + } + return; + } + emitGuildEvent(mute.guild_id, "expiredMute", [mute]); +} + +export async function runExpiringMutesLoop() { + console.log("[EXPIRING MUTES LOOP] Clearing old timeouts"); + for (const timeout of timeouts.values()) { + clearTimeout(timeout); + } + + console.log("[EXPIRING MUTES LOOP] Setting timeouts for expiring mutes"); + const expiringMutes = await getMutesRepository().getSoonExpiringMutes(LOOP_INTERVAL); + for (const mute of expiringMutes) { + const remaining = Math.max(0, moment.utc(mute.expires_at!).diff(moment.utc())); + timeouts.set( + muteToKey(mute), + setTimeout(() => broadcastExpiredMute(mute), remaining), + ); + } + + console.log("[EXPIRING MUTES LOOP] Scheduling next loop"); + setTimeout(() => runExpiringMutesLoop(), LOOP_INTERVAL); +} + +export function registerExpiringMute(mute: Mute) { + clearExpiringMute(mute); + + console.log("[EXPIRING MUTES LOOP] Registering new expiring mute"); + const remaining = Math.max(0, moment.utc(mute.expires_at!).diff(moment.utc())); + if (remaining > LOOP_INTERVAL) { + return; + } + + timeouts.set( + muteToKey(mute), + setTimeout(() => broadcastExpiredMute(mute), remaining), + ); +} + +export function clearExpiringMute(mute: Mute) { + console.log("[EXPIRING MUTES LOOP] Clearing expiring mute"); + if (timeouts.has(muteToKey(mute))) { + clearTimeout(timeouts.get(muteToKey(mute))!); + } +} diff --git a/backend/src/data/loops/expiringTempbansLoop.ts b/backend/src/data/loops/expiringTempbansLoop.ts new file mode 100644 index 00000000..a28f91e6 --- /dev/null +++ b/backend/src/data/loops/expiringTempbansLoop.ts @@ -0,0 +1,72 @@ +import { lazyMemoize, MINUTES } from "../../utils"; +import moment from "moment-timezone"; +import { emitGuildEvent, hasGuildEventListener } from "../GuildEvents"; +import { Tempbans } from "../Tempbans"; +import { Tempban } from "../entities/Tempban"; +import Timeout = NodeJS.Timeout; + +const LOOP_INTERVAL = 15 * MINUTES; +const MAX_TRIES_PER_SERVER = 3; +const getBansRepository = lazyMemoize(() => new Tempbans()); +const timeouts = new Map(); + +function tempbanToKey(tempban: Tempban) { + return `${tempban.guild_id}/${tempban.user_id}`; +} + +function broadcastExpiredTempban(tempban: Tempban, tries = 0) { + console.log(`[EXPIRING TEMPBANS LOOP] Broadcasting expired tempban: ${tempban.guild_id}/${tempban.user_id}`); + if (!hasGuildEventListener(tempban.guild_id, "expiredTempban")) { + // If there are no listeners registered for the server yet, try again in a bit + if (tries < MAX_TRIES_PER_SERVER) { + timeouts.set( + tempbanToKey(tempban), + setTimeout(() => broadcastExpiredTempban(tempban, tries + 1), 1 * MINUTES), + ); + } + return; + } + emitGuildEvent(tempban.guild_id, "expiredTempban", [tempban]); +} + +export async function runExpiringTempbansLoop() { + console.log("[EXPIRING TEMPBANS LOOP] Clearing old timeouts"); + for (const timeout of timeouts.values()) { + clearTimeout(timeout); + } + + console.log("[EXPIRING TEMPBANS LOOP] Setting timeouts for expiring tempbans"); + const expiringTempbans = await getBansRepository().getSoonExpiringTempbans(LOOP_INTERVAL); + for (const tempban of expiringTempbans) { + const remaining = Math.max(0, moment.utc(tempban.expires_at!).diff(moment.utc())); + timeouts.set( + tempbanToKey(tempban), + setTimeout(() => broadcastExpiredTempban(tempban), remaining), + ); + } + + console.log("[EXPIRING TEMPBANS LOOP] Scheduling next loop"); + setTimeout(() => runExpiringTempbansLoop(), LOOP_INTERVAL); +} + +export function registerExpiringTempban(tempban: Tempban) { + clearExpiringTempban(tempban); + + console.log("[EXPIRING TEMPBANS LOOP] Registering new expiring tempban"); + const remaining = Math.max(0, moment.utc(tempban.expires_at!).diff(moment.utc())); + if (remaining > LOOP_INTERVAL) { + return; + } + + timeouts.set( + tempbanToKey(tempban), + setTimeout(() => broadcastExpiredTempban(tempban), remaining), + ); +} + +export function clearExpiringTempban(tempban: Tempban) { + console.log("[EXPIRING TEMPBANS LOOP] Clearing expiring tempban"); + if (timeouts.has(tempbanToKey(tempban))) { + clearTimeout(timeouts.get(tempbanToKey(tempban))!); + } +} diff --git a/backend/src/data/loops/expiringVCAlertsLoop.ts b/backend/src/data/loops/expiringVCAlertsLoop.ts new file mode 100644 index 00000000..b987d71a --- /dev/null +++ b/backend/src/data/loops/expiringVCAlertsLoop.ts @@ -0,0 +1,68 @@ +import { lazyMemoize, MINUTES } from "../../utils"; +import moment from "moment-timezone"; +import { emitGuildEvent, hasGuildEventListener } from "../GuildEvents"; +import Timeout = NodeJS.Timeout; +import { VCAlerts } from "../VCAlerts"; +import { VCAlert } from "../entities/VCAlert"; + +const LOOP_INTERVAL = 15 * MINUTES; +const MAX_TRIES_PER_SERVER = 3; +const getVCAlertsRepository = lazyMemoize(() => new VCAlerts()); +const timeouts = new Map(); + +function broadcastExpiredVCAlert(alert: VCAlert, tries = 0) { + console.log(`[EXPIRING VCALERTS LOOP] Broadcasting expired vcalert: ${alert.guild_id}/${alert.user_id}`); + if (!hasGuildEventListener(alert.guild_id, "expiredVCAlert")) { + // If there are no listeners registered for the server yet, try again in a bit + if (tries < MAX_TRIES_PER_SERVER) { + timeouts.set( + alert.id, + setTimeout(() => broadcastExpiredVCAlert(alert, tries + 1), 1 * MINUTES), + ); + } + return; + } + emitGuildEvent(alert.guild_id, "expiredVCAlert", [alert]); +} + +export async function runExpiringVCAlertsLoop() { + console.log("[EXPIRING VCALERTS LOOP] Clearing old timeouts"); + for (const timeout of timeouts.values()) { + clearTimeout(timeout); + } + + console.log("[EXPIRING VCALERTS LOOP] Setting timeouts for expiring vcalerts"); + const expiringVCAlerts = await getVCAlertsRepository().getSoonExpiringAlerts(LOOP_INTERVAL); + for (const alert of expiringVCAlerts) { + const remaining = Math.max(0, moment.utc(alert.expires_at!).diff(moment.utc())); + timeouts.set( + alert.id, + setTimeout(() => broadcastExpiredVCAlert(alert), remaining), + ); + } + + console.log("[EXPIRING VCALERTS LOOP] Scheduling next loop"); + setTimeout(() => runExpiringVCAlertsLoop(), LOOP_INTERVAL); +} + +export function registerExpiringVCAlert(alert: VCAlert) { + clearExpiringVCAlert(alert); + + console.log("[EXPIRING VCALERTS LOOP] Registering new expiring vcalert"); + const remaining = Math.max(0, moment.utc(alert.expires_at!).diff(moment.utc())); + if (remaining > LOOP_INTERVAL) { + return; + } + + timeouts.set( + alert.id, + setTimeout(() => broadcastExpiredVCAlert(alert), remaining), + ); +} + +export function clearExpiringVCAlert(alert: VCAlert) { + console.log("[EXPIRING VCALERTS LOOP] Clearing expiring vcalert"); + if (timeouts.has(alert.id)) { + clearTimeout(timeouts.get(alert.id)!); + } +} diff --git a/backend/src/data/loops/upcomingRemindersLoop.ts b/backend/src/data/loops/upcomingRemindersLoop.ts new file mode 100644 index 00000000..1f34e99f --- /dev/null +++ b/backend/src/data/loops/upcomingRemindersLoop.ts @@ -0,0 +1,67 @@ +import { lazyMemoize, MINUTES } from "../../utils"; +import moment from "moment-timezone"; +import { emitGuildEvent, hasGuildEventListener } from "../GuildEvents"; +import { Reminder } from "../entities/Reminder"; +import { Reminders } from "../Reminders"; +import Timeout = NodeJS.Timeout; + +const LOOP_INTERVAL = 15 * MINUTES; +const MAX_TRIES_PER_SERVER = 3; +const getRemindersRepository = lazyMemoize(() => new Reminders()); +const timeouts = new Map(); + +function broadcastReminder(reminder: Reminder, tries = 0) { + if (!hasGuildEventListener(reminder.guild_id, "reminder")) { + // If there are no listeners registered for the server yet, try again in a bit + if (tries < MAX_TRIES_PER_SERVER) { + timeouts.set( + reminder.id, + setTimeout(() => broadcastReminder(reminder, tries + 1), 1 * MINUTES), + ); + } + return; + } + emitGuildEvent(reminder.guild_id, "reminder", [reminder]); +} + +export async function runUpcomingRemindersLoop() { + console.log("[REMINDERS LOOP] Clearing old timeouts"); + for (const timeout of timeouts.values()) { + clearTimeout(timeout); + } + + console.log("[REMINDERS LOOP] Setting timeouts for upcoming reminders"); + const remindersDueSoon = await getRemindersRepository().getRemindersDueSoon(LOOP_INTERVAL); + for (const reminder of remindersDueSoon) { + const remaining = Math.max(0, moment.utc(reminder.remind_at!).diff(moment.utc())); + timeouts.set( + reminder.id, + setTimeout(() => broadcastReminder(reminder), remaining), + ); + } + + console.log("[REMINDERS LOOP] Scheduling next loop"); + setTimeout(() => runUpcomingRemindersLoop(), LOOP_INTERVAL); +} + +export function registerUpcomingReminder(reminder: Reminder) { + clearUpcomingReminder(reminder); + + console.log("[REMINDERS LOOP] Registering new upcoming reminder"); + const remaining = Math.max(0, moment.utc(reminder.remind_at!).diff(moment.utc())); + if (remaining > LOOP_INTERVAL) { + return; + } + + timeouts.set( + reminder.id, + setTimeout(() => broadcastReminder(reminder), remaining), + ); +} + +export function clearUpcomingReminder(reminder: Reminder) { + console.log("[REMINDERS LOOP] Clearing upcoming reminder"); + if (timeouts.has(reminder.id)) { + clearTimeout(timeouts.get(reminder.id)!); + } +} diff --git a/backend/src/data/loops/upcomingScheduledPostsLoop.ts b/backend/src/data/loops/upcomingScheduledPostsLoop.ts new file mode 100644 index 00000000..51da1bae --- /dev/null +++ b/backend/src/data/loops/upcomingScheduledPostsLoop.ts @@ -0,0 +1,67 @@ +import { lazyMemoize, MINUTES } from "../../utils"; +import moment from "moment-timezone"; +import { emitGuildEvent, hasGuildEventListener } from "../GuildEvents"; +import { ScheduledPosts } from "../ScheduledPosts"; +import { ScheduledPost } from "../entities/ScheduledPost"; +import Timeout = NodeJS.Timeout; + +const LOOP_INTERVAL = 15 * MINUTES; +const MAX_TRIES_PER_SERVER = 3; +const getScheduledPostsRepository = lazyMemoize(() => new ScheduledPosts()); +const timeouts = new Map(); + +function broadcastScheduledPost(post: ScheduledPost, tries = 0) { + if (!hasGuildEventListener(post.guild_id, "scheduledPost")) { + // If there are no listeners registered for the server yet, try again in a bit + if (tries < MAX_TRIES_PER_SERVER) { + timeouts.set( + post.id, + setTimeout(() => broadcastScheduledPost(post, tries + 1), 1 * MINUTES), + ); + } + return; + } + emitGuildEvent(post.guild_id, "scheduledPost", [post]); +} + +export async function runUpcomingScheduledPostsLoop() { + console.log("[SCHEDULED POSTS LOOP] Clearing old timeouts"); + for (const timeout of timeouts.values()) { + clearTimeout(timeout); + } + + console.log("[SCHEDULED POSTS LOOP] Setting timeouts for upcoming scheduled posts"); + const postsDueSoon = await getScheduledPostsRepository().getScheduledPostsDueSoon(LOOP_INTERVAL); + for (const post of postsDueSoon) { + const remaining = Math.max(0, moment.utc(post.post_at!).diff(moment.utc())); + timeouts.set( + post.id, + setTimeout(() => broadcastScheduledPost(post), remaining), + ); + } + + console.log("[SCHEDULED POSTS LOOP] Scheduling next loop"); + setTimeout(() => runUpcomingScheduledPostsLoop(), LOOP_INTERVAL); +} + +export function registerUpcomingScheduledPost(post: ScheduledPost) { + clearUpcomingScheduledPost(post); + + console.log("[SCHEDULED POSTS LOOP] Registering new upcoming scheduled post"); + const remaining = Math.max(0, moment.utc(post.post_at!).diff(moment.utc())); + if (remaining > LOOP_INTERVAL) { + return; + } + + timeouts.set( + post.id, + setTimeout(() => broadcastScheduledPost(post), remaining), + ); +} + +export function clearUpcomingScheduledPost(post: ScheduledPost) { + console.log("[SCHEDULED POSTS LOOP] Clearing upcoming scheduled post"); + if (timeouts.has(post.id)) { + clearTimeout(timeouts.get(post.id)!); + } +} diff --git a/backend/src/index.ts b/backend/src/index.ts index 6b552733..76ec4c1c 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -23,6 +23,11 @@ import { DecayingCounter } from "./utils/DecayingCounter"; import { PluginNotLoadedError } from "knub/dist/plugins/PluginNotLoadedError"; import { logRestCall } from "./restCallStats"; import { logRateLimit } from "./rateLimitStats"; +import { runExpiringMutesLoop } from "./data/loops/expiringMutesLoop"; +import { runUpcomingRemindersLoop } from "./data/loops/upcomingRemindersLoop"; +import { runUpcomingScheduledPostsLoop } from "./data/loops/upcomingScheduledPostsLoop"; +import { runExpiringTempbansLoop } from "./data/loops/expiringTempbansLoop"; +import { runExpiringVCAlertsLoop } from "./data/loops/expiringVCAlertsLoop"; if (!process.env.KEY) { // tslint:disable-next-line:no-console @@ -322,6 +327,14 @@ connect().then(async () => { logRateLimit(data); }); + bot.on("loadingFinished", () => { + runExpiringMutesLoop(); + runExpiringTempbansLoop(); + runExpiringVCAlertsLoop(); + runUpcomingRemindersLoop(); + runUpcomingScheduledPostsLoop(); + }); + bot.initialize(); logger.info("Bot Initialized"); logger.info("Logging in..."); diff --git a/backend/src/migrations/1632582078622-SplitScheduledPostsPostAtIndex.ts b/backend/src/migrations/1632582078622-SplitScheduledPostsPostAtIndex.ts new file mode 100644 index 00000000..16bbabb2 --- /dev/null +++ b/backend/src/migrations/1632582078622-SplitScheduledPostsPostAtIndex.ts @@ -0,0 +1,30 @@ +import { MigrationInterface, QueryRunner, TableIndex } from "typeorm"; + +export class SplitScheduledPostsPostAtIndex1632582078622 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.dropIndex("scheduled_posts", "IDX_c383ecfbddd8b625a0912ded3e"); + await queryRunner.createIndex( + "scheduled_posts", + new TableIndex({ + columnNames: ["guild_id"], + }), + ); + await queryRunner.createIndex( + "scheduled_posts", + new TableIndex({ + columnNames: ["post_at"], + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropIndex("scheduled_posts", "IDX_e3ce9a618354f29256712abc5c"); + await queryRunner.dropIndex("scheduled_posts", "IDX_b30f532b58ec5caf116389486f"); + await queryRunner.createIndex( + "scheduled_posts", + new TableIndex({ + columnNames: ["guild_id", "post_at"], + }), + ); + } +} diff --git a/backend/src/migrations/1632582299400-AddIndexToRemindersRemindAt.ts b/backend/src/migrations/1632582299400-AddIndexToRemindersRemindAt.ts new file mode 100644 index 00000000..31f52869 --- /dev/null +++ b/backend/src/migrations/1632582299400-AddIndexToRemindersRemindAt.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner, TableIndex } from "typeorm"; + +export class AddIndexToRemindersRemindAt1632582299400 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createIndex( + "reminders", + new TableIndex({ + columnNames: ["remind_at"], + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropIndex("reminders", "IDX_6f4e1a9db3410c43c7545ff060"); + } +} diff --git a/backend/src/plugins/LocateUser/LocateUserPlugin.ts b/backend/src/plugins/LocateUser/LocateUserPlugin.ts index fc77d99a..506380ff 100644 --- a/backend/src/plugins/LocateUser/LocateUserPlugin.ts +++ b/backend/src/plugins/LocateUser/LocateUserPlugin.ts @@ -9,8 +9,8 @@ import { GuildBanRemoveAlertsEvt } from "./events/BanRemoveAlertsEvt"; import { VoiceStateUpdateAlertEvt } from "./events/SendAlertsEvts"; import { ConfigSchema, LocateUserPluginType } from "./types"; import { fillActiveAlertsList } from "./utils/fillAlertsList"; -import { outdatedAlertsLoop } from "./utils/outdatedLoop"; -import Timeout = NodeJS.Timeout; +import { onGuildEvent } from "../../data/GuildEvents"; +import { clearExpiredAlert } from "./utils/clearExpiredAlert"; const defaultOptions: PluginOptions = { config: { @@ -61,18 +61,17 @@ export const LocateUserPlugin = zeppelinGuildPlugin()({ const { state, guild } = pluginData; state.alerts = GuildVCAlerts.getGuildInstance(guild.id); - state.outdatedAlertsTimeout = null; state.usersWithAlerts = []; - state.unloaded = false; }, afterLoad(pluginData) { - outdatedAlertsLoop(pluginData); + pluginData.state.unregisterGuildEventListener = onGuildEvent(pluginData.guild.id, "expiredVCAlert", (alert) => + clearExpiredAlert(pluginData, alert), + ); fillActiveAlertsList(pluginData); }, beforeUnload(pluginData) { - clearTimeout(pluginData.state.outdatedAlertsTimeout as Timeout); - pluginData.state.unloaded = true; + pluginData.state.unregisterGuildEventListener(); }, }); diff --git a/backend/src/plugins/LocateUser/commands/FollowCmd.ts b/backend/src/plugins/LocateUser/commands/FollowCmd.ts index 5a8d7e5d..82f84e50 100644 --- a/backend/src/plugins/LocateUser/commands/FollowCmd.ts +++ b/backend/src/plugins/LocateUser/commands/FollowCmd.ts @@ -4,6 +4,7 @@ import { commandTypeHelpers as ct } from "../../../commandTypes"; import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils"; import { MINUTES, SECONDS } from "../../../utils"; import { locateUserCmd } from "../types"; +import { registerExpiringVCAlert } from "../../../data/loops/expiringVCAlertsLoop"; export const FollowCmd = locateUserCmd({ trigger: ["follow", "f"], @@ -30,7 +31,7 @@ export const FollowCmd = locateUserCmd({ return; } - await pluginData.state.alerts.add( + const alert = await pluginData.state.alerts.add( msg.author.id, args.member.id, msg.channel.id, @@ -38,6 +39,8 @@ export const FollowCmd = locateUserCmd({ body, active, ); + registerExpiringVCAlert(alert); + if (!pluginData.state.usersWithAlerts.includes(args.member.id)) { pluginData.state.usersWithAlerts.push(args.member.id); } diff --git a/backend/src/plugins/LocateUser/commands/ListFollowCmd.ts b/backend/src/plugins/LocateUser/commands/ListFollowCmd.ts index f8e669bc..5db75917 100644 --- a/backend/src/plugins/LocateUser/commands/ListFollowCmd.ts +++ b/backend/src/plugins/LocateUser/commands/ListFollowCmd.ts @@ -2,6 +2,7 @@ import { commandTypeHelpers as ct } from "../../../commandTypes"; import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils"; import { createChunkedMessage, sorter } from "../../../utils"; import { locateUserCmd } from "../types"; +import { clearExpiringVCAlert } from "../../../data/loops/expiringVCAlertsLoop"; export const ListFollowCmd = locateUserCmd({ trigger: ["follows", "fs"], @@ -50,6 +51,7 @@ export const DeleteFollowCmd = locateUserCmd({ } const toDelete = alerts[args.num - 1]; + clearExpiringVCAlert(toDelete); await pluginData.state.alerts.delete(toDelete.id); sendSuccessMessage(pluginData, msg.channel, "Alert deleted"); diff --git a/backend/src/plugins/LocateUser/events/BanRemoveAlertsEvt.ts b/backend/src/plugins/LocateUser/events/BanRemoveAlertsEvt.ts index c93ad872..42b71312 100644 --- a/backend/src/plugins/LocateUser/events/BanRemoveAlertsEvt.ts +++ b/backend/src/plugins/LocateUser/events/BanRemoveAlertsEvt.ts @@ -1,4 +1,5 @@ import { locateUserEvt } from "../types"; +import { clearExpiringVCAlert } from "../../../data/loops/expiringVCAlertsLoop"; export const GuildBanRemoveAlertsEvt = locateUserEvt({ event: "guildBanAdd", @@ -6,6 +7,7 @@ export const GuildBanRemoveAlertsEvt = locateUserEvt({ async listener(meta) { const alerts = await meta.pluginData.state.alerts.getAlertsByUserId(meta.args.ban.user.id); alerts.forEach((alert) => { + clearExpiringVCAlert(alert); meta.pluginData.state.alerts.delete(alert.id); }); }, diff --git a/backend/src/plugins/LocateUser/types.ts b/backend/src/plugins/LocateUser/types.ts index 5b4f4752..51f3a34d 100644 --- a/backend/src/plugins/LocateUser/types.ts +++ b/backend/src/plugins/LocateUser/types.ts @@ -13,9 +13,8 @@ export interface LocateUserPluginType extends BasePluginType { config: TConfigSchema; state: { alerts: GuildVCAlerts; - outdatedAlertsTimeout: Timeout | null; usersWithAlerts: string[]; - unloaded: boolean; + unregisterGuildEventListener: () => void; }; } diff --git a/backend/src/plugins/LocateUser/utils/clearExpiredAlert.ts b/backend/src/plugins/LocateUser/utils/clearExpiredAlert.ts new file mode 100644 index 00000000..5ee86836 --- /dev/null +++ b/backend/src/plugins/LocateUser/utils/clearExpiredAlert.ts @@ -0,0 +1,9 @@ +import { GuildPluginData } from "knub"; +import { LocateUserPluginType } from "../types"; +import { removeUserIdFromActiveAlerts } from "./removeUserIdFromActiveAlerts"; +import { VCAlert } from "../../../data/entities/VCAlert"; + +export async function clearExpiredAlert(pluginData: GuildPluginData, alert: VCAlert) { + await pluginData.state.alerts.delete(alert.id); + await removeUserIdFromActiveAlerts(pluginData, alert.user_id); +} diff --git a/backend/src/plugins/LocateUser/utils/outdatedLoop.ts b/backend/src/plugins/LocateUser/utils/outdatedLoop.ts deleted file mode 100644 index 4236342d..00000000 --- a/backend/src/plugins/LocateUser/utils/outdatedLoop.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { GuildPluginData } from "knub"; -import { SECONDS } from "../../../utils"; -import { LocateUserPluginType } from "../types"; -import { removeUserIdFromActiveAlerts } from "./removeUserIdFromActiveAlerts"; - -const ALERT_LOOP_TIME = 30 * SECONDS; - -export async function outdatedAlertsLoop(pluginData: GuildPluginData) { - const outdatedAlerts = await pluginData.state.alerts.getOutdatedAlerts(); - - for (const alert of outdatedAlerts) { - await pluginData.state.alerts.delete(alert.id); - await removeUserIdFromActiveAlerts(pluginData, alert.user_id); - } - - if (!pluginData.state.unloaded) { - pluginData.state.outdatedAlertsTimeout = setTimeout(() => outdatedAlertsLoop(pluginData), ALERT_LOOP_TIME); - } -} diff --git a/backend/src/plugins/Logs/logFunctions/logPostedScheduledMessage.ts b/backend/src/plugins/Logs/logFunctions/logPostedScheduledMessage.ts index ee4ffd1e..52aa83ca 100644 --- a/backend/src/plugins/Logs/logFunctions/logPostedScheduledMessage.ts +++ b/backend/src/plugins/Logs/logFunctions/logPostedScheduledMessage.ts @@ -3,12 +3,12 @@ import { LogsPluginType } from "../types"; import { LogType } from "../../../data/LogType"; import { log } from "../util/log"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter"; -import { BaseGuildTextChannel, User } from "discord.js"; +import { BaseGuildTextChannel, ThreadChannel, User } from "discord.js"; import { channelToTemplateSafeChannel, userToTemplateSafeUser } from "../../../utils/templateSafeObjects"; interface LogPostedScheduledMessageData { author: User; - channel: BaseGuildTextChannel; + channel: BaseGuildTextChannel | ThreadChannel; messageId: string; } diff --git a/backend/src/plugins/ModActions/ModActionsPlugin.ts b/backend/src/plugins/ModActions/ModActionsPlugin.ts index 452cabf6..60dc3bf7 100644 --- a/backend/src/plugins/ModActions/ModActionsPlugin.ts +++ b/backend/src/plugins/ModActions/ModActionsPlugin.ts @@ -34,7 +34,6 @@ import { UnmuteCmd } from "./commands/UnmuteCmd"; import { UpdateCmd } from "./commands/UpdateCmd"; import { WarnCmd } from "./commands/WarnCmd"; import { CreateBanCaseOnManualBanEvt } from "./events/CreateBanCaseOnManualBanEvt"; -import { CreateKickCaseOnManualKickEvt } from "./events/CreateKickCaseOnManualKickEvt"; import { CreateUnbanCaseOnManualUnbanEvt } from "./events/CreateUnbanCaseOnManualUnbanEvt"; import { PostAlertOnMemberJoinEvt } from "./events/PostAlertOnMemberJoinEvt"; import { banUserId } from "./functions/banUserId"; @@ -42,11 +41,12 @@ import { hasMutePermission } from "./functions/hasMutePerm"; import { kickMember } from "./functions/kickMember"; import { offModActionsEvent } from "./functions/offModActionsEvent"; import { onModActionsEvent } from "./functions/onModActionsEvent"; -import { outdatedTempbansLoop } from "./functions/outdatedTempbansLoop"; import { updateCase } from "./functions/updateCase"; import { warnMember } from "./functions/warnMember"; import { BanOptions, ConfigSchema, KickOptions, ModActionsPluginType, WarnOptions } from "./types"; import { LogsPlugin } from "../Logs/LogsPlugin"; +import { onGuildEvent } from "../../data/GuildEvents"; +import { clearTempban } from "./functions/clearTempban"; const defaultOptions = { config: { @@ -200,7 +200,6 @@ export const ModActionsPlugin = zeppelinGuildPlugin()({ state.serverLogs = new GuildLogs(guild.id); state.unloaded = false; - state.outdatedTempbansTimeout = null; state.ignoredEvents = []; // Massbans can take a while depending on rate limits, // so we're giving each massban 15 minutes to complete before launching the next massban @@ -210,11 +209,14 @@ export const ModActionsPlugin = zeppelinGuildPlugin()({ }, afterLoad(pluginData) { - outdatedTempbansLoop(pluginData); + pluginData.state.unregisterGuildEventListener = onGuildEvent(pluginData.guild.id, "expiredTempban", (tempban) => + clearTempban(pluginData, tempban), + ); }, beforeUnload(pluginData) { pluginData.state.unloaded = true; + pluginData.state.unregisterGuildEventListener(); pluginData.state.events.removeAllListeners(); }, }); diff --git a/backend/src/plugins/ModActions/commands/UnbanCmd.ts b/backend/src/plugins/ModActions/commands/UnbanCmd.ts index eb235917..2c9bbfd3 100644 --- a/backend/src/plugins/ModActions/commands/UnbanCmd.ts +++ b/backend/src/plugins/ModActions/commands/UnbanCmd.ts @@ -10,6 +10,7 @@ import { formatReasonWithAttachments } from "../functions/formatReasonWithAttach import { ignoreEvent } from "../functions/ignoreEvent"; import { IgnoredEventType, modActionsCmd } from "../types"; import { LogsPlugin } from "../../Logs/LogsPlugin"; +import { clearExpiringTempban } from "../../../data/loops/expiringTempbansLoop"; const opts = { mod: ct.member({ option: true }), @@ -68,7 +69,11 @@ export const UnbanCmd = modActionsCmd({ ppId: mod.id !== msg.author.id ? msg.author.id : undefined, }); // Delete the tempban, if one exists - pluginData.state.tempbans.clear(user.id); + const tempban = await pluginData.state.tempbans.findExistingTempbanForUserId(user.id); + if (tempban) { + clearExpiringTempban(tempban); + await pluginData.state.tempbans.clear(user.id); + } // Confirm the action sendSuccessMessage(pluginData, msg.channel, `Member unbanned (Case #${createdCase.case_number})`); diff --git a/backend/src/plugins/ModActions/functions/banUserId.ts b/backend/src/plugins/ModActions/functions/banUserId.ts index 0fc69371..bbaab584 100644 --- a/backend/src/plugins/ModActions/functions/banUserId.ts +++ b/backend/src/plugins/ModActions/functions/banUserId.ts @@ -19,6 +19,7 @@ import { BanOptions, BanResult, IgnoredEventType, ModActionsPluginType } from ". import { getDefaultContactMethods } from "./getDefaultContactMethods"; import { ignoreEvent } from "./ignoreEvent"; import { LogsPlugin } from "../../Logs/LogsPlugin"; +import { registerExpiringTempban } from "../../../data/loops/expiringTempbansLoop"; /** * Ban the specified user id, whether or not they're actually on the server at the time. Generates a case. @@ -112,6 +113,8 @@ export async function banUserId( } else { pluginData.state.tempbans.addTempban(user.id, banTime, banOptions.modId ?? selfId); } + const tempban = (await pluginData.state.tempbans.findExistingTempbanForUserId(user.id))!; + registerExpiringTempban(tempban); } // Create a case for this action diff --git a/backend/src/plugins/ModActions/functions/clearTempban.ts b/backend/src/plugins/ModActions/functions/clearTempban.ts new file mode 100644 index 00000000..00ef5849 --- /dev/null +++ b/backend/src/plugins/ModActions/functions/clearTempban.ts @@ -0,0 +1,60 @@ +import { Snowflake } from "discord.js"; +import humanizeDuration from "humanize-duration"; +import { GuildPluginData } from "knub"; +import moment from "moment-timezone"; +import { LogType } from "src/data/LogType"; +import { logger } from "src/logger"; +import { CaseTypes } from "../../../data/CaseTypes"; +import { resolveUser, SECONDS } from "../../../utils"; +import { CasesPlugin } from "../../Cases/CasesPlugin"; +import { IgnoredEventType, ModActionsPluginType } from "../types"; +import { formatReasonWithAttachments } from "./formatReasonWithAttachments"; +import { ignoreEvent } from "./ignoreEvent"; +import { isBanned } from "./isBanned"; +import { LogsPlugin } from "../../Logs/LogsPlugin"; +import { Tempban } from "../../../data/entities/Tempban"; + +export async function clearTempban(pluginData: GuildPluginData, tempban: Tempban) { + if (!(await isBanned(pluginData, tempban.user_id))) { + pluginData.state.tempbans.clear(tempban.user_id); + return; + } + + pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_UNBAN, tempban.user_id); + const reason = formatReasonWithAttachments( + `Tempban timed out. + Tempbanned at: \`${tempban.created_at} UTC\``, + [], + ); + try { + ignoreEvent(pluginData, IgnoredEventType.Unban, tempban.user_id); + await pluginData.guild.bans.remove(tempban.user_id as Snowflake, reason ?? undefined); + } catch (e) { + pluginData.getPlugin(LogsPlugin).logBotAlert({ + body: `Encountered an error trying to automatically unban ${tempban.user_id} after tempban timeout`, + }); + logger.warn(`Error automatically unbanning ${tempban.user_id} (tempban timeout): ${e}`); + return; + } + + // Create case and delete tempban + const casesPlugin = pluginData.getPlugin(CasesPlugin); + const createdCase = await casesPlugin.createCase({ + userId: tempban.user_id, + modId: tempban.mod_id, + type: CaseTypes.Unban, + reason, + ppId: undefined, + }); + pluginData.state.tempbans.clear(tempban.user_id); + + // Log the unban + const banTime = moment(tempban.created_at).diff(moment(tempban.expires_at)); + pluginData.getPlugin(LogsPlugin).logMemberTimedUnban({ + mod: await resolveUser(pluginData.client, tempban.mod_id), + userId: tempban.user_id, + caseNumber: createdCase.case_number, + reason, + banTime: humanizeDuration(banTime), + }); +} diff --git a/backend/src/plugins/ModActions/functions/outdatedTempbansLoop.ts b/backend/src/plugins/ModActions/functions/outdatedTempbansLoop.ts deleted file mode 100644 index ca3c79ef..00000000 --- a/backend/src/plugins/ModActions/functions/outdatedTempbansLoop.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { Snowflake } from "discord.js"; -import humanizeDuration from "humanize-duration"; -import { GuildPluginData } from "knub"; -import moment from "moment-timezone"; -import { LogType } from "src/data/LogType"; -import { logger } from "src/logger"; -import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects"; -import { CaseTypes } from "../../../data/CaseTypes"; -import { resolveUser, SECONDS } from "../../../utils"; -import { CasesPlugin } from "../../Cases/CasesPlugin"; -import { IgnoredEventType, ModActionsPluginType } from "../types"; -import { formatReasonWithAttachments } from "./formatReasonWithAttachments"; -import { ignoreEvent } from "./ignoreEvent"; -import { isBanned } from "./isBanned"; -import { LogsPlugin } from "../../Logs/LogsPlugin"; - -const TEMPBAN_LOOP_TIME = 60 * SECONDS; - -export async function outdatedTempbansLoop(pluginData: GuildPluginData) { - const outdatedTempbans = await pluginData.state.tempbans.getExpiredTempbans(); - - for (const tempban of outdatedTempbans) { - if (!(await isBanned(pluginData, tempban.user_id))) { - pluginData.state.tempbans.clear(tempban.user_id); - continue; - } - - pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_UNBAN, tempban.user_id); - const reason = formatReasonWithAttachments( - `Tempban timed out. - Tempbanned at: \`${tempban.created_at} UTC\``, - [], - ); - try { - ignoreEvent(pluginData, IgnoredEventType.Unban, tempban.user_id); - await pluginData.guild.bans.remove(tempban.user_id as Snowflake, reason ?? undefined); - } catch (e) { - pluginData.getPlugin(LogsPlugin).logBotAlert({ - body: `Encountered an error trying to automatically unban ${tempban.user_id} after tempban timeout`, - }); - logger.warn(`Error automatically unbanning ${tempban.user_id} (tempban timeout): ${e}`); - return; - } - - // Create case and delete tempban - const casesPlugin = pluginData.getPlugin(CasesPlugin); - const createdCase = await casesPlugin.createCase({ - userId: tempban.user_id, - modId: tempban.mod_id, - type: CaseTypes.Unban, - reason, - ppId: undefined, - }); - pluginData.state.tempbans.clear(tempban.user_id); - - // Log the unban - const banTime = moment(tempban.created_at).diff(moment(tempban.expires_at)); - pluginData.getPlugin(LogsPlugin).logMemberTimedUnban({ - mod: await resolveUser(pluginData.client, tempban.mod_id), - userId: tempban.user_id, - caseNumber: createdCase.case_number, - reason, - banTime: humanizeDuration(banTime), - }); - } - - if (!pluginData.state.unloaded) { - pluginData.state.outdatedTempbansTimeout = setTimeout(() => outdatedTempbansLoop(pluginData), TEMPBAN_LOOP_TIME); - } -} diff --git a/backend/src/plugins/ModActions/types.ts b/backend/src/plugins/ModActions/types.ts index b8c07270..16e8e15e 100644 --- a/backend/src/plugins/ModActions/types.ts +++ b/backend/src/plugins/ModActions/types.ts @@ -72,7 +72,7 @@ export interface ModActionsPluginType extends BasePluginType { serverLogs: GuildLogs; unloaded: boolean; - outdatedTempbansTimeout: Timeout | null; + unregisterGuildEventListener: () => void; ignoredEvents: IIgnoredEvent[]; massbanQueue: Queue; diff --git a/backend/src/plugins/Mutes/MutesPlugin.ts b/backend/src/plugins/Mutes/MutesPlugin.ts index ad1c0c66..7d18335e 100644 --- a/backend/src/plugins/Mutes/MutesPlugin.ts +++ b/backend/src/plugins/Mutes/MutesPlugin.ts @@ -15,12 +15,13 @@ import { MutesCmd } from "./commands/MutesCmd"; import { ClearActiveMuteOnMemberBanEvt } from "./events/ClearActiveMuteOnMemberBanEvt"; import { ClearActiveMuteOnRoleRemovalEvt } from "./events/ClearActiveMuteOnRoleRemovalEvt"; import { ReapplyActiveMuteOnJoinEvt } from "./events/ReapplyActiveMuteOnJoinEvt"; -import { clearExpiredMutes } from "./functions/clearExpiredMutes"; import { muteUser } from "./functions/muteUser"; import { offMutesEvent } from "./functions/offMutesEvent"; import { onMutesEvent } from "./functions/onMutesEvent"; import { unmuteUser } from "./functions/unmuteUser"; import { ConfigSchema, MutesPluginType } from "./types"; +import { onGuildEvent } from "../../data/GuildEvents"; +import { clearMute } from "./functions/clearMute"; const defaultOptions = { config: { @@ -113,15 +114,13 @@ export const MutesPlugin = zeppelinGuildPlugin()({ }, afterLoad(pluginData) { - clearExpiredMutes(pluginData); - pluginData.state.muteClearIntervalId = setInterval( - () => clearExpiredMutes(pluginData), - EXPIRED_MUTE_CHECK_INTERVAL, + pluginData.state.unregisterGuildEventListener = onGuildEvent(pluginData.guild.id, "expiredMute", (mute) => + clearMute(pluginData, mute), ); }, beforeUnload(pluginData) { - clearInterval(pluginData.state.muteClearIntervalId); + pluginData.state.unregisterGuildEventListener(); pluginData.state.events.removeAllListeners(); }, }); diff --git a/backend/src/plugins/Mutes/functions/clearExpiredMutes.ts b/backend/src/plugins/Mutes/functions/clearExpiredMutes.ts deleted file mode 100644 index dc83fee6..00000000 --- a/backend/src/plugins/Mutes/functions/clearExpiredMutes.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { Snowflake } from "discord.js"; -import { GuildPluginData } from "knub"; -import { memberToTemplateSafeMember } from "../../../utils/templateSafeObjects"; -import { LogType } from "../../../data/LogType"; -import { resolveMember, UnknownUser, verboseUserMention } from "../../../utils"; -import { memberRolesLock } from "../../../utils/lockNameHelpers"; -import { MutesPluginType } from "../types"; -import { LogsPlugin } from "../../Logs/LogsPlugin"; - -export async function clearExpiredMutes(pluginData: GuildPluginData) { - const expiredMutes = await pluginData.state.mutes.getExpiredMutes(); - for (const mute of expiredMutes) { - const member = await resolveMember(pluginData.client, pluginData.guild, mute.user_id, true); - - if (member) { - try { - const lock = await pluginData.locks.acquire(memberRolesLock(member)); - - const muteRole = pluginData.config.get().mute_role; - if (muteRole) { - await member.roles.remove(muteRole); - } - 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); - } - } - await member.roles.set(newRoles); - } - - lock.unlock(); - } catch { - pluginData.getPlugin(LogsPlugin).logBotAlert({ - body: `Failed to remove mute role from ${verboseUserMention(member.user)}`, - }); - } - } - - await pluginData.state.mutes.clear(mute.user_id); - - pluginData.getPlugin(LogsPlugin).logMemberMuteExpired({ - member: member || new UnknownUser({ id: mute.user_id }), - }); - - pluginData.state.events.emit("unmute", mute.user_id); - } -} diff --git a/backend/src/plugins/Mutes/functions/clearMute.ts b/backend/src/plugins/Mutes/functions/clearMute.ts new file mode 100644 index 00000000..e7167474 --- /dev/null +++ b/backend/src/plugins/Mutes/functions/clearMute.ts @@ -0,0 +1,55 @@ +import { Mute } from "../../../data/entities/Mute"; +import { resolveMember, verboseUserMention } from "../../../utils"; +import { memberRolesLock } from "../../../utils/lockNameHelpers"; +import { LogsPlugin } from "../../Logs/LogsPlugin"; +import { GuildPluginData } from "knub"; +import { MutesPluginType } from "../types"; +import { clearExpiringMute } from "../../../data/loops/expiringMutesLoop"; +import { GuildMember } from "discord.js"; + +export async function clearMute( + pluginData: GuildPluginData, + mute: Mute | null = null, + member: GuildMember | null = null, +) { + if (mute) { + clearExpiringMute(mute); + } + + if (!member && mute) { + member = await resolveMember(pluginData.client, pluginData.guild, mute.user_id, true); + } + + if (member) { + const lock = await pluginData.locks.acquire(memberRolesLock(member)); + + try { + const muteRole = pluginData.config.get().mute_role; + if (muteRole) { + await member.roles.remove(muteRole); + } + 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); + } + } + await member.roles.set(newRoles); + } + + lock.unlock(); + } catch { + pluginData.getPlugin(LogsPlugin).logBotAlert({ + body: `Failed to remove mute role from ${verboseUserMention(member.user)}`, + }); + } finally { + lock.unlock(); + } + } + + if (mute) { + await pluginData.state.mutes.clear(mute.user_id); + } +} diff --git a/backend/src/plugins/Mutes/functions/muteUser.ts b/backend/src/plugins/Mutes/functions/muteUser.ts index cf0aeca0..bf60002e 100644 --- a/backend/src/plugins/Mutes/functions/muteUser.ts +++ b/backend/src/plugins/Mutes/functions/muteUser.ts @@ -4,7 +4,6 @@ import { GuildPluginData } from "knub"; import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects"; import { CaseTypes } from "../../../data/CaseTypes"; import { Case } from "../../../data/entities/Case"; -import { LogType } from "../../../data/LogType"; import { LogsPlugin } from "../../../plugins/Logs/LogsPlugin"; import { ERRORS, RecoverablePluginError } from "../../../RecoverablePluginError"; import { renderTemplate, TemplateSafeValueContainer } from "../../../templateFormatter"; @@ -19,7 +18,12 @@ import { import { muteLock } from "../../../utils/lockNameHelpers"; import { CasesPlugin } from "../../Cases/CasesPlugin"; import { MuteOptions, MutesPluginType } from "../types"; +import { Mute } from "../../../data/entities/Mute"; +import { registerExpiringMute } from "../../../data/loops/expiringMutesLoop"; +/** + * TODO: Clean up this function + */ export async function muteUser( pluginData: GuildPluginData, userId: string, @@ -132,6 +136,7 @@ export async function muteUser( // If the user is already muted, update the duration of their existing mute const existingMute = await pluginData.state.mutes.findExistingMuteForUserId(user.id); + let finalMute: Mute; let notifyResult: UserNotificationResult = { method: null, success: true }; if (existingMute) { @@ -139,10 +144,13 @@ export async function muteUser( rolesToRestore = Array.from(new Set([...existingMute.roles_to_restore, ...rolesToRestore])); } await pluginData.state.mutes.updateExpiryTime(user.id, muteTime, rolesToRestore); + finalMute = (await pluginData.state.mutes.findExistingMuteForUserId(user.id))!; } else { - await pluginData.state.mutes.addMute(user.id, muteTime, rolesToRestore); + finalMute = await pluginData.state.mutes.addMute(user.id, muteTime, rolesToRestore); } + registerExpiringMute(finalMute); + const template = existingMute ? config.update_mute_message : muteTime diff --git a/backend/src/plugins/Mutes/functions/unmuteUser.ts b/backend/src/plugins/Mutes/functions/unmuteUser.ts index 0f2d88d5..a3c55b08 100644 --- a/backend/src/plugins/Mutes/functions/unmuteUser.ts +++ b/backend/src/plugins/Mutes/functions/unmuteUser.ts @@ -11,6 +11,7 @@ import { CaseArgs } from "../../Cases/types"; import { MutesPluginType, UnmuteResult } from "../types"; import { memberHasMutedRole } from "./memberHasMutedRole"; import { LogsPlugin } from "../../Logs/LogsPlugin"; +import { clearMute } from "./clearMute"; export async function unmuteUser( pluginData: GuildPluginData, @@ -34,34 +35,7 @@ export async function unmuteUser( } } else { // Unmute immediately - if (member) { - const lock = await pluginData.locks.acquire(memberRolesLock(member)); - - const muteRole = pluginData.config.get().mute_role; - if (muteRole && member.roles.cache.has(muteRole as Snowflake)) { - await member.roles.remove(muteRole as Snowflake); - } - if (existingMute?.roles_to_restore) { - const guildRoles = pluginData.guild.roles.cache; - const newRoles = [...member.roles.cache.keys()].filter((roleId) => roleId !== muteRole); - for (const toRestore of existingMute.roles_to_restore) { - if (guildRoles.has(toRestore) && toRestore !== muteRole && !newRoles.includes(toRestore)) { - newRoles.push(toRestore); - } - } - await member.roles.set(newRoles); - } - - lock.unlock(); - } else { - // tslint:disable-next-line:no-console - console.warn( - `Member ${userId} not found in guild ${pluginData.guild.name} (${pluginData.guild.id}) when attempting to unmute`, - ); - } - if (existingMute) { - await pluginData.state.mutes.clear(userId); - } + clearMute(pluginData, existingMute); } const timeUntilUnmute = unmuteTime && humanizeDuration(unmuteTime); diff --git a/backend/src/plugins/Mutes/types.ts b/backend/src/plugins/Mutes/types.ts index c0afcc18..b612b029 100644 --- a/backend/src/plugins/Mutes/types.ts +++ b/backend/src/plugins/Mutes/types.ts @@ -52,7 +52,7 @@ export interface MutesPluginType extends BasePluginType { serverLogs: GuildLogs; archives: GuildArchives; - muteClearIntervalId: Timeout; + unregisterGuildEventListener: () => void; events: MutesEventEmitter; }; diff --git a/backend/src/plugins/Post/PostPlugin.ts b/backend/src/plugins/Post/PostPlugin.ts index 1b176252..5c44ce27 100644 --- a/backend/src/plugins/Post/PostPlugin.ts +++ b/backend/src/plugins/Post/PostPlugin.ts @@ -8,12 +8,13 @@ import { EditCmd } from "./commands/EditCmd"; import { EditEmbedCmd } from "./commands/EditEmbedCmd"; import { PostCmd } from "./commands/PostCmd"; import { PostEmbedCmd } from "./commands/PostEmbedCmd"; -import { ScheduledPostsDeleteCmd } from "./commands/SchedluedPostsDeleteCmd"; +import { ScheduledPostsDeleteCmd } from "./commands/ScheduledPostsDeleteCmd"; import { ScheduledPostsListCmd } from "./commands/ScheduledPostsListCmd"; import { ScheduledPostsShowCmd } from "./commands/ScheduledPostsShowCmd"; import { ConfigSchema, PostPluginType } from "./types"; -import { scheduledPostLoop } from "./util/scheduledPostLoop"; import { LogsPlugin } from "../Logs/LogsPlugin"; +import { onGuildEvent } from "../../data/GuildEvents"; +import { postScheduledPost } from "./util/postScheduledPost"; const defaultOptions: PluginOptions = { config: { @@ -60,10 +61,12 @@ export const PostPlugin = zeppelinGuildPlugin()({ }, afterLoad(pluginData) { - scheduledPostLoop(pluginData); + pluginData.state.unregisterGuildEventListener = onGuildEvent(pluginData.guild.id, "scheduledPost", (post) => + postScheduledPost(pluginData, post), + ); }, beforeUnload(pluginData) { - clearTimeout(pluginData.state.scheduledPostLoopTimeout); + pluginData.state.unregisterGuildEventListener(); }, }); diff --git a/backend/src/plugins/Post/commands/SchedluedPostsDeleteCmd.ts b/backend/src/plugins/Post/commands/ScheduledPostsDeleteCmd.ts similarity index 86% rename from backend/src/plugins/Post/commands/SchedluedPostsDeleteCmd.ts rename to backend/src/plugins/Post/commands/ScheduledPostsDeleteCmd.ts index 0cda8a72..2a08344f 100644 --- a/backend/src/plugins/Post/commands/SchedluedPostsDeleteCmd.ts +++ b/backend/src/plugins/Post/commands/ScheduledPostsDeleteCmd.ts @@ -2,6 +2,7 @@ import { commandTypeHelpers as ct } from "../../../commandTypes"; import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils"; import { sorter } from "../../../utils"; import { postCmd } from "../types"; +import { clearUpcomingScheduledPost } from "../../../data/loops/upcomingScheduledPostsLoop"; export const ScheduledPostsDeleteCmd = postCmd({ trigger: ["scheduled_posts delete", "scheduled_posts d"], @@ -20,6 +21,7 @@ export const ScheduledPostsDeleteCmd = postCmd({ return; } + clearUpcomingScheduledPost(post); await pluginData.state.scheduledPosts.delete(post.id); sendSuccessMessage(pluginData, msg.channel, "Scheduled post deleted!"); }, diff --git a/backend/src/plugins/Post/types.ts b/backend/src/plugins/Post/types.ts index b54a23d2..493e8444 100644 --- a/backend/src/plugins/Post/types.ts +++ b/backend/src/plugins/Post/types.ts @@ -16,7 +16,7 @@ export interface PostPluginType extends BasePluginType { scheduledPosts: GuildScheduledPosts; logs: GuildLogs; - scheduledPostLoopTimeout: NodeJS.Timeout; + unregisterGuildEventListener: () => void; }; } diff --git a/backend/src/plugins/Post/util/actualPostCmd.ts b/backend/src/plugins/Post/util/actualPostCmd.ts index 32320368..054f655c 100644 --- a/backend/src/plugins/Post/util/actualPostCmd.ts +++ b/backend/src/plugins/Post/util/actualPostCmd.ts @@ -11,6 +11,7 @@ import { PostPluginType } from "../types"; import { parseScheduleTime } from "./parseScheduleTime"; import { postMessage } from "./postMessage"; import { LogsPlugin } from "../../Logs/LogsPlugin"; +import { registerUpcomingScheduledPost } from "../../../data/loops/upcomingScheduledPostsLoop"; const MIN_REPEAT_TIME = 5 * MINUTES; const MAX_REPEAT_TIME = Math.pow(2, 32); @@ -141,7 +142,7 @@ export async function actualPostCmd( return; } - await pluginData.state.scheduledPosts.create({ + const post = await pluginData.state.scheduledPosts.create({ author_id: msg.author.id, author_name: msg.author.tag, channel_id: targetChannel.id, @@ -153,6 +154,7 @@ export async function actualPostCmd( repeat_until: repeatUntil ? repeatUntil.clone().tz("Etc/UTC").format(DBDateFormat) : null, repeat_times: repeatTimes ?? null, }); + registerUpcomingScheduledPost(post); if (opts.repeat) { pluginData.getPlugin(LogsPlugin).logScheduledRepeatedMessage({ diff --git a/backend/src/plugins/Post/util/postScheduledPost.ts b/backend/src/plugins/Post/util/postScheduledPost.ts new file mode 100644 index 00000000..824f5021 --- /dev/null +++ b/backend/src/plugins/Post/util/postScheduledPost.ts @@ -0,0 +1,78 @@ +import { Snowflake, TextChannel, User } from "discord.js"; +import { GuildPluginData } from "knub"; +import moment from "moment-timezone"; +import { logger } from "../../../logger"; +import { DBDateFormat, verboseChannelMention, verboseUserMention } from "../../../utils"; +import { PostPluginType } from "../types"; +import { postMessage } from "./postMessage"; +import { LogsPlugin } from "../../Logs/LogsPlugin"; +import { ScheduledPost } from "../../../data/entities/ScheduledPost"; +import { registerUpcomingScheduledPost } from "../../../data/loops/upcomingScheduledPostsLoop"; + +export async function postScheduledPost(pluginData: GuildPluginData, post: ScheduledPost) { + // First, update the scheduled post or delete it from the database *before* we try posting it. + // This ensures strange errors don't cause reposts. + let shouldClear = true; + + if (post.repeat_interval) { + const nextPostAt = moment.utc().add(post.repeat_interval, "ms"); + + if (post.repeat_until) { + const repeatUntil = moment.utc(post.repeat_until, DBDateFormat); + if (nextPostAt.isSameOrBefore(repeatUntil)) { + await pluginData.state.scheduledPosts.update(post.id, { + post_at: nextPostAt.format(DBDateFormat), + }); + shouldClear = false; + } + } else if (post.repeat_times) { + if (post.repeat_times > 1) { + await pluginData.state.scheduledPosts.update(post.id, { + post_at: nextPostAt.format(DBDateFormat), + repeat_times: post.repeat_times - 1, + }); + shouldClear = false; + } + } + } + + if (shouldClear) { + await pluginData.state.scheduledPosts.delete(post.id); + } else { + const upToDatePost = (await pluginData.state.scheduledPosts.find(post.id))!; + registerUpcomingScheduledPost(upToDatePost); + } + + // Post the message + const channel = pluginData.guild.channels.cache.get(post.channel_id as Snowflake); + if (channel?.isText() || channel?.isThread()) { + const [username, discriminator] = post.author_name.split("#"); + const author: User = (await pluginData.client.users.fetch(post.author_id as Snowflake)) || { + id: post.author_id, + username, + discriminator, + }; + + try { + const postedMessage = await postMessage( + pluginData, + channel, + post.content, + post.attachments, + post.enable_mentions, + ); + pluginData.getPlugin(LogsPlugin).logPostedScheduledMessage({ + author, + channel, + messageId: postedMessage.id, + }); + } catch { + pluginData.getPlugin(LogsPlugin).logBotAlert({ + body: `Failed to post scheduled message by ${verboseUserMention(author)} to ${verboseChannelMention(channel)}`, + }); + logger.warn( + `Failed to post scheduled message to #${channel.name} (${channel.id}) on ${pluginData.guild.name} (${pluginData.guild.id})`, + ); + } + } +} diff --git a/backend/src/plugins/Post/util/scheduledPostLoop.ts b/backend/src/plugins/Post/util/scheduledPostLoop.ts deleted file mode 100644 index 2a946c1e..00000000 --- a/backend/src/plugins/Post/util/scheduledPostLoop.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { Snowflake, TextChannel, User } from "discord.js"; -import { GuildPluginData } from "knub"; -import moment from "moment-timezone"; -import { channelToTemplateSafeChannel, userToTemplateSafeUser } from "../../../utils/templateSafeObjects"; -import { LogType } from "../../../data/LogType"; -import { logger } from "../../../logger"; -import { DBDateFormat, SECONDS, verboseChannelMention, verboseUserMention } from "../../../utils"; -import { PostPluginType } from "../types"; -import { postMessage } from "./postMessage"; -import { LogsPlugin } from "../../Logs/LogsPlugin"; - -const SCHEDULED_POST_CHECK_INTERVAL = 5 * SECONDS; - -export async function scheduledPostLoop(pluginData: GuildPluginData) { - const duePosts = await pluginData.state.scheduledPosts.getDueScheduledPosts(); - for (const post of duePosts) { - const channel = pluginData.guild.channels.cache.get(post.channel_id as Snowflake); - if (channel instanceof TextChannel) { - const [username, discriminator] = post.author_name.split("#"); - const author: User = (await pluginData.client.users.fetch(post.author_id as Snowflake)) || { - id: post.author_id, - username, - discriminator, - }; - - try { - const postedMessage = await postMessage( - pluginData, - channel, - post.content, - post.attachments, - post.enable_mentions, - ); - pluginData.getPlugin(LogsPlugin).logPostedScheduledMessage({ - author, - channel, - messageId: postedMessage.id, - }); - } catch { - pluginData.getPlugin(LogsPlugin).logBotAlert({ - body: `Failed to post scheduled message by ${verboseUserMention(author)} to ${verboseChannelMention( - channel, - )}`, - }); - logger.warn( - `Failed to post scheduled message to #${channel.name} (${channel.id}) on ${pluginData.guild.name} (${pluginData.guild.id})`, - ); - } - } - - let shouldClear = true; - - if (post.repeat_interval) { - const nextPostAt = moment.utc().add(post.repeat_interval, "ms"); - - if (post.repeat_until) { - const repeatUntil = moment.utc(post.repeat_until, DBDateFormat); - if (nextPostAt.isSameOrBefore(repeatUntil)) { - await pluginData.state.scheduledPosts.update(post.id, { - post_at: nextPostAt.format(DBDateFormat), - }); - shouldClear = false; - } - } else if (post.repeat_times) { - if (post.repeat_times > 1) { - await pluginData.state.scheduledPosts.update(post.id, { - post_at: nextPostAt.format(DBDateFormat), - repeat_times: post.repeat_times - 1, - }); - shouldClear = false; - } - } - } - - if (shouldClear) { - await pluginData.state.scheduledPosts.delete(post.id); - } - } - - pluginData.state.scheduledPostLoopTimeout = setTimeout( - () => scheduledPostLoop(pluginData), - SCHEDULED_POST_CHECK_INTERVAL, - ); -} diff --git a/backend/src/plugins/Reminders/RemindersPlugin.ts b/backend/src/plugins/Reminders/RemindersPlugin.ts index 058c5a46..67a97035 100644 --- a/backend/src/plugins/Reminders/RemindersPlugin.ts +++ b/backend/src/plugins/Reminders/RemindersPlugin.ts @@ -6,7 +6,8 @@ import { RemindCmd } from "./commands/RemindCmd"; import { RemindersCmd } from "./commands/RemindersCmd"; import { RemindersDeleteCmd } from "./commands/RemindersDeleteCmd"; import { ConfigSchema, RemindersPluginType } from "./types"; -import { postDueRemindersLoop } from "./utils/postDueRemindersLoop"; +import { onGuildEvent } from "../../data/GuildEvents"; +import { postReminder } from "./functions/postReminder"; const defaultOptions: PluginOptions = { config: { @@ -46,16 +47,16 @@ export const RemindersPlugin = zeppelinGuildPlugin()({ state.reminders = GuildReminders.getGuildInstance(guild.id); state.tries = new Map(); state.unloaded = false; - - state.postRemindersTimeout = null; }, afterLoad(pluginData) { - postDueRemindersLoop(pluginData); + pluginData.state.unregisterGuildEventListener = onGuildEvent(pluginData.guild.id, "reminder", (reminder) => + postReminder(pluginData, reminder), + ); }, beforeUnload(pluginData) { - clearTimeout(pluginData.state.postRemindersTimeout); + pluginData.state.unregisterGuildEventListener(); pluginData.state.unloaded = true; }, }); diff --git a/backend/src/plugins/Reminders/commands/RemindCmd.ts b/backend/src/plugins/Reminders/commands/RemindCmd.ts index 24c765a7..122c9e3d 100644 --- a/backend/src/plugins/Reminders/commands/RemindCmd.ts +++ b/backend/src/plugins/Reminders/commands/RemindCmd.ts @@ -5,6 +5,7 @@ import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils"; import { convertDelayStringToMS, messageLink } from "../../../utils"; import { TimeAndDatePlugin } from "../../TimeAndDate/TimeAndDatePlugin"; import { remindersCmd } from "../types"; +import { registerUpcomingReminder } from "../../../data/loops/upcomingRemindersLoop"; export const RemindCmd = remindersCmd({ trigger: ["remind", "remindme", "reminder"], @@ -50,7 +51,7 @@ export const RemindCmd = remindersCmd({ } const reminderBody = args.reminder || messageLink(pluginData.guild.id, msg.channel.id, msg.id); - await pluginData.state.reminders.add( + const reminder = await pluginData.state.reminders.add( msg.author.id, msg.channel.id, reminderTime.clone().tz("Etc/UTC").format("YYYY-MM-DD HH:mm:ss"), @@ -58,6 +59,8 @@ export const RemindCmd = remindersCmd({ moment.utc().format("YYYY-MM-DD HH:mm:ss"), ); + registerUpcomingReminder(reminder); + const msUntilReminder = reminderTime.diff(now); const timeUntilReminder = humanizeDuration(msUntilReminder, { largest: 2, round: true }); const prettyReminderTime = (await timeAndDate.inMemberTz(msg.author.id, reminderTime)).format( diff --git a/backend/src/plugins/Reminders/commands/RemindersDeleteCmd.ts b/backend/src/plugins/Reminders/commands/RemindersDeleteCmd.ts index e9f97a66..0690b434 100644 --- a/backend/src/plugins/Reminders/commands/RemindersDeleteCmd.ts +++ b/backend/src/plugins/Reminders/commands/RemindersDeleteCmd.ts @@ -2,6 +2,7 @@ import { commandTypeHelpers as ct } from "../../../commandTypes"; import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils"; import { sorter } from "../../../utils"; import { remindersCmd } from "../types"; +import { clearUpcomingReminder } from "../../../data/loops/upcomingRemindersLoop"; export const RemindersDeleteCmd = remindersCmd({ trigger: ["reminders delete", "reminders d"], @@ -21,6 +22,7 @@ export const RemindersDeleteCmd = remindersCmd({ } const toDelete = reminders[args.num - 1]; + clearUpcomingReminder(toDelete); await pluginData.state.reminders.delete(toDelete.id); sendSuccessMessage(pluginData, msg.channel, "Reminder deleted"); diff --git a/backend/src/plugins/Reminders/functions/postReminder.ts b/backend/src/plugins/Reminders/functions/postReminder.ts new file mode 100644 index 00000000..85b95cb8 --- /dev/null +++ b/backend/src/plugins/Reminders/functions/postReminder.ts @@ -0,0 +1,41 @@ +import { GuildPluginData } from "knub"; +import { RemindersPluginType } from "../types"; +import { Reminder } from "../../../data/entities/Reminder"; +import { Snowflake, TextChannel } from "discord.js"; +import moment from "moment-timezone"; +import { disableLinkPreviews } from "knub/dist/helpers"; +import { DBDateFormat, SECONDS } from "../../../utils"; +import humanizeDuration from "humanize-duration"; + +export async function postReminder(pluginData: GuildPluginData, reminder: Reminder) { + const channel = pluginData.guild.channels.cache.get(reminder.channel_id as Snowflake); + if (channel && (channel.isText() || channel.isThread())) { + try { + // Only show created at date if one exists + if (moment.utc(reminder.created_at).isValid()) { + const createdAtTS = Math.floor(moment.utc(reminder.created_at, DBDateFormat).valueOf() / 1000); + await channel.send({ + content: disableLinkPreviews( + `Reminder for <@!${reminder.user_id}>: ${reminder.body} \nSet `, + ), + allowedMentions: { + users: [reminder.user_id as Snowflake], + }, + }); + } else { + await channel.send({ + content: disableLinkPreviews(`Reminder for <@!${reminder.user_id}>: ${reminder.body}`), + allowedMentions: { + users: [reminder.user_id as Snowflake], + }, + }); + } + } catch (err) { + // If we were unable to post the reminder, we'll try again later + console.warn(`Error when posting reminder for ${reminder.user_id} in guild ${reminder.guild_id}: ${String(err)}`); + return; + } + } + + await pluginData.state.reminders.delete(reminder.id); +} diff --git a/backend/src/plugins/Reminders/types.ts b/backend/src/plugins/Reminders/types.ts index 0a2c37e9..be9f3fdb 100644 --- a/backend/src/plugins/Reminders/types.ts +++ b/backend/src/plugins/Reminders/types.ts @@ -14,7 +14,8 @@ export interface RemindersPluginType extends BasePluginType { reminders: GuildReminders; tries: Map; - postRemindersTimeout; + unregisterGuildEventListener: () => void; + unloaded: boolean; }; } diff --git a/backend/src/plugins/Reminders/utils/postDueRemindersLoop.ts b/backend/src/plugins/Reminders/utils/postDueRemindersLoop.ts deleted file mode 100644 index b5fc6257..00000000 --- a/backend/src/plugins/Reminders/utils/postDueRemindersLoop.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { Snowflake, TextChannel } from "discord.js"; -import humanizeDuration from "humanize-duration"; -import { GuildPluginData } from "knub"; -import { disableLinkPreviews } from "knub/dist/helpers"; -import moment from "moment-timezone"; -import { SECONDS } from "../../../utils"; -import { RemindersPluginType } from "../types"; - -const REMINDER_LOOP_TIME = 10 * SECONDS; -const MAX_TRIES = 3; - -export async function postDueRemindersLoop(pluginData: GuildPluginData) { - const pendingReminders = await pluginData.state.reminders.getDueReminders(); - for (const reminder of pendingReminders) { - const channel = pluginData.guild.channels.cache.get(reminder.channel_id as Snowflake); - if (channel && channel instanceof TextChannel) { - try { - // Only show created at date if one exists - if (moment.utc(reminder.created_at).isValid()) { - const target = moment.utc(); - const diff = target.diff(moment.utc(reminder.created_at, "YYYY-MM-DD HH:mm:ss")); - const result = humanizeDuration(diff, { largest: 2, round: true }); - await channel.send({ - content: disableLinkPreviews( - `Reminder for <@!${reminder.user_id}>: ${reminder.body} \n\`Set at ${reminder.created_at} (${result} ago)\``, - ), - allowedMentions: { - users: [reminder.user_id as Snowflake], - }, - }); - } else { - await channel.send({ - content: disableLinkPreviews(`Reminder for <@!${reminder.user_id}>: ${reminder.body}`), - allowedMentions: { - users: [reminder.user_id as Snowflake], - }, - }); - } - } catch { - // Probably random Discord internal server error or missing permissions or somesuch - // Try again next round unless we've already tried to post this a bunch of times - const tries = pluginData.state.tries.get(reminder.id) || 0; - if (tries < MAX_TRIES) { - pluginData.state.tries.set(reminder.id, tries + 1); - continue; - } - } - } - - await pluginData.state.reminders.delete(reminder.id); - } - - if (!pluginData.state.unloaded) { - pluginData.state.postRemindersTimeout = setTimeout(() => postDueRemindersLoop(pluginData), REMINDER_LOOP_TIME); - } -} diff --git a/backend/src/utils.ts b/backend/src/utils.ts index e5bde6fe..fd14070d 100644 --- a/backend/src/utils.ts +++ b/backend/src/utils.ts @@ -1534,7 +1534,7 @@ interface IMemoizedItem { } const memoizeCache: Map = new Map(); -export function memoize(fn: (...args: any[]) => T, key?, time?): T { +export function memoize(fn: () => T, key?, time?): T { const realKey = key ?? fn; if (memoizeCache.has(realKey)) { @@ -1555,6 +1555,12 @@ export function memoize(fn: (...args: any[]) => T, key?, time?): T { return value; } +export function lazyMemoize unknown>(fn: T, key?: string, time?: number): T { + return (() => { + return memoize(fn, key, time); + }) as T; +} + type RecursiveRenderFn = (str: string) => string | Promise; export async function renderRecursively(value, fn: RecursiveRenderFn) { diff --git a/backend/src/utils/typeUtils.ts b/backend/src/utils/typeUtils.ts index a32aa0e1..26bc121e 100644 --- a/backend/src/utils/typeUtils.ts +++ b/backend/src/utils/typeUtils.ts @@ -5,3 +5,6 @@ export declare type WithRequiredProps = T & { // https://mariusschulz.com/blog/mapped-type-modifiers-in-typescript#removing-the-mapped-type-modifier [PK in K]-?: Exclude; }; + +// https://devblogs.microsoft.com/typescript/announcing-typescript-4-1/ +export type Awaited = T extends PromiseLike ? Awaited : T;