diff --git a/.eslintrc.json b/.eslintrc.json index 3075b3d..97b1b75 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -19,9 +19,12 @@ "prettier/prettier": [ "error" ], - "@typescript-eslint/no-unused-vars": ["error", { - "argsIgnorePattern": "^_" - }], + "@typescript-eslint/no-unused-vars": [ + "error", + { + "argsIgnorePattern": "^_" + } + ], "@typescript-eslint/explicit-member-accessibility": "off", "@typescript-eslint/no-object-literal-type-assertion": "off", "@typescript-eslint/no-non-null-assertion": "off", @@ -29,17 +32,18 @@ "@typescript-eslint/camelcase": "off", "@typescript-eslint/explicit-function-return-type": "off", "no-use-before-define": "off", - "@typescript-eslint/no-use-before-define": [ - "error" - ], + "@typescript-eslint/no-use-before-define": "off", "class-methods-use-this": "off", "no-plusplus": "off", - "no-restricted-imports": ["error", { - "paths": [ - "firebase-admin", - "firebase-admin/lib/*" - ] - }], + "no-restricted-imports": [ + "error", + { + "paths": [ + "firebase-admin", + "firebase-admin/lib/*" + ] + } + ], "no-restricted-syntax": "off", "no-shadow": "off", "no-underscore-dangle": "off", @@ -58,12 +62,26 @@ "tsx": "never" } ], - "import/order": ["error", { - "groups": [["index", "sibling", "parent", "internal", "external", "builtin", "object", "type"]], - "alphabetize": { - "order": "asc" + "import/order": [ + "error", + { + "groups": [ + [ + "index", + "sibling", + "parent", + "internal", + "external", + "builtin", + "object", + "type" + ] + ], + "alphabetize": { + "order": "asc" + } } - }] + ] }, "settings": { "import/resolver": { diff --git a/src/commands/event.command.ts b/src/commands/event.command.ts new file mode 100644 index 0000000..6d7d1af --- /dev/null +++ b/src/commands/event.command.ts @@ -0,0 +1,354 @@ +import { Course, Period } from '../messaging/schedule'; +import { int2mil, setDatetimeFromInt } from '../utils/date.utils'; +import { errorEmbed, successEmbed } from '../utils/embed-utils'; +import { ApplyOptions } from '@sapphire/decorators'; +import { ApplicationCommandRegistry } from '@sapphire/framework'; +import { Subcommand } from '@sapphire/plugin-subcommands'; +import { oneLine, stripIndent } from 'common-tags'; +import dayjs from 'dayjs'; +import { + ActionRowBuilder, + ComponentType, + GuildScheduledEventEntityType, + GuildScheduledEventPrivacyLevel, + ModalActionRowComponentBuilder, + ModalBuilder, + StringSelectMenuBuilder, + StringSelectMenuOptionBuilder, + TextChannel, + TextInputBuilder, + TextInputStyle, +} from 'discord.js'; + +const ids = { + classInput: 'class-select', + periodInput: 'period-select', + dateInput: 'date-select', + modal: { + id: 'create-event', + title: 'event-title', + description: 'event-description', + }, +}; + +const stages = [ + { question: 'Select a class' }, + { question: 'Select the period of the week' }, + { question: 'Select the date of the event' }, +]; + +@ApplyOptions({ + name: 'event', + enabled: true, + runIn: 'GUILD_TEXT', + description: 'Edit events for a specific class', + subcommands: [ + { + name: 'create', + default: true, + chatInputRun: 'createEvent', + }, + { + name: 'delete', + chatInputRun: 'deleteEvent', + }, + ], +}) +export default class EventCommand extends Subcommand { + override registerApplicationCommands(registry: ApplicationCommandRegistry) { + registry.registerChatInputCommand( + (builder) => + builder + .setName(this.name) + .setDescription(this.description) + .addSubcommand((command) => + command + .setName('create') + .setDescription('Create a new event'), + ) + .addSubcommand((command) => + command + .setName('delete') + .setDescription('Delete an event') + .addStringOption((option) => + option + .setName('id') + .setDescription('The event ID') + .setRequired(true), + ), + ), + { idHints: ['1185637298335920233'] }, + ); + } + + async createEvent(interaction: Subcommand.ChatInputCommandInteraction) { + const reply = await interaction.deferReply({ ephemeral: true }); + try { + const channel = (await interaction.channel?.fetch()) as TextChannel; + const course = parseCourse(channel.topic); + if (!course) { + return reply.edit({ + embeds: [ + errorEmbed('This channel is not linked to a course'), + ], + }); + } + + const classMap = course.classes.reduce>( + (acc, c) => ({ + ...acc, + [c.name]: c.schedule, + }), + {}, + ); + + const classSelect = new StringSelectMenuBuilder() + .setCustomId(ids.classInput) + .setOptions( + Object.keys(classMap).map((c) => + new StringSelectMenuOptionBuilder() + .setLabel(c) + .setValue(c), + ), + ); + + await reply.edit({ + content: buildMessageContent(0), + components: [ + new ActionRowBuilder().addComponents( + classSelect, + ), + ], + }); + + const classSelectInteraction = await reply.awaitMessageComponent({ + componentType: ComponentType.StringSelect, + time: 1000 * 60 * 3, + }); + + const selectedClassName = classSelectInteraction.values[0]; + if (!selectedClassName) { + return reply.edit({ + embeds: [errorEmbed('No class selected')], + }); + } + + const periods = classMap[selectedClassName]!; + const periodSelect = new StringSelectMenuBuilder() + .setCustomId(ids.classInput) + .setOptions( + periods.map((p) => + new StringSelectMenuOptionBuilder() + .setLabel(buildPeriodName(p)) + .setValue(p.name), + ), + ); + + await classSelectInteraction.update({ + content: buildMessageContent(1, selectedClassName), + components: [ + new ActionRowBuilder().addComponents( + periodSelect, + ), + ], + }); + + const periodSelectInteraction = await reply.awaitMessageComponent({ + componentType: ComponentType.StringSelect, + time: 1000 * 60 * 3, + }); + + const selectedPeriod = periods.find( + (p) => p.name === periodSelectInteraction.values[0], + ); + if (!selectedPeriod) { + return reply.edit({ + embeds: [errorEmbed('No period selected')], + }); + } + + const dateSelect = new StringSelectMenuBuilder() + .setCustomId(ids.dateInput) + .setOptions(getNextDates(selectedPeriod)); + + await periodSelectInteraction.update({ + content: buildMessageContent( + 2, + selectedClassName, + buildPeriodName(selectedPeriod), + ), + components: [ + new ActionRowBuilder().addComponents( + dateSelect, + ), + ], + }); + + const dateSelectInteraction = await reply.awaitMessageComponent({ + componentType: ComponentType.StringSelect, + time: 1000 * 60 * 3, + }); + + const contentModal = new ModalBuilder() + .setCustomId(ids.modal.id) + .setTitle('Event details') + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId(ids.modal.title) + .setLabel('Event title') + .setStyle(TextInputStyle.Short) + .setPlaceholder('TE1') + .setRequired(true), + ), + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId(ids.modal.description) + .setLabel('Event description') + .setStyle(TextInputStyle.Paragraph) + .setRequired(false), + ), + ); + + await dateSelectInteraction.showModal(contentModal); + const modalInteraction = + await dateSelectInteraction.awaitModalSubmit({ + time: 1000 * 60 * 5, + }); + + if (!modalInteraction.isFromMessage()) { + // illegal state + return undefined; + } + + const day = dayjs(dateSelectInteraction.values[0]); + const startDate = setDatetimeFromInt(day, selectedPeriod.time[0]); + const endDate = setDatetimeFromInt(day, selectedPeriod.time[1]); + + await modalInteraction.update({ + content: `${buildMessageContent( + 3, + selectedClassName, + buildPeriodName(selectedPeriod), + day.format('DD/MM/YYYY'), + )}\n\n:ballot_box_with_check: Creating event...`, + components: [], + }); + + const scheduledEvent = + await interaction.guild!.scheduledEvents.create({ + entityType: GuildScheduledEventEntityType.External, + entityMetadata: { + location: `HEIG-VD - ${selectedPeriod.room}`, + }, + name: `${selectedClassName} - ${ + modalInteraction.fields.getField(ids.modal.title).value + }`, + description: modalInteraction.fields.getField( + ids.modal.description, + ).value, + scheduledStartTime: startDate.toISOString(), + scheduledEndTime: endDate.toISOString(), + privacyLevel: GuildScheduledEventPrivacyLevel.GuildOnly, + }); + + return await modalInteraction.followUp({ + content: stripIndent` + :white_check_mark: **Success**: New event created for this class + + Course: ${selectedClassName}-${selectedPeriod.name} + Date: ${startDate.format('DD/MM/YYYY HH:mm')} + + :link: [View event](${scheduledEvent.url}) + ID: \`${scheduledEvent.id}\` + `, + }); + } catch (e) { + this.container.logger.error(e); + return reply.edit({ + embeds: [ + errorEmbed( + 'Builder canceled, no interaction within timeout', + ), + ], + content: null, + components: [], + }); + } + } + + async deleteEvent(interaction: Subcommand.ChatInputCommandInteraction) { + try { + await interaction.guild?.scheduledEvents.delete( + interaction.options.getString('id', true), + ); + + return interaction.reply({ + embeds: [successEmbed('Event successfully deleted')], + }); + } catch (e) { + this.container.logger.error(e); + return interaction.reply({ + embeds: [ + errorEmbed('An issue occured while deleting the event'), + ], + }); + } + } +} + +const parseCourse = (topic: string | null): Course | undefined => { + const encoded = topic + ?.split('\n') + ?.find((m) => m.startsWith('sch:')) + ?.substring(4); + + if (!encoded) { + return undefined; + } + + return Course.decode(Buffer.from(encoded, 'base64')); +}; + +const buildMessageContent = (stage: number, ...previousValues: string[]) => { + const question = stages[stage]?.question; + const previous = previousValues + .map((v, i) => `:white_check_mark: ${stages[i]?.question}: **${v}**`) + .join('\n'); + + if (!question) { + return `**Class Event Builder**\n${previous ? `\n${previous}` : ''}`; + } + + return `**Class Event Builder**\n${ + previous ? `\n${previous}` : '' + }\n:question: ${question}`; +}; + +const buildPeriodName = (period: Period): string => { + const weekday = dayjs() + .weekday(period.day - 1) + .format('dddd'); + + return oneLine` + ${period.name}: + ${weekday} ${int2mil(period.time[0])} - ${int2mil(period.time[1])} + en ${period.room} + `; +}; + +const getNextDates = (period: Period): StringSelectMenuOptionBuilder[] => { + const today = dayjs().utc().startOf('day'); + const nextDates = []; + + for (let i = 0; i < 16; i++) { + const date = today.add(i, 'week').weekday(period.day - 1); + nextDates.push( + new StringSelectMenuOptionBuilder() + .setLabel(date.format('DD/MM/YYYY')) + .setValue(date.toISOString()), + ); + } + + return nextDates; +}; diff --git a/src/lib/setup.ts b/src/lib/setup.ts index 23f7ba5..67f4a84 100644 --- a/src/lib/setup.ts +++ b/src/lib/setup.ts @@ -4,7 +4,11 @@ import '@sapphire/plugin-logger/register'; import '@sapphire/plugin-scheduled-tasks/register'; import dayjsParser from '../utils/dayjs-parser'; -import { container } from '@sapphire/framework'; +import { + ApplicationCommandRegistries, + container, + RegisterBehavior, +} from '@sapphire/framework'; import dayjs from 'dayjs'; import 'dayjs/locale/fr-ch'; @@ -35,3 +39,7 @@ initializeApp({ container.database = getFirestore(); container.redisClient = new IORedis(process.env.REDIS_URL ?? ''); + +ApplicationCommandRegistries.setDefaultBehaviorWhenNotIdentical( + RegisterBehavior.BulkOverwrite, +); diff --git a/src/messaging/schedule.ts b/src/messaging/schedule.ts index cecc5d6..c1e6713 100644 --- a/src/messaging/schedule.ts +++ b/src/messaging/schedule.ts @@ -87,7 +87,10 @@ function createBaseCourse(): Course { } export const Course = { - encode(message: Course, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer { + encode( + message: Course, + writer: _m0.Writer = _m0.Writer.create(), + ): _m0.Writer { for (const v of message.classes) { Class.encode(v!, writer.uint32(10).fork()).ldelim(); } @@ -95,7 +98,8 @@ export const Course = { }, decode(input: _m0.Reader | Uint8Array, length?: number): Course { - const reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input); + const reader = + input instanceof _m0.Reader ? input : _m0.Reader.create(input); let end = length === undefined ? reader.len : reader.pos + length; const message = createBaseCourse(); while (reader.pos < end) { @@ -119,7 +123,9 @@ export const Course = { fromJSON(object: any): Course { return { - classes: globalThis.Array.isArray(object?.classes) ? object.classes.map((e: any) => Class.fromJSON(e)) : [], + classes: globalThis.Array.isArray(object?.classes) + ? object.classes.map((e: any) => Class.fromJSON(e)) + : [], }; }, @@ -136,7 +142,8 @@ export const Course = { }, fromPartial, I>>(object: I): Course { const message = createBaseCourse(); - message.classes = object.classes?.map((e) => Class.fromPartial(e)) || []; + message.classes = + object.classes?.map((e) => Class.fromPartial(e)) || []; return message; }, }; @@ -146,7 +153,10 @@ function createBaseClass(): Class { } export const Class = { - encode(message: Class, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer { + encode( + message: Class, + writer: _m0.Writer = _m0.Writer.create(), + ): _m0.Writer { if (message.name !== '') { writer.uint32(10).string(message.name); } @@ -157,7 +167,8 @@ export const Class = { }, decode(input: _m0.Reader | Uint8Array, length?: number): Class { - const reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input); + const reader = + input instanceof _m0.Reader ? input : _m0.Reader.create(input); let end = length === undefined ? reader.len : reader.pos + length; const message = createBaseClass(); while (reader.pos < end) { @@ -175,7 +186,9 @@ export const Class = { break; } - message.schedule.push(Period.decode(reader, reader.uint32())); + message.schedule.push( + Period.decode(reader, reader.uint32()), + ); continue; } if ((tag & 7) === 4 || tag === 0) { @@ -189,7 +202,9 @@ export const Class = { fromJSON(object: any): Class { return { name: isSet(object.name) ? globalThis.String(object.name) : '', - schedule: globalThis.Array.isArray(object?.schedule) ? object.schedule.map((e: any) => Period.fromJSON(e)) : [], + schedule: globalThis.Array.isArray(object?.schedule) + ? object.schedule.map((e: any) => Period.fromJSON(e)) + : [], }; }, @@ -210,7 +225,8 @@ export const Class = { fromPartial, I>>(object: I): Class { const message = createBaseClass(); message.name = object.name ?? ''; - message.schedule = object.schedule?.map((e) => Period.fromPartial(e)) || []; + message.schedule = + object.schedule?.map((e) => Period.fromPartial(e)) || []; return message; }, }; @@ -220,7 +236,10 @@ function createBasePeriod(): Period { } export const Period = { - encode(message: Period, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer { + encode( + message: Period, + writer: _m0.Writer = _m0.Writer.create(), + ): _m0.Writer { if (message.name !== '') { writer.uint32(10).string(message.name); } @@ -239,7 +258,8 @@ export const Period = { }, decode(input: _m0.Reader | Uint8Array, length?: number): Period { - const reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input); + const reader = + input instanceof _m0.Reader ? input : _m0.Reader.create(input); let end = length === undefined ? reader.len : reader.pos + length; const message = createBasePeriod(); while (reader.pos < end) { @@ -289,6 +309,11 @@ export const Period = { } reader.skipType(tag & 7); } + + message.time = message.time.filter( + (e) => e !== undefined && e > 0, + ) as any; + return message; }, @@ -296,7 +321,9 @@ export const Period = { return { name: isSet(object.name) ? globalThis.String(object.name) : '', day: isSet(object.day) ? weekdayFromJSON(object.day) : 0, - time: globalThis.Array.isArray(object?.time) ? object.time.map((e: any) => globalThis.Number(e)) : [], + time: globalThis.Array.isArray(object?.time) + ? object.time.map((e: any) => globalThis.Number(e)) + : [], room: isSet(object.room) ? globalThis.String(object.room) : '', }; }, @@ -326,23 +353,40 @@ export const Period = { const message = createBasePeriod(); message.name = object.name ?? ''; message.day = object.day ?? 0; - message.time = (object.time?.map((e) => e) || [0, 0]) as [number, number]; + message.time = (object.time?.map((e) => e) || [0, 0]) as [ + number, + number, + ]; message.room = object.room ?? ''; return message; }, }; -type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined; - -export type DeepPartial = T extends Builtin ? T - : T extends globalThis.Array ? globalThis.Array> - : T extends ReadonlyArray ? ReadonlyArray> - : T extends {} ? { [K in keyof T]?: DeepPartial } - : Partial; +type Builtin = + | Date + | Function + | Uint8Array + | string + | number + | boolean + | undefined; + +export type DeepPartial = T extends Builtin + ? T + : T extends globalThis.Array + ? globalThis.Array> + : T extends ReadonlyArray + ? ReadonlyArray> + : T extends {} + ? { [K in keyof T]?: DeepPartial } + : Partial; type KeysOfUnion = T extends T ? keyof T : never; -export type Exact = P extends Builtin ? P - : P & { [K in keyof P]: Exact } & { [K in Exclude>]: never }; +export type Exact = P extends Builtin + ? P + : P & { [K in keyof P]: Exact } & { + [K in Exclude>]: never; + }; function isSet(value: any): boolean { return value !== null && value !== undefined; diff --git a/src/utils/date.utils.ts b/src/utils/date.utils.ts new file mode 100644 index 0000000..ee63194 --- /dev/null +++ b/src/utils/date.utils.ts @@ -0,0 +1,13 @@ +import { Dayjs } from 'dayjs'; + +export const int2mil = (int: number): string => { + const str = String(int).padStart(4, '0'); + return `${str.slice(0, 2)}:${str.slice(2)}`; +}; + +export const setDatetimeFromInt = (dayjs: Dayjs, int: number): Dayjs => { + const str = String(int).padStart(4, '0'); + return dayjs + .set('hour', Number(str.slice(0, 2))) + .set('minute', Number(str.slice(2))); +};