From c946b75924f588bf46e90e1879eaa34c69457b3c Mon Sep 17 00:00:00 2001 From: Guido de Jong <35309288+guidojw@users.noreply.github.com> Date: Thu, 21 Sep 2023 23:22:39 +0200 Subject: [PATCH] feat: event-based trainings overview (#599) --- package.json | 4 +- src/client/setting-provider.ts | 2 +- src/client/websocket/handlers/index.ts | 3 + .../websocket/handlers/training-cancel.ts | 40 ++++++++++ .../websocket/handlers/training-create.ts | 33 +++++++++ .../websocket/handlers/training-update.ts | 40 ++++++++++ src/configs/container.ts | 6 ++ src/configs/cron.ts | 4 - .../slash-commands/admin/trainings.ts | 6 +- src/jobs/announce-trainings.ts | 51 ++++--------- src/loaders/index.ts | 4 +- src/services/group.ts | 6 +- src/services/index.ts | 1 + src/structures/guild-context.ts | 13 ++-- src/utils/time.ts | 9 --- yarn.lock | 73 +++++++++++++------ 16 files changed, 203 insertions(+), 92 deletions(-) create mode 100644 src/client/websocket/handlers/training-cancel.ts create mode 100644 src/client/websocket/handlers/training-create.ts create mode 100644 src/client/websocket/handlers/training-update.ts diff --git a/package.json b/package.json index ddbfde4f..6a04392c 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "emoji-regex": "^10.2.1", "inversify": "^6.0.1", "lodash": "^4.17.21", - "node-cron": "^3.0.2", + "node-schedule": "^2.1.1", "pg": "^8.11.3", "pg-hstore": "^2.3.4", "pluralize": "^8.0.0", @@ -39,7 +39,7 @@ "@types/common-tags": "^1.8.1", "@types/lodash": "^4.14.197", "@types/node": "^18.17.14", - "@types/node-cron": "^3.0.8", + "@types/node-schedule": "^2.1.0", "@types/pluralize": "^0.0.30", "@types/ws": "^8.5.5", "@typescript-eslint/eslint-plugin": "^6.5.0", diff --git a/src/client/setting-provider.ts b/src/client/setting-provider.ts index fdf2d244..d4d648ee 100644 --- a/src/client/setting-provider.ts +++ b/src/client/setting-provider.ts @@ -84,6 +84,6 @@ export default class SettingProvider { // Remove more from the relations and put it here if above error returns.. const context = this.guildContexts.add(data, { id: data.id, extras: [guild] }) - context.init() + await context.init() } } diff --git a/src/client/websocket/handlers/index.ts b/src/client/websocket/handlers/index.ts index bd9ed825..596e39ce 100644 --- a/src/client/websocket/handlers/index.ts +++ b/src/client/websocket/handlers/index.ts @@ -1 +1,4 @@ export { default as RankChangePacketHandler } from './rank-change' +export { default as TrainingCancelPacketHandler } from './training-cancel' +export { default as TrainingCreatePacketHandler } from './training-create' +export { default as TrainingUpdatePacketHandler } from './training-update' diff --git a/src/client/websocket/handlers/training-cancel.ts b/src/client/websocket/handlers/training-cancel.ts new file mode 100644 index 00000000..557b08e3 --- /dev/null +++ b/src/client/websocket/handlers/training-cancel.ts @@ -0,0 +1,40 @@ +import { inject, injectable, named } from 'inversify' +import { AnnounceTrainingsJob } from '../../../jobs' +import type { BaseHandler } from '../..' +import { GuildContextManager } from '../../../managers' +import type { Training } from '../../../services' +import { constants } from '../../../utils' +import cron from 'node-schedule' + +const { TYPES } = constants + +interface TraniningCancelPacket { + groupId: number + training: Training +} + +@injectable() +export default class TrainingCancelPacketHandler implements BaseHandler { + @inject(TYPES.Job) + @named('announceTrainings') + private readonly announceTrainingsJob!: AnnounceTrainingsJob + + @inject(TYPES.Manager) + @named('GuildContextManager') + private readonly guildContexts!: GuildContextManager + + public async handle ({ data }: { data: TraniningCancelPacket }): Promise { + const { groupId, training } = data + for (const context of this.guildContexts.cache.values()) { + if (context.robloxGroupId === groupId) { + const jobName = `training_${training.id}` + const job = cron.scheduledJobs[jobName] + if (typeof job !== 'undefined') { + job.cancel() + } + + await this.announceTrainingsJob.run(context) + } + } + } +} diff --git a/src/client/websocket/handlers/training-create.ts b/src/client/websocket/handlers/training-create.ts new file mode 100644 index 00000000..a869a352 --- /dev/null +++ b/src/client/websocket/handlers/training-create.ts @@ -0,0 +1,33 @@ +import { inject, injectable, named } from 'inversify' +import { AnnounceTrainingsJob } from '../../../jobs' +import type { BaseHandler } from '../..' +import { GuildContextManager } from '../../../managers' +import type { Training } from '../../../services' +import { constants } from '../../../utils' + +const { TYPES } = constants + +interface TraniningCreatePacket { + groupId: number + training: Training +} + +@injectable() +export default class TrainingCreatePacketHandler implements BaseHandler { + @inject(TYPES.Job) + @named('announceTrainings') + private readonly announceTrainingsJob!: AnnounceTrainingsJob + + @inject(TYPES.Manager) + @named('GuildContextManager') + private readonly guildContexts!: GuildContextManager + + public async handle ({ data }: { data: TraniningCreatePacket }): Promise { + const { groupId } = data + for (const context of this.guildContexts.cache.values()) { + if (context.robloxGroupId === groupId) { + await this.announceTrainingsJob.run(context) + } + } + } +} diff --git a/src/client/websocket/handlers/training-update.ts b/src/client/websocket/handlers/training-update.ts new file mode 100644 index 00000000..b92609d5 --- /dev/null +++ b/src/client/websocket/handlers/training-update.ts @@ -0,0 +1,40 @@ +import { inject, injectable, named } from 'inversify' +import { AnnounceTrainingsJob } from '../../../jobs' +import type { BaseHandler } from '../..' +import { GuildContextManager } from '../../../managers' +import type { Training } from '../../../services' +import { constants } from '../../../utils' +import cron from 'node-schedule' + +const { TYPES } = constants + +interface TraniningUpdatePacket { + groupId: number + training: Training +} + +@injectable() +export default class TrainingUpdatePacketHandler implements BaseHandler { + @inject(TYPES.Job) + @named('announceTrainings') + private readonly announceTrainingsJob!: AnnounceTrainingsJob + + @inject(TYPES.Manager) + @named('GuildContextManager') + private readonly guildContexts!: GuildContextManager + + public async handle ({ data }: { data: TraniningUpdatePacket }): Promise { + const { groupId, training } = data + for (const context of this.guildContexts.cache.values()) { + if (context.robloxGroupId === groupId) { + const jobName = `training_${training.id}` + const job = cron.scheduledJobs[jobName] + if (typeof job !== 'undefined') { + job.cancel() + } + + await this.announceTrainingsJob.run(context) + } + } + } +} diff --git a/src/configs/container.ts b/src/configs/container.ts index c4ed4a24..4e9ddae7 100644 --- a/src/configs/container.ts +++ b/src/configs/container.ts @@ -233,6 +233,12 @@ bind>(TYPES.JobFactory) // Packet Handlers bind(TYPES.Handler).to(packetHandlers.RankChangePacketHandler) .whenTargetTagged('packetHandler', 'rankChange') +bind(TYPES.Handler).to(packetHandlers.TrainingCancelPacketHandler) + .whenTargetTagged('packetHandler', 'trainingCancel') +bind(TYPES.Handler).to(packetHandlers.TrainingCreatePacketHandler) + .whenTargetTagged('packetHandler', 'trainingCreate') +bind(TYPES.Handler).to(packetHandlers.TrainingUpdatePacketHandler) + .whenTargetTagged('packetHandler', 'trainingUpdate') bind>(TYPES.PacketHandlerFactory) .toFactory( diff --git a/src/configs/cron.ts b/src/configs/cron.ts index adff6e37..36937bb2 100644 --- a/src/configs/cron.ts +++ b/src/configs/cron.ts @@ -1,10 +1,6 @@ export interface CronConfig { name: string, expression: string } const cronConfig: Record = { - announceTrainingsJob: { - name: 'announceTrainings', - expression: '*/5 * * * *' // https://crontab.guru/#*/5_*_*_*_* - }, healthCheckJob: { name: 'healthCheck', expression: '*/5 * * * *' // https://crontab.guru/#*/5_*_*_*_* diff --git a/src/interactions/application-commands/slash-commands/admin/trainings.ts b/src/interactions/application-commands/slash-commands/admin/trainings.ts index 48f25bef..33506556 100644 --- a/src/interactions/application-commands/slash-commands/admin/trainings.ts +++ b/src/interactions/application-commands/slash-commands/admin/trainings.ts @@ -12,7 +12,7 @@ import { applicationAdapter } from '../../../../adapters' import applicationConfig from '../../../../configs/application' const { TYPES } = constants -const { getDate, getDateInfo, getTime, getTimeInfo, getTimeZoneAbbreviation } = timeUtil +const { getDate, getDateInfo, getTime, getTimeInfo } = timeUtil const { noChannels, noTags, noUrls, validDate, validTime, validators } = argumentUtil const parseKey = (val: string): string => val.toLowerCase() @@ -237,8 +237,8 @@ export default class TrainingsCommand extends SubCommandCommand`, inline: true }, + { name: 'Time', value: ``, inline: true }, { name: 'Host', value: username, inline: true } ]) .setColor(context.primaryColor ?? applicationConfig.defaultColor) diff --git a/src/jobs/announce-trainings.ts b/src/jobs/announce-trainings.ts index 95f20cc4..2117c043 100644 --- a/src/jobs/announce-trainings.ts +++ b/src/jobs/announce-trainings.ts @@ -1,3 +1,4 @@ +import cron, { type JobCallback } from 'node-schedule' import { groupService, userService } from '../services' import type BaseJob from './base' import { EmbedBuilder } from 'discord.js' @@ -8,16 +9,12 @@ import { applicationAdapter } from '../adapters' import applicationConfig from '../configs/application' import { injectable } from 'inversify' import lodash from 'lodash' -import pluralize from 'pluralize' -import { timeUtil } from '../utils' - -const { getDate, getTime, getTimeZoneAbbreviation } = timeUtil @injectable() export default class AnnounceTrainingsJob implements BaseJob { public async run (context: GuildContext): Promise { if (context.robloxGroupId === null) { - return + throw new Error(`GuildContext with id '${context.id}' has no robloxGroupId`) } const trainingsInfoPanel = context.panels.resolve('trainingsInfoPanel') const trainingsPanel = context.panels.resolve('trainingsPanel') @@ -29,6 +26,18 @@ export default class AnnounceTrainingsJob implements BaseJob { 'GET', `v1/groups/${context.robloxGroupId}/trainings?sort=date` )).data + for (const training of trainings) { + const jobName = `training_${training.id}` + const job = cron.scheduledJobs[jobName] + if (typeof job === 'undefined') { + cron.scheduleJob( + jobName, + new Date(new Date(training.date).getTime() + 15 * 60_000), + this.run.bind(this, context) as JobCallback + ) + } + } + const authorIds = [...new Set(trainings.map(training => training.authorId))] const authors = await userService.getUsers(authorIds) @@ -37,10 +46,6 @@ export default class AnnounceTrainingsJob implements BaseJob { const embed = trainingsInfoPanel.embed.setColor(context.primaryColor ?? applicationConfig.defaultColor) const now = new Date() - if (typeof embed.data.description !== 'undefined') { - embed.setDescription(embed.data.description.replace(/{timezone}/g, getTimeZoneAbbreviation(now))) - } - const nextTraining = trainings.find(training => new Date(training.date) > now) embed.addFields([ { @@ -102,29 +107,10 @@ async function getTrainingsEmbed (groupId: number, trainings: Training[], author } function getTrainingMessage (training: Training, authors: GetUsers): string { - const now = new Date() - const today = now.getDate() const date = new Date(training.date) - const timeString = getTime(date) - const trainingDay = date.getDate() - const dateString = trainingDay === today ? 'Today' : trainingDay === today + 1 ? 'Tomorrow' : getDate(date) const author = authors.find(author => author.id === training.authorId) - const hourDifference = date.getHours() - now.getHours() - - let result = `:calendar_spiral: **${dateString}** at **${timeString}** hosted by ${author?.name ?? training.authorId}` - if (trainingDay === today && hourDifference <= 5) { - if (hourDifference <= 1) { - const minuteDifference = hourDifference * 60 + date.getMinutes() - now.getMinutes() - if (minuteDifference >= 0) { - result += `\n> :alarm_clock: Starts in: **${pluralize('minute', minuteDifference, true)}**` - } else { - result += `\n> :alarm_clock: Started **${pluralize('minute', -1 * minuteDifference, true)}** ago` - } - } else { - result += `\n> :alarm_clock: Starts in: **${pluralize('hour', hourDifference, true)}**` - } - } + let result = `:calendar_spiral: at hosted by ${author?.name ?? training.authorId}` if (training.notes !== null) { result += `\n> :notepad_spiral: ${training.notes}` @@ -133,15 +119,10 @@ function getTrainingMessage (training: Training, authors: GetUsers): string { } function getNextTrainingMessage (training: Training, authors: GetUsers): string { - const now = new Date() - const today = now.getDate() const date = new Date(training.date) - const timeString = getTime(date) - const trainingDay = date.getDate() - const dateString = trainingDay === today ? 'today' : trainingDay === today + 1 ? 'tomorrow' : getDate(date) const author = authors.find(author => author.id === training.authorId) - let result = `${training.type?.abbreviation ?? '(Deleted)'} **${dateString}** at **${timeString}** hosted by ${author?.name ?? training.authorId}` + let result = `${training.type?.abbreviation ?? '(Deleted)'} training on at hosted by ${author?.name ?? training.authorId}` if (training.notes !== null) { result += `\n${training.notes}` diff --git a/src/loaders/index.ts b/src/loaders/index.ts index d2f3c05d..67bbffe7 100644 --- a/src/loaders/index.ts +++ b/src/loaders/index.ts @@ -4,7 +4,7 @@ import type { BaseJob } from '../jobs' import { RewriteFrames } from '@sentry/integrations' import { constants } from '../utils' import container from '../configs/container' -import cron from 'node-cron' +import cron from 'node-schedule' import cronConfig from '../configs/cron' import dataSource from '../configs/data-source' @@ -29,7 +29,7 @@ export async function init (): Promise { const jobFactory = container.get<(jobName: string) => BaseJob>(TYPES.JobFactory) const healthCheckJobConfig = cronConfig.healthCheckJob const healthCheckJob = jobFactory(healthCheckJobConfig.name) - cron.schedule( + cron.scheduleJob( healthCheckJobConfig.expression, () => { Promise.resolve(healthCheckJob.run('main')).catch(console.error) diff --git a/src/services/group.ts b/src/services/group.ts index 24a64079..3478c09a 100644 --- a/src/services/group.ts +++ b/src/services/group.ts @@ -11,7 +11,7 @@ export type GetGroupStatus = GetGroup['shout'] export type GetGroupRole = GetGroupRoles['roles'][0] export interface ChangeMemberRole { oldRole: GetGroupRole, newRole: GetGroupRole } -const { getDate, getTime, getTimeZoneAbbreviation } = timeUtil +const { getDate } = timeUtil const { getAbbreviation } = util /// Move below API types to own package? @@ -147,10 +147,8 @@ export async function getTrainingEmbeds (trainings: Training[]): Promise user.id === training.authorId)?.name ?? training.authorId const date = new Date(training.date) - const readableDate = getDate(date) - const readableTime = getTime(date) - return `${training.id}. **${training.type?.abbreviation ?? '??'}** training on **${readableDate}** at **${readableTime} ${getTimeZoneAbbreviation(date)}**, hosted by **${username}**.` + return `${training.id}. **${training.type?.abbreviation ?? '??'}** training on at , hosted by **${username}**.` } export function groupTrainingsByType (trainings: Training[]): Record { diff --git a/src/services/index.ts b/src/services/index.ts index 0b8d1552..14490004 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -1,3 +1,4 @@ +export * from './group' export * as discordService from './discord' export * as groupService from './group' export * as userService from './user' diff --git a/src/structures/guild-context.ts b/src/structures/guild-context.ts index 9e3cbb76..f9c60a14 100644 --- a/src/structures/guild-context.ts +++ b/src/structures/guild-context.ts @@ -32,7 +32,7 @@ import BaseStructure from './base' import type { Guild as GuildEntity } from '../entities' import type { VerificationProvider } from '../utils/constants' import applicationConfig from '../configs/application' -import cron from 'node-cron' +import cron from 'node-schedule' import cronConfig from '../configs/cron' const { TYPES } = constants @@ -155,18 +155,15 @@ export default class GuildContext extends BaseStructure { } } - public init (): void { + public async init (): Promise { if (applicationConfig.apiEnabled === true) { - const announceTrainingsJobConfig = cronConfig.announceTrainingsJob - const announceTrainingsJob = this.jobFactory(announceTrainingsJobConfig.name) - cron.schedule(announceTrainingsJobConfig.expression, () => { - Promise.resolve(announceTrainingsJob.run(this)).catch(console.error) - }) + const announceTrainingsJob = this.jobFactory('announceTrainings') + await announceTrainingsJob.run(this) } const premiumMembersReportJobConfig = cronConfig.premiumMembersReportJob const premiumMembersReportJob = this.jobFactory(premiumMembersReportJobConfig.name) - cron.schedule(premiumMembersReportJobConfig.expression, () => { + cron.scheduleJob(premiumMembersReportJobConfig.expression, () => { Promise.resolve(premiumMembersReportJob.run(this)).catch(console.error) }) } diff --git a/src/utils/time.ts b/src/utils/time.ts index f40ab396..ad2357f4 100644 --- a/src/utils/time.ts +++ b/src/utils/time.ts @@ -53,12 +53,3 @@ export function getTimeInfo (timeString: string): TimeInfo { const minutes = parseInt(timeString.substring(timeString.indexOf(':') + 1, timeString.length)) return { hours, minutes } } - -export function getTimeZoneAbbreviation (date: Date): string { - return date.toLocaleTimeString('en-us', { hour12: false, hour: '2-digit', minute: '2-digit', timeZoneName: 'long' }) - .replace(/^(2[0-4]|[0-1][1-9]):[0-5]\d\s/, '') - .split(' ') - .filter(word => word !== 'Standard') - .map(word => word.charAt(0)) - .join('') -} diff --git a/yarn.lock b/yarn.lock index 8578d1f3..2fea8fc6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -430,10 +430,12 @@ __metadata: languageName: node linkType: hard -"@types/node-cron@npm:^3.0.8": - version: 3.0.8 - resolution: "@types/node-cron@npm:3.0.8" - checksum: e45f3fb8e0f3ed57bb6bc7b365a3fd50a93e64c0751381bb6db5d8e758f8b54b6af61bf68c72fc5be3b550c01f06b16dca56c05e9507cd3f345c39f44f250ec2 +"@types/node-schedule@npm:^2.1.0": + version: 2.1.0 + resolution: "@types/node-schedule@npm:2.1.0" + dependencies: + "@types/node": "*" + checksum: db782544dbdfaddc78488b463cdc474f76c55ebb1ffcec9fc9d3d67e02e23e0ff1d36e6bdca562097db650e6d8a1fac17c3dd03dc5564e5dbb54e5368019e55b languageName: node linkType: hard @@ -764,7 +766,7 @@ __metadata: "@types/common-tags": ^1.8.1 "@types/lodash": ^4.14.197 "@types/node": ^18.17.14 - "@types/node-cron": ^3.0.8 + "@types/node-schedule": ^2.1.0 "@types/pluralize": ^0.0.30 "@types/ws": ^8.5.5 "@typescript-eslint/eslint-plugin": ^6.5.0 @@ -782,7 +784,7 @@ __metadata: eslint-plugin-unicorn: ^48.0.1 inversify: ^6.0.1 lodash: ^4.17.21 - node-cron: ^3.0.2 + node-schedule: ^2.1.1 pg: ^8.11.3 pg-hstore: ^2.3.4 pluralize: ^8.0.0 @@ -1224,6 +1226,15 @@ __metadata: languageName: node linkType: hard +"cron-parser@npm:^4.2.0": + version: 4.9.0 + resolution: "cron-parser@npm:4.9.0" + dependencies: + luxon: ^3.2.1 + checksum: 3cf248fc5cae6c19ec7124962b1cd84b76f02b9bc4f58976b3bd07624db3ef10aaf1548efcc2d2dcdab0dad4f12029d640a55ecce05ea5e1596af9db585502cf + languageName: node + linkType: hard + "cross-spawn@npm:^7.0.2": version: 7.0.3 resolution: "cross-spawn@npm:7.0.3" @@ -2980,6 +2991,13 @@ __metadata: languageName: node linkType: hard +"long-timeout@npm:0.1.1": + version: 0.1.1 + resolution: "long-timeout@npm:0.1.1" + checksum: 48668e5362cb74c4b77a6b833d59f149b9bb9e99c5a5097609807e2597cd0920613b2a42b89bd0870848298be3691064d95599a04ae010023d07dba39932afa7 + languageName: node + linkType: hard + "lru-cache@npm:^6.0.0": version: 6.0.0 resolution: "lru-cache@npm:6.0.0" @@ -2996,6 +3014,13 @@ __metadata: languageName: node linkType: hard +"luxon@npm:^3.2.1": + version: 3.4.3 + resolution: "luxon@npm:3.4.3" + checksum: 3eade81506224d038ed24035a0cd0dd4887848d7eba9361dce9ad8ef81380596a68153240be3988721f9690c624fb449fcf8fd8c3fc0681a6a8496faf48e92a3 + languageName: node + linkType: hard + "magic-bytes.js@npm:^1.0.15": version: 1.0.15 resolution: "magic-bytes.js@npm:1.0.15" @@ -3249,15 +3274,6 @@ __metadata: languageName: node linkType: hard -"node-cron@npm:^3.0.2": - version: 3.0.2 - resolution: "node-cron@npm:3.0.2" - dependencies: - uuid: 8.3.2 - checksum: dd21585c0d4069a0752022dad9b8380a4393c4783ec78355ffa99ff32b018c3743a35d4ebf9d7c7863949e94e302b440f58c884eb4960e71c7260d817e2d3f25 - languageName: node - linkType: hard - "node-gyp-build@npm:^4.2.0": version: 4.2.3 resolution: "node-gyp-build@npm:4.2.3" @@ -3289,6 +3305,17 @@ __metadata: languageName: node linkType: hard +"node-schedule@npm:^2.1.1": + version: 2.1.1 + resolution: "node-schedule@npm:2.1.1" + dependencies: + cron-parser: ^4.2.0 + long-timeout: 0.1.1 + sorted-array-functions: ^1.3.0 + checksum: 6a8822b16fb024277c42efe710bdb35b6f1f6ab3a2f826283640511247d693f34ebd5ddf2863cd91609e7f323574e36c81cd2084dc204fa521f931380f0f963f + languageName: node + linkType: hard + "nopt@npm:^5.0.0": version: 5.0.0 resolution: "nopt@npm:5.0.0" @@ -4147,6 +4174,13 @@ __metadata: languageName: node linkType: hard +"sorted-array-functions@npm:^1.3.0": + version: 1.3.0 + resolution: "sorted-array-functions@npm:1.3.0" + checksum: 673fd39ca3b6c92644d4483eac1700bb7d7555713a536822a7522a35af559bef3e72f10d89356b75042dc394cd7c2e2ab6f40024385218ec3c85bb7335032857 + languageName: node + linkType: hard + "spdx-correct@npm:^3.0.0": version: 3.1.1 resolution: "spdx-correct@npm:3.1.1" @@ -4762,15 +4796,6 @@ __metadata: languageName: node linkType: hard -"uuid@npm:8.3.2": - version: 8.3.2 - resolution: "uuid@npm:8.3.2" - bin: - uuid: dist/bin/uuid - checksum: 5575a8a75c13120e2f10e6ddc801b2c7ed7d8f3c8ac22c7ed0c7b2ba6383ec0abda88c905085d630e251719e0777045ae3236f04c812184b7c765f63a70e58df - languageName: node - linkType: hard - "uuid@npm:^9.0.0": version: 9.0.0 resolution: "uuid@npm:9.0.0"