diff --git a/.env.example b/.env.example index 1863ec7..694620c 100644 --- a/.env.example +++ b/.env.example @@ -22,3 +22,14 @@ ECLASS_DRIVE_URL="https://sharepoint.com" # Main Guild IDS used for registrating some specific commands ("/lxp", "/recordings", "/eclass"). Coma-separated. MAIN_GUILD_IDS="" + +# +-----------------------+ +# | Google Sheets | +# +-----------------------+ + +# Service Account email address (xxx@xxx.iam.gserviceaccount.com) +GOOGLE_SHEET_SVC_EMAIL= +# Service Account private key, with newlines replaced by "\n" +GOOGLE_SHEET_SVC_PRIVATE_KEY= +# Google Sheet ID from the URL +GOOGLE_SHEET_ID= diff --git a/config/commands/admin.ts b/config/commands/admin.ts index 5da37b6..9f7b768 100644 --- a/config/commands/admin.ts +++ b/config/commands/admin.ts @@ -6,6 +6,7 @@ import { TimestampStyles, userMention, } from 'discord.js'; +import { ValidationError } from '@/app/lib/structures/SubjectsManager'; import { LogStatuses } from '@/types/database'; import { timeFormat } from '@/utils'; import { settings } from '../settings'; @@ -442,3 +443,31 @@ export const setup = { lineWithoutValue: '**{name}** : Aucune valeur associée', }, } as const; + +export const subjects = { + descriptions: { + name: 'subjects', + command: 'Gérer les matières définies sur le Google Sheet.', + subcommands: { + test: 'Vérifie que le Google Sheet est valide.', + refresh: 'Raffraichir les matières dans le bot en re-téléchargeant les données du Google Sheet.', + }, + }, + messages: { + refreshSuccess: 'Les matières ont bien été raffraichies !', + validationSuccess: 'La feuille est valide !', + validationErrors: 'Il y a des erreurs dans la feuille :\n{errors}', + errorLine: '- **Ligne {row} :** {error}', + errors: { + [ValidationError.DuplicatedIdentifier]: "L'idenfiant choisit n'est pas unique.", + [ValidationError.InvalidEmoji]: "L'emoji n'est pas valide.", + [ValidationError.DuplicatedClassCode]: "Le code de classe n'est pas unique.", + [ValidationError.InvalidTeachingUnit]: "L'unité d'enseignement n'est pas valide. Vérifie que ce soit bien une des valeurs du menu déroulant.", + [ValidationError.InvalidSchoolYear]: "L'année scolaire n'est pas valide. Vérifie que ce soit bien une des valeurs du menu déroulant.", + [ValidationError.InvalidGuild]: "Le serveur discord n'existe pas.", + [ValidationError.InvalidTextChannel]: "Le salon de texte n'existe pas.", + [ValidationError.InvalidTextDocsChannel]: "Le salon de texte pour les documents n'existe pas.", + [ValidationError.InvalidTextVoiceChannel]: "Le salon vocal n'existe pas.", + }, + }, +} as const; diff --git a/config/commands/professors.ts b/config/commands/professors.ts index fac40af..3e5fb6b 100644 --- a/config/commands/professors.ts +++ b/config/commands/professors.ts @@ -8,7 +8,7 @@ import { userMention, } from 'discord.js'; import { SchoolYear } from '@/types'; -import type { SubjectBase } from '@/types/database'; +import type { SubjectEntry } from '@/types/database'; import { EclassPlace, EclassStatus } from '@/types/database'; import { timeFormat } from '@/utils'; @@ -89,7 +89,7 @@ export const eclass = { }, recordedValues: ['Non :x:', 'Oui :white_check_mark:'], where: ({ place, placeInformation, subject }: { - place: EclassPlace; placeInformation: string | null; subject: SubjectBase; + place: EclassPlace; placeInformation: string | null; subject: SubjectEntry; }): string => { switch (place) { case EclassPlace.Discord: diff --git a/package-lock.json b/package-lock.json index 34f3588..3f574b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,8 @@ "discord.js": "^14.14.1", "dotenv": "^16.4.2", "fuzzy-search": "^3.2.1", + "google-auth-library": "^9.6.3", + "google-spreadsheet": "^4.1.1", "lodash": "^4.17.21", "marked": "^12.0.0", "mathjax": "^3.2.2", @@ -1813,6 +1815,33 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/bignumber.js": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.1.2.tgz", + "integrity": "sha512-2/mKyZH9K85bzOEfhXDBFZTGd1CTs+5IHpeFQo9luiBG7hghdC851Pj2WAhb6E3R6b9tZj/XKhbg4fum+Kepug==", + "engines": { + "node": "*" + } + }, "node_modules/brace-expansion": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", @@ -1874,6 +1903,11 @@ "node": ">=16.20.1" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -2308,6 +2342,14 @@ "url": "https://dotenvx.com" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/electron-to-chromium": { "version": "1.4.665", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.665.tgz", @@ -2958,6 +3000,11 @@ "node": ">=0.10.0" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3200,6 +3247,73 @@ "node": ">=10" } }, + "node_modules/gaxios": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.2.0.tgz", + "integrity": "sha512-H6+bHeoEAU5D6XNc6mPKeN5dLZqEDs9Gpk6I+SZBEzK5So58JVrHPmevNi35fRl1J9Y5TaeLW0kYx3pCJ1U2mQ==", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/gaxios/node_modules/agent-base": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.0.tgz", + "integrity": "sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg==", + "dependencies": { + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/gaxios/node_modules/https-proxy-agent": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.3.tgz", + "integrity": "sha512-kCnwztfX0KZJSLOBrcL0emLeFako55NWMovvyPP2AjsghNk9RB1yjSI+jVumPHYZsNXegNoqupSW9IY3afSH8w==", + "dependencies": { + "agent-base": "^7.0.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/gcp-metadata": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-5.3.0.tgz", + "integrity": "sha512-FNTkdNEnBdlqF2oatizolQqNANMrcqJt6AAYt99B3y1aLLC8Hc5IOBb+ZnnzllodEEf6xMBp6wRcBbc16fa65w==", + "optional": true, + "peer": true, + "dependencies": { + "gaxios": "^5.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/gcp-metadata/node_modules/gaxios": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-5.1.3.tgz", + "integrity": "sha512-95hVgBRgEIRQQQHIbnxBXeHbW4TqFk4ZDJW7wmVtvYar72FdhRIo1UGOLS2eRAKCPEdPBWu+M7+A33D9CdX9rA==", + "optional": true, + "peer": true, + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^5.0.0", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/get-intrinsic": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", @@ -3349,6 +3463,51 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/google-auth-library": { + "version": "9.6.3", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.6.3.tgz", + "integrity": "sha512-4CacM29MLC2eT9Cey5GDVK4Q8t+MMp8+OEdOaqD9MG6b0dOyLORaaeJMPQ7EESVgm/+z5EKYyFLxgzBJlJgyHQ==", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^6.1.1", + "gcp-metadata": "^6.1.0", + "gtoken": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-auth-library/node_modules/gcp-metadata": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.0.tgz", + "integrity": "sha512-Jh/AIwwgaxan+7ZUUmRLCjtchyDiqh4KjBJ5tW3plBZb5iL/BPcso8A5DlzeD9qlw0duCamnNdpFjxwaT0KyKg==", + "dependencies": { + "gaxios": "^6.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-spreadsheet": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/google-spreadsheet/-/google-spreadsheet-4.1.1.tgz", + "integrity": "sha512-Npk/xAMTgxEt/m/X9EXIqdY6CEYGiqUHrSuiLnNSKli5H+wiOQLSLsnfMxcdNPH6aSh6GttZm6QJhrnsxjwpZQ==", + "dependencies": { + "axios": "^1.4.0", + "lodash": "^4.17.21" + }, + "peerDependencies": { + "google-auth-library": "^8.8.0 || ^9.0.0" + }, + "peerDependenciesMeta": { + "google-auth-library": { + "optional": true + } + } + }, "node_modules/gopd": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", @@ -3373,6 +3532,18 @@ "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true }, + "node_modules/gtoken": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", + "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", + "dependencies": { + "gaxios": "^6.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/has-bigints": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", @@ -3754,6 +3925,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-string": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", @@ -3853,6 +4035,14 @@ "node": ">=6" } }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -3889,6 +4079,25 @@ "json5": "lib/cli.js" } }, + "node_modules/jwa": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz", + "integrity": "sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==", + "dependencies": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", + "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", + "dependencies": { + "jwa": "^2.0.0", + "safe-buffer": "^5.0.1" + } + }, "node_modules/kareem": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.5.1.tgz", diff --git a/package.json b/package.json index 2cf2aa4..58f348c 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,8 @@ "discord.js": "^14.14.1", "dotenv": "^16.4.2", "fuzzy-search": "^3.2.1", + "google-auth-library": "^9.6.3", + "google-spreadsheet": "^4.1.1", "lodash": "^4.17.21", "marked": "^12.0.0", "mathjax": "^3.2.2", diff --git a/src/commands/Admin/subjects.ts b/src/commands/Admin/subjects.ts new file mode 100644 index 0000000..277facf --- /dev/null +++ b/src/commands/Admin/subjects.ts @@ -0,0 +1,73 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { PermissionsBitField } from 'discord.js'; +import pupa from 'pupa'; +import { subjects as config } from '@/config/commands/admin'; +import { HorizonSubcommand } from '@/structures/commands/HorizonSubcommand'; + +@ApplyOptions({ + ...config, + subcommands: [ + { name: 'test', chatInputRun: 'test' }, + { name: 'refresh', chatInputRun: 'refresh' }, + ], +}) +export class SetupCommand extends HorizonSubcommand { + public override registerApplicationCommands(registry: HorizonSubcommand.Registry): void { + registry.registerChatInputCommand( + command => command + .setName(this.descriptions.name) + .setDescription(this.descriptions.command) + .setDMPermission(false) + .setDefaultMemberPermissions(PermissionsBitField.Flags.ModerateMembers) + .addSubcommand( + subcommand => subcommand + .setName('test') + .setDescription(this.descriptions.subcommands.test), + ) + .addSubcommand( + subcommand => subcommand + .setName('refresh') + .setDescription(this.descriptions.subcommands.refresh), + ), + ); + } + + public async test(interaction: HorizonSubcommand.ChatInputInteraction<'cached'>): Promise { + await interaction.deferReply({ ephemeral: true }); + + const errors = await this.container.client.subjectsManager.validate(); + if (errors.length === 0) { + await interaction.followUp(this.messages.validationSuccess); + } else { + await interaction.followUp(pupa(this.messages.validationErrors, { + errors: errors + .sort((a, b) => a.row - b.row) + .map(err => pupa(this.messages.errorLine, { + row: err.row, + error: this.messages.errors[err.error], + })) + .join('\n'), + })); + } + } + + public async refresh(interaction: HorizonSubcommand.ChatInputInteraction<'cached'>): Promise { + await interaction.deferReply({ ephemeral: true }); + + const errors = await this.container.client.subjectsManager.validate(); + if (errors.length === 0) { + await this.container.client.subjectsManager.refresh(); + await interaction.followUp(this.messages.refreshSuccess); + } else { + await interaction.followUp(pupa(this.messages.validationErrors, { + errors: errors + .sort((a, b) => a.row - b.row) + .map(err => pupa(this.messages.errorLine, { + row: err.row, + error: this.messages.errors[err.error], + })) + .join('\n'), + })); + } + } +} diff --git a/src/commands/General/recordings.ts b/src/commands/General/recordings.ts index 578afe9..539b62a 100644 --- a/src/commands/General/recordings.ts +++ b/src/commands/General/recordings.ts @@ -8,7 +8,7 @@ import { messages } from '@/config/messages'; import { settings } from '@/config/settings'; import { Eclass } from '@/models/eclass'; import { HorizonCommand } from '@/structures/commands/HorizonCommand'; -import type { EclassPopulatedDocument } from '@/types/database'; +import type { EclassDocument } from '@/types/database'; @ApplyOptions(config) export class RecordingsCommand extends HorizonCommand { @@ -22,7 +22,7 @@ export class RecordingsCommand extends HorizonCommand { } public async chatInputRun(interaction: HorizonCommand.ChatInputInteraction): Promise { - const classes: EclassPopulatedDocument[] = await Eclass.find({ recordLinks: { $not: { $size: 0 } } }); + const classes: EclassDocument[] = await Eclass.find({ recordLinks: { $not: { $size: 0 } } }); if (classes.length === 0) { await interaction.reply(this.messages.noRecords); return; diff --git a/src/commands/Professors/eclass.ts b/src/commands/Professors/eclass.ts index 8dca34e..8622b37 100644 --- a/src/commands/Professors/eclass.ts +++ b/src/commands/Professors/eclass.ts @@ -24,14 +24,13 @@ import { IsEprofOrStaff, ValidateEclassArgument } from '@/decorators'; import * as EclassManager from '@/eclasses/EclassManager'; import * as EclassMessagesManager from '@/eclasses/EclassMessagesManager'; import { Eclass } from '@/models/eclass'; -import { Subject } from '@/models/subject'; import { resolveDate, resolveDuration } from '@/resolvers'; import { PaginatedMessageEmbedFields } from '@/structures/PaginatedMessageEmbedFields'; import { HorizonSubcommand } from '@/structures/commands/HorizonSubcommand'; import type { SchoolYear } from '@/types'; import { + EclassDocument, EclassPlace, - EclassPopulatedDocument, EclassStatus, EclassStep, } from '@/types/database'; @@ -81,7 +80,6 @@ enum OptionRecordChoiceChoices { enum Options { // eslint-disable-next-line @typescript-eslint/no-shadow SchoolYear = 'promo', - // eslint-disable-next-line @typescript-eslint/no-shadow Subject = 'matière', Topic = 'thème', Professor = 'professeur', @@ -359,7 +357,7 @@ export class EclassCommand extends HorizonSubcommand { return; } - const subject = await Subject.findOne({ classCode: rawSubject }); + const subject = this.container.client.subjectsManager.getByClassCode(rawSubject); if (!subject) { await interaction.reply({ content: this.messages.invalidSubject, ephemeral: true }); return; @@ -442,7 +440,7 @@ export class EclassCommand extends HorizonSubcommand { @ValidateEclassArgument({ statusIn: [EclassStatus.Planned] }) @IsEprofOrStaff({ isOriginalEprof: true }) - public async start(interaction: Interaction, eclass: EclassPopulatedDocument): Promise { + public async start(interaction: Interaction, eclass: EclassDocument): Promise { // Start the class & confirm. await EclassManager.startClass(eclass); await interaction.reply(this.messages.successfullyStarted); @@ -450,7 +448,7 @@ export class EclassCommand extends HorizonSubcommand { @ValidateEclassArgument({ statusIn: [EclassStatus.Planned] }) @IsEprofOrStaff({ isOriginalEprof: true }) - public async edit(interaction: Interaction, eclass: EclassPopulatedDocument): Promise { + public async edit(interaction: Interaction, eclass: EclassDocument): Promise { const shouldPing = interaction.options.getBoolean(Options.ShouldPing) ?? false; const topic = interaction.options.getString(Options.Topic); @@ -625,7 +623,7 @@ export class EclassCommand extends HorizonSubcommand { @ValidateEclassArgument({ statusIn: [EclassStatus.Planned, EclassStatus.InProgress] }) @IsEprofOrStaff({ isOriginalEprof: true }) - public async cancel(interaction: Interaction, eclass: EclassPopulatedDocument): Promise { + public async cancel(interaction: Interaction, eclass: EclassDocument): Promise { await interaction.reply({ content: this.messages.confirmCancel, components: [yesNoButtonsRow], @@ -653,7 +651,7 @@ export class EclassCommand extends HorizonSubcommand { @ValidateEclassArgument({ statusIn: [EclassStatus.InProgress] }) @IsEprofOrStaff({ isOriginalEprof: true }) - public async finish(interaction: Interaction, eclass: EclassPopulatedDocument): Promise { + public async finish(interaction: Interaction, eclass: EclassDocument): Promise { // Finish the class & confirm. await EclassManager.finishClass(eclass); await interaction.reply(this.messages.successfullyFinished); @@ -661,7 +659,7 @@ export class EclassCommand extends HorizonSubcommand { @ValidateEclassArgument() @IsEprofOrStaff({ isOriginalEprof: true }) - public async record(interaction: Interaction, eclass: EclassPopulatedDocument): Promise { + public async record(interaction: Interaction, eclass: EclassDocument): Promise { const action = interaction.options.getString(Options.Choice, true) as OptionRecordChoiceChoices; let link: Result | null = null; @@ -718,11 +716,11 @@ export class EclassCommand extends HorizonSubcommand { } @ValidateEclassArgument() - public async info(interaction: Interaction, eclass: EclassPopulatedDocument): Promise { + public async info(interaction: Interaction, eclass: EclassDocument): Promise { const payload = { ...eclass.toJSON(), ...eclass.normalizeDates(true), - subject: eclass.subject.toJSON(), + subject: eclass.subject, status: capitalize(this.messages.rawStatuses[eclass.status]), where: this.messages.where(eclass), recorded: oneLine` @@ -753,9 +751,9 @@ export class EclassCommand extends HorizonSubcommand { public async list(interaction: Interaction): Promise { // TODO: Add filter by date (before/after) // TODO: Add ability to combine same filters with each-other - const eclasses: EclassPopulatedDocument[] = await Eclass.find({ guildId: interaction.guild.id }); + const eclasses: EclassDocument[] = await Eclass.find({ guildId: interaction.guild.id }); - const filters: Array<(eclass: EclassPopulatedDocument) => boolean> = []; + const filters: Array<(eclass: EclassDocument) => boolean> = []; const filterDescriptions: string[] = []; const schoolYear = interaction.options.getString(Options.SchoolYear) as SchoolYear | null; diff --git a/src/interaction-handlers/autocomplete/subjectsAutocomplete.ts b/src/interaction-handlers/autocomplete/subjectsAutocomplete.ts index eb4f748..63ed6cd 100644 --- a/src/interaction-handlers/autocomplete/subjectsAutocomplete.ts +++ b/src/interaction-handlers/autocomplete/subjectsAutocomplete.ts @@ -4,14 +4,13 @@ import type { Option } from '@sapphire/framework'; import { InteractionHandler, InteractionHandlerTypes } from '@sapphire/framework'; import type { ApplicationCommandOptionChoiceData, AutocompleteInteraction } from 'discord.js'; import FuzzySearch from 'fuzzy-search'; -import { Subject } from '@/models/subject'; -import type { SubjectDocument } from '@/types/database'; +import type { SubjectEntry } from '@/types/database'; @ApplyOptions({ interactionHandlerType: InteractionHandlerTypes.Autocomplete, }) export class SubjectsAutocompleteHandler extends InteractionHandler { - private _cache: SubjectDocument[] = []; + private _cache: SubjectEntry[] = []; private _cacheDate: Date | null = null; public override async run( @@ -21,14 +20,14 @@ export class SubjectsAutocompleteHandler extends InteractionHandler { return interaction.respond(result.slice(0, AutoCompleteLimits.MaximumAmountOfOptions)); } - public override async parse( + public override parse( interaction: AutocompleteInteraction, - ): Promise> { + ): Option { const focusedOption = interaction.options.getFocused(true); if (!focusedOption.name.includes('matière')) return this.none(); - await this._updateCache(); + this._updateCache(); const fuzzy = new FuzzySearch(this._cache, ['name', 'nameEnglish', 'slug', 'classCode'], { sort: true }); const results = fuzzy.search(focusedOption.value); @@ -38,9 +37,9 @@ export class SubjectsAutocompleteHandler extends InteractionHandler { }))); } - private async _updateCache(): Promise { + private _updateCache(): void { if (!this._cacheDate || this._cacheDate.getTime() < Date.now() - 60_000) { - this._cache = await Subject.find(); + this._cache = this.container.client.subjectsManager.rows.filter(row => row.active); this._cacheDate = new Date(); } } diff --git a/src/lib/database/migrate.ts b/src/lib/database/migrate.ts index 82cc460..89d1759 100644 --- a/src/lib/database/migrate.ts +++ b/src/lib/database/migrate.ts @@ -19,13 +19,21 @@ async function migrate(): Promise { console.log('Starting migration...'); // Tags - // rename the "tags" collection to "faq" + // - rename the "tags" collection to "faq" console.log('Tags: updating...'); - await db.collection('tags').rename('faq'); - console.log('Tags: renamed collection\n\n'); + // Eclass + // - rename "subject" to "subjectId" + // - make "subjectId" a string rather than an ObjectId + console.log('Eclass: updating...'); + await db.collection('eclasses').updateMany({}, [ + { $set: { subjectId: { $toString: '$subject' } } }, + { $unset: 'subject' }, + ]); + console.log('Eclass: updated collection\n\n'); + console.log('Migration complete'); } diff --git a/src/lib/eclasses/EclassManager.ts b/src/lib/eclasses/EclassManager.ts index a9ee460..44bedae 100644 --- a/src/lib/eclasses/EclassManager.ts +++ b/src/lib/eclasses/EclassManager.ts @@ -16,7 +16,7 @@ import { Eclass } from '@/models/eclass'; import { EclassParticipation } from '@/models/eclassParticipation'; import type { EclassCreationOptions, EclassEmbedOptions } from '@/types'; import { SchoolYear } from '@/types'; -import type { EclassPopulatedDocument } from '@/types/database'; +import type { EclassDocument } from '@/types/database'; import { ConfigEntriesChannels, ConfigEntriesRoles, @@ -25,6 +25,7 @@ import { EclassStep, } from '@/types/database'; import { + getEmojiImage, massSend, noop, nullop, @@ -52,7 +53,7 @@ export function createAnnouncementEmbed(options: EclassEmbedOptions): EmbedBuild .setColor(settings.colors.green) .setTitle(pupa(texts.title, options)) .setDescription(pupa(texts.description, options)) - .setThumbnail(options.subject.emojiImage) + .setThumbnail(getEmojiImage(options.subject.emoji)) .addFields([ { name: texts.date, @@ -69,14 +70,14 @@ export function createAnnouncementEmbed(options: EclassEmbedOptions): EmbedBuild } export function getRoleNameForClass( - { formattedDate, subject, topic }: Pick & { formattedDate: string }, + { formattedDate, subject, topic }: Pick & { formattedDate: string }, ): string { const baseRoleName = pupa(settings.configuration.eclassRoleFormat, { subject, topic: '{topic}', formattedDate }); const remainingLength = RoleLimits.MaximumNameLength - baseRoleName.length + '{topic}'.length; return pupa(baseRoleName, { topic: trimText(topic, remainingLength) }); } -export function getRoleNameForFinishedClass(eclass: EclassPopulatedDocument): string { +export function getRoleNameForFinishedClass(eclass: EclassDocument): string { const formattedDate = dayjs(eclass.end).format(settings.configuration.shortDayFormat); const baseRoleName = pupa(settings.configuration.eclassRoleFormatFinished, { subject: eclass.subject, topic: '{topic}', formattedDate }); const remainingLength = RoleLimits.MaximumNameLength - baseRoleName.length + '{topic}'.length; @@ -88,7 +89,7 @@ export async function createClass( { date, subject, topic, duration, professor, isRecorded, targetRole, place, placeInformation, }: EclassCreationOptions, -): Promise> { +): Promise> { // Prepare the date const formattedDate = dayjs(date).format(settings.configuration.dateFormat); @@ -159,7 +160,7 @@ export async function createClass( classId, guildId: classChannel.guild.id, topic, - subject, + subject: subject.id, date: date.getTime(), duration, professorId: professor.id, @@ -184,7 +185,7 @@ export async function createClass( return Result.ok(eclass); } -export async function startClass(eclass: EclassPopulatedDocument): Promise { +export async function startClass(eclass: EclassDocument): Promise { container.client.currentlyRunningEclassIds.add(eclass.classId); // Fetch the announcement message @@ -242,7 +243,7 @@ export async function startClass(eclass: EclassPopulatedDocument): Promise container.logger.debug(`[e-class:${eclass.classId}] Started class.`); } -export async function finishClass(eclass: EclassPopulatedDocument): Promise { +export async function finishClass(eclass: EclassDocument): Promise { container.client.currentlyRunningEclassIds.delete(eclass.classId); // Update participations @@ -302,7 +303,7 @@ export async function finishClass(eclass: EclassPopulatedDocument): Promise { +export async function cleanupClass(eclass: EclassDocument): Promise { // Remove the associated role await container.client .guilds.cache.get(eclass.guildId) @@ -314,7 +315,7 @@ export async function cleanupClass(eclass: EclassPopulatedDocument): Promise { +export async function cancelClass(eclass: EclassDocument): Promise { // Fetch the announcement message const announcementChannel = await container.client.configManager.get(eclass.announcementChannelId, eclass.guildId); if (!announcementChannel) @@ -348,7 +349,7 @@ export async function cancelClass(eclass: EclassPopulatedDocument): Promise { @@ -397,7 +398,7 @@ export async function addRecordLink( container.logger.debug(`[e-class:${eclass.classId}] Added record link.`); } -export async function removeRecordLink(eclass: EclassPopulatedDocument, recordLink: string): Promise { +export async function removeRecordLink(eclass: EclassDocument, recordLink: string): Promise { // Fetch the announcement message const announcementChannel = await container.client.configManager.get(eclass.announcementChannelId, eclass.guildId); if (!announcementChannel) @@ -431,7 +432,7 @@ export async function removeRecordLink(eclass: EclassPopulatedDocument, recordLi container.logger.debug(`[e-class:${eclass.classId}] Removed record link.`); } -export async function prepareClass(eclass: EclassPopulatedDocument): Promise { +export async function prepareClass(eclass: EclassDocument): Promise { // Create initial participations if (eclass.subject.voiceChannelId) { const voiceChannel = container.client @@ -497,7 +498,7 @@ export async function prepareClass(eclass: EclassPopulatedDocument): Promise { +export async function subscribeMember(member: GuildMember, eclass: EclassDocument): Promise { if (eclass.status !== EclassStatus.Planned) return; if (eclass.professorId === member.id) @@ -518,7 +519,7 @@ export async function subscribeMember(member: GuildMember, eclass: EclassPopulat container.logger.debug(`[e-class:${eclass.classId}] Subscribed member ${member.id} (${member.user.tag}).`); } -export async function unsubscribeMember(member: GuildMember, eclass: EclassPopulatedDocument): Promise { +export async function unsubscribeMember(member: GuildMember, eclass: EclassDocument): Promise { if (eclass.status !== EclassStatus.Planned) return; @@ -542,12 +543,12 @@ export function validateDateSpan(date: Date): boolean { } export async function checkOverlaps( - data: Partial> & Pick, + data: Partial> & Pick, ): Promise> { const myStart = data.date; const myEnd = new Date(myStart.getTime() + data.duration); - const allOverlapping = await Eclass.find({ + const allOverlapping = await Eclass.find({ classId: { $ne: data.classId }, status: EclassStatus.Planned, date: { $lte: myEnd }, diff --git a/src/lib/eclasses/EclassMessagesManager.ts b/src/lib/eclasses/EclassMessagesManager.ts index df21773..72c47f5 100644 --- a/src/lib/eclasses/EclassMessagesManager.ts +++ b/src/lib/eclasses/EclassMessagesManager.ts @@ -7,7 +7,7 @@ import pupa from 'pupa'; import { messages } from '@/config/messages'; import { Eclass } from '@/models/eclass'; import type { GuildMessage } from '@/types'; -import type { EclassDocument, EclassPopulatedDocument } from '@/types/database'; +import type { EclassDocument } from '@/types/database'; import { ConfigEntriesChannels, EclassStatus } from '@/types/database'; import { capitalize, @@ -87,7 +87,7 @@ function generateUpcomingClassesMessage(upcomingClasses: EclassDocument[]): stri async function updateUpcomingClasses( channel: GuildTextBasedChannel, - upcomingClasses: EclassPopulatedDocument[], + upcomingClasses: EclassDocument[], ): Promise { const content = generateUpcomingClassesMessage(upcomingClasses); const chunks = splitText(content); @@ -104,18 +104,18 @@ async function updateUpcomingClasses( } if (i < allBotMessages.length) - allBotMessages.slice(i).map(async msg => await msg.delete()); + await Promise.all(allBotMessages.slice(i).map(async msg => await msg.delete())); } export async function updateUpcomingClassesForGuild( guildId: string, - allUpcomingClasses?: EclassPopulatedDocument[], + allUpcomingClasses?: EclassDocument[], ): Promise { const channel = await container.client.configManager.get(ConfigEntriesChannels.WeekUpcomingClasses, guildId); if (!channel) return; - let upcomingClasses: EclassPopulatedDocument[]; + let upcomingClasses: EclassDocument[]; if (isNullish(allUpcomingClasses)) { upcomingClasses = await Eclass.find({ $and: [ diff --git a/src/lib/models/eclass.ts b/src/lib/models/eclass.ts index 5fbed6e..d34f8ef 100644 --- a/src/lib/models/eclass.ts +++ b/src/lib/models/eclass.ts @@ -31,11 +31,9 @@ const EclassSchema = new Schema({ type: String, required: true, }, - subject: { - type: Schema.Types.ObjectId, - ref: 'Subject', + subjectId: { + type: String, required: true, - autopopulate: true, }, date: { type: Date, @@ -128,6 +126,14 @@ EclassSchema.methods.getStatus = function (this: EclassDocument): string { return eclassConfig.messages.statuses[this.status]; }; +EclassSchema.post('init', function (this: EclassDocument) { + const subject = container.client.subjectsManager.getById(this.subjectId); + if (!subject) + throw new Error(`Could not find [eclass:${this.classId}] subject (${this.subjectId}).`); + + this.subject = subject; +}); + EclassSchema.plugin(autopopulate); export const Eclass = model('Eclass', EclassSchema); diff --git a/src/lib/models/subject.ts b/src/lib/models/subject.ts deleted file mode 100644 index 646761e..0000000 --- a/src/lib/models/subject.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { model, Schema } from 'mongoose'; -import { SchoolYear, TeachingUnit } from '@/types'; -import type { SubjectDocument, SubjectModel } from '@/types/database'; - -const SubjectSchema = new Schema({ - name: { - type: String, - required: true, - }, - nameEnglish: { - type: String, - required: false, - }, - emoji: { - type: String, - required: true, - }, - emojiImage: { - type: String, - required: false, - }, - classCode: { - type: String, - required: true, - index: true, - }, - moodleLink: { - type: String, - required: true, - }, - docsLink: { - type: String, - required: false, - }, - teachingUnit: { - type: String, - enum: TeachingUnit, - required: true, - }, - schoolYear: { - type: String, - enum: SchoolYear, - required: true, - }, - textChannelId: { - type: String, - required: true, - }, - textDocsChannelId: { - type: String, - }, - voiceChannelId: { - type: String, - }, -}, { timestamps: true }); - -export const Subject = model('Subject', SubjectSchema); diff --git a/src/lib/structures/HorizonClient.ts b/src/lib/structures/HorizonClient.ts index 9249678..ecc8c5c 100644 --- a/src/lib/structures/HorizonClient.ts +++ b/src/lib/structures/HorizonClient.ts @@ -16,9 +16,11 @@ import { TaskStore } from '@/structures/tasks/TaskStore'; import type { DiscordLogType, LogStatuses as LogStatusesEnum, ReminderDocument } from '@/types/database'; import { EclassStatus } from '@/types/database'; import { nullop } from '@/utils'; +import { SubjectsManager } from './SubjectsManager'; export class HorizonClient extends SapphireClient { configManager: ConfigurationManager; + subjectsManager: SubjectsManager; remainingCompilerApiCredits = 0; reactionRolesIds = new Set(); @@ -113,6 +115,9 @@ export class HorizonClient extends SapphireClient { private async _startCaches(): Promise { await this._loadCompilerApiCredits(); + this.logger.info('[Offline Cache] Caching subjects...'); + await this._cacheSubjects(); + this.logger.info('[Offline Cache] Caching reaction roles...'); await this._cacheReactionRoles(); @@ -144,6 +149,11 @@ export class HorizonClient extends SapphireClient { this.logger.info(`[Compiler API] ${200 - this.remainingCompilerApiCredits}/200 credits used (${this.remainingCompilerApiCredits} remaining).`); } + private async _cacheSubjects(): Promise { + this.subjectsManager = new SubjectsManager(process.env.GOOGLE_SHEET_ID!); + await this.subjectsManager.refresh(); + } + private async _cacheReactionRoles(): Promise { this.reactionRolesIds.clear(); const reactionRoles = await ReactionRole.find().catch(nullop); diff --git a/src/lib/structures/SubjectsManager.ts b/src/lib/structures/SubjectsManager.ts new file mode 100644 index 0000000..6e4ac34 --- /dev/null +++ b/src/lib/structures/SubjectsManager.ts @@ -0,0 +1,141 @@ +import { container } from '@sapphire/framework'; +import { JWT } from 'google-auth-library'; +import type { GoogleSpreadsheetRow, GoogleSpreadsheetWorksheet } from 'google-spreadsheet'; +import { GoogleSpreadsheet } from 'google-spreadsheet'; +import * as nodeEmoji from 'node-emoji'; +import { SchoolYear, TeachingUnit } from '../types'; +import type { SubjectEntry } from '../types/database'; + +export enum ValidationError { + DuplicatedIdentifier = 'duplicated-identifier', + InvalidEmoji = 'invalid-emoji', + DuplicatedClassCode = 'duplicated-class-code', + InvalidTeachingUnit = 'invalid-teaching-unit', + InvalidSchoolYear = 'invalid-school-year', + InvalidGuild = 'invalid-guild', + InvalidTextChannel = 'invalid-text-channel', + InvalidTextDocsChannel = 'invalid-text-docs-channel', + InvalidTextVoiceChannel = 'invalid-text-voice-channel', +} + +interface ValidationErrorData { + row: number; + error: ValidationError; +} + +interface RawSubjectRow { + id: string; + active: 'FALSE' | 'TRUE'; + name: string; + emoji: string; + classCode: string; + teachingUnit: TeachingUnit; + schoolYear: SchoolYear; + guildId: string; + textChannelId: string; + textDocsChannelId: string; + voiceChannelId: string; +} + +export class SubjectsManager { + private readonly _doc: GoogleSpreadsheet; + private _sheet: GoogleSpreadsheetWorksheet; + private _rawRows: Array> = []; + private _rows: SubjectEntry[] = []; + + constructor(sheetId: string) { + const serviceAccountAuth = new JWT({ + email: process.env.GOOGLE_SHEET_SVC_EMAIL!, + key: process.env.GOOGLE_SHEET_SVC_PRIVATE_KEY!, + scopes: ['https://www.googleapis.com/auth/spreadsheets'], + }); + + this._doc = new GoogleSpreadsheet(sheetId, serviceAccountAuth); + } + + public async fetchRemoteRows(): Promise>> { + await this._sheet.loadHeaderRow(3); + return await this._sheet.getRows(); + } + + public async getRemoteRows(): Promise { + await this._sheet.loadHeaderRow(3); + const rows = await this._sheet.getRows(); + return rows.map(this._parseRow); + } + + public async refresh(): Promise { + await this._doc.loadInfo(); + this._sheet = this._doc.sheetsByIndex[0]; + this._rawRows = await this.fetchRemoteRows(); + this._rows = this._rawRows.map(this._parseRow); + + container.logger.info('[Subjects] Subjects sheet has been refreshed.'); + } + + public async validate(): Promise { + const allRows = await this.fetchRemoteRows(); + const rows = allRows.map(row => ({ ...this._parseRow(row), _number: row.rowNumber })).filter(row => row.active); + + const errors: ValidationErrorData[] = []; + + for (const row of rows) { + // 1. Check that the identifier is unique + if (rows.filter(r => r.id === row.id).length > 1) + errors.push({ row: row._number, error: ValidationError.DuplicatedIdentifier }); + + // 2. Check that the emoji are valid + if (!nodeEmoji.has(row.emoji)) + errors.push({ row: row._number, error: ValidationError.InvalidEmoji }); + + // 3. Check that the class code is unique + if (rows.filter(r => r.classCode === row.classCode).length > 1) + errors.push({ row: row._number, error: ValidationError.DuplicatedClassCode }); + + // 4. Check that the teaching unit is valid (enum value) + if (!Object.values(TeachingUnit).includes(row.teachingUnit)) + errors.push({ row: row._number, error: ValidationError.InvalidTeachingUnit }); + + // 5. Check that the school year is valid (enum value) + if (!Object.values(SchoolYear).includes(row.schoolYear)) + errors.push({ row: row._number, error: ValidationError.InvalidSchoolYear }); + + // 6. Check that the guild ID is valid + const guild = container.client.guilds.cache.get(row.guildId); + if (guild) { + // 7. Check that all channel IDs are valid + if (!guild.channels.cache.has(row.textChannelId)) + errors.push({ row: row._number, error: ValidationError.InvalidTextChannel }); + + if (!guild.channels.cache.has(row.textDocsChannelId)) + errors.push({ row: row._number, error: ValidationError.InvalidTextDocsChannel }); + + if (!guild.channels.cache.has(row.voiceChannelId)) + errors.push({ row: row._number, error: ValidationError.InvalidTextVoiceChannel }); + } else { + errors.push({ row: row._number, error: ValidationError.InvalidGuild }); + } + } + + return errors; + } + + public getByClassCode(classCode: string): SubjectEntry | undefined { + return this._rows.find(row => row.classCode === classCode); + } + + public getById(id: string): SubjectEntry | undefined { + return this._rows.find(row => row.id === id); + } + + public get rows(): SubjectEntry[] { + return this._rows; + } + + private _parseRow(this: void, row: GoogleSpreadsheetRow): SubjectEntry { + return { + ...row.toObject() as RawSubjectRow, + active: row.get('active') === 'TRUE', + }; + } +} diff --git a/src/lib/types/augments.d.ts b/src/lib/types/augments.d.ts index 6bd3baf..0c62f3e 100644 --- a/src/lib/types/augments.d.ts +++ b/src/lib/types/augments.d.ts @@ -2,6 +2,7 @@ import type { Collection } from 'discord.js'; import type { ConfigurationManager } from '@/structures/ConfigurationManager'; import type { TaskStore } from '@/structures/tasks/TaskStore'; import type { DiscordLogType, LogStatuses, ReminderDocument } from '@/types/database'; +import type { SubjectsManager } from '../structures/SubjectsManager'; declare module '@sapphire/framework' { interface StoreRegistryEntries { @@ -10,6 +11,7 @@ declare module '@sapphire/framework' { interface SapphireClient { configManager: ConfigurationManager; + subjectsManager: SubjectsManager; remainingCompilerApiCredits: number; reactionRolesIds: Set; currentlyRunningEclassIds: Set; diff --git a/src/lib/types/database.ts b/src/lib/types/database.ts index 285fe57..a079c9d 100644 --- a/src/lib/types/database.ts +++ b/src/lib/types/database.ts @@ -6,7 +6,7 @@ import type { Role, } from 'discord.js'; import type { Document, Model, Types } from 'mongoose'; -import type { SchoolYear, TeachingUnit } from '@/types'; +import type { SchoolYear, TeachingUnit } from '.'; /* ************************************ */ /* AnnouncementMessage Database Types */ @@ -70,9 +70,9 @@ export interface ConfigurationDocument extends ConfigurationBase, Document {} export type ConfigurationModel = Model; // #endregion -/* ****************************** */ +/* *********************** */ /* Eclass Database Types */ -/* ****************************** */ +/* *********************** */ // #region Eclass Database Types /** Enum for the eclass' current status */ @@ -103,7 +103,7 @@ export interface EclassBase { classId: string; guildId: string; topic: string; - subject: SubjectDocument | Types.ObjectId; + subjectId: string; date: Date; duration: number; end: Date; @@ -122,8 +122,9 @@ export interface EclassBase { } /** Interface for the "Eclass"'s mongoose document */ -interface EclassBaseDocument extends EclassBase, Document { +export interface EclassDocument extends EclassBase, Document { subscriberIds: Types.Array; + subject: SubjectEntry; getMessageLink(): string; formatDates(): { date: string; end: string; duration: string }; getStatus(): string; @@ -131,49 +132,31 @@ interface EclassBaseDocument extends EclassBase, Document { normalizeDates(formatDuration?: false): { date: number; end: number; duration: number }; } -/** Interface for the "Eclass"'s mongoose document, when the subject field is not populated */ -export interface EclassDocument extends EclassBaseDocument { - subject: SubjectDocument['_id']; -} - -/** Interface for the "Eclass"'s mongoose document, when the subject field is populated */ -export interface EclassPopulatedDocument extends EclassBaseDocument { - subject: SubjectDocument; -} - /** Interface for the "Eclass"'s mongoose model */ export interface EclassModel extends Model { generateId(professor: GuildMember, date: Date): string; } // #endregion -/* ****************************** */ -/* Subject Database Types */ -/* ****************************** */ +/* **************************** */ +/* Subject Google Sheet Types */ +/* **************************** */ // #region Subject Database Types -/** Interface for the "Subject"'s mongoose schema */ -export interface SubjectBase { +/** Interface for the "Subject" Google Sheet row schema */ +export interface SubjectEntry { + active: boolean; + id: string; name: string; - nameEnglish: string; - slug: string; emoji: string; - emojiImage: string; classCode: string; - moodleLink: string; - docsLink: string; teachingUnit: TeachingUnit; schoolYear: SchoolYear; + guildId: string; textChannelId: string; - textDocsChannelId?: string; - voiceChannelId?: string; + textDocsChannelId: string; + voiceChannelId: string; } - -/** Interface for the "Subject"'s mongoose document */ -export interface SubjectDocument extends SubjectBase, Document {} - -/** Interface for the "Subject"'s mongoose model */ -export type SubjectModel = Model; // #endregion /* ***************************** */ @@ -493,9 +476,9 @@ export interface LogStatusesDocument extends LogStatusesBase, Document {} export type LogStatusesModel = Model; // #endregion -/* ***************************** */ -/* Contact Database Types */ -/* ***************************** */ +/* ************************ */ +/* Contact Database Types */ +/* ************************ */ // #region Contact Database Types /** Type for the "Contact"'s mongoose schema */ diff --git a/src/lib/types/index.ts b/src/lib/types/index.ts index 6ccded3..8b877de 100644 --- a/src/lib/types/index.ts +++ b/src/lib/types/index.ts @@ -4,7 +4,7 @@ import type { Message, Role, } from 'discord.js'; -import type { EclassPlace, SubjectDocument } from '@/types/database'; +import type { EclassPlace, SubjectEntry } from '@/types/database'; /* ******************************************* */ /* Utils */ @@ -66,7 +66,7 @@ export enum TeachingUnit { export interface EclassCreationOptions { date: Date; - subject: SubjectDocument; + subject: SubjectEntry; topic: string; duration: number; professor: GuildMember; @@ -83,7 +83,7 @@ export interface EclassEmbedOptions { end: number; isRecorded: boolean; professor: GuildMember; - subject: SubjectDocument; + subject: SubjectEntry; place: EclassPlace; placeInformation: string | null; topic: string; diff --git a/src/lib/utils/getEmojiImage.ts b/src/lib/utils/getEmojiImage.ts new file mode 100644 index 0000000..3b9722a --- /dev/null +++ b/src/lib/utils/getEmojiImage.ts @@ -0,0 +1,9 @@ +/** + * Returns the URL of an image corresponding to the given emoji + * @param emoji The emoji to get the image of + * @returns The URL of the image + */ +export function getEmojiImage(emoji: string): string { + const unifiedId = [...emoji].map(e => e.codePointAt(0)!.toString(16)).join('-'); + return `https://twemoji.maxcdn.com/v/latest/72x72/${unifiedId.toLowerCase()}.png`; +} diff --git a/src/lib/utils/index.ts b/src/lib/utils/index.ts index 6ebe8f8..8163bdb 100644 --- a/src/lib/utils/index.ts +++ b/src/lib/utils/index.ts @@ -4,6 +4,7 @@ export { convertSize } from './convertSize'; export { extractCodeBlocks } from './extractCodeBlocks'; export { firstSemesterDay } from './firstSemesterDay'; export { getDuration } from './getDuration'; +export { getEmojiImage } from './getEmojiImage'; export { getGitRev } from './getGitRev'; export { makeMessageLink } from './makeMessageLink'; export { massSend } from './massSend'; diff --git a/src/listeners/client/ready.ts b/src/listeners/client/ready.ts index 9a5de68..94d9085 100644 --- a/src/listeners/client/ready.ts +++ b/src/listeners/client/ready.ts @@ -31,6 +31,9 @@ export class ReadyListener extends Listener { this.container.logger.info('[Online Cache] Loading log statuses...'); await this._loadLogStatuses(); + this.container.logger.info('[Online Cache] Validating subjects...'); + await this._validateSubjects(); + this.container.logger.info('[Online Cache] All caching done!'); } @@ -81,4 +84,13 @@ export class ReadyListener extends Listener { } await LogStatuses.insertMany(docs); } + + private async _validateSubjects(): Promise { + const errors = await this.container.client.subjectsManager.validate(); + if (errors.length > 0) { + this.container.logger.error('[Subjects] The following errors were found in the subjects sheet:'); + for (const error of errors) + this.container.logger.error(`[Subjects] Row ${error.row}: ${error.error}`); + } + } } diff --git a/src/listeners/client/voiceStateUpdate.ts b/src/listeners/client/voiceStateUpdate.ts index ecc4f9a..29ccb70 100644 --- a/src/listeners/client/voiceStateUpdate.ts +++ b/src/listeners/client/voiceStateUpdate.ts @@ -3,7 +3,7 @@ import type { VoiceState } from 'discord.js'; import { Eclass } from '@/models/eclass'; import { EclassParticipation } from '@/models/eclassParticipation'; import * as DiscordLogManager from '@/structures/logs/DiscordLogManager'; -import type { EclassPopulatedDocument } from '@/types/database'; +import type { EclassDocument } from '@/types/database'; import { DiscordLogType, EclassStatus, EclassStep } from '@/types/database'; export class VoiceStateUpdateListener extends Listener { @@ -99,7 +99,7 @@ export class VoiceStateUpdateListener extends Listener { } } - private async _eclassesInProgressInChannel(channelId: string): Promise> { + private async _eclassesInProgressInChannel(channelId: string): Promise> { // TODO: Cache/memoize the result of the database call for a few minutes const eclassesInProgress = await Eclass.find({ $or: [