diff --git a/package-lock.json b/package-lock.json index 8ba702b..6475fd1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,7 +27,8 @@ "node-cron": "^4.0.7", "node-ical": "^0.20.1", "object-path-set": "^1.0.2", - "svg2img": "^1.0.0-beta.2" + "svg2img": "^1.0.0-beta.2", + "tzdata": "^1.0.44" }, "devDependencies": { "@eslint/js": "^9.29.0", @@ -4518,6 +4519,12 @@ "typescript": ">=4.8.4 <5.9.0" } }, + "node_modules/tzdata": { + "version": "1.0.44", + "resolved": "https://registry.npmjs.org/tzdata/-/tzdata-1.0.44.tgz", + "integrity": "sha512-xJ8xcdoFRwFpIQ90QV3WFXJNCO/feNn9vHVsZMJiKmtMYuo7nvF6CTpBc+SgegC1fb/3L+m32ytXT9XrBjrINg==", + "license": "MIT" + }, "node_modules/undici": { "version": "6.21.1", "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.1.tgz", diff --git a/package.json b/package.json index 9377988..01cf4a6 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,8 @@ "node-cron": "^4.0.7", "node-ical": "^0.20.1", "object-path-set": "^1.0.2", - "svg2img": "^1.0.0-beta.2" + "svg2img": "^1.0.0-beta.2", + "tzdata": "^1.0.44" }, "devDependencies": { "@eslint/js": "^9.29.0", diff --git a/source/Configuration/ConfigurationTransformer.ts b/source/Configuration/ConfigurationTransformer.ts index de45447..64c4e35 100644 --- a/source/Configuration/ConfigurationTransformer.ts +++ b/source/Configuration/ConfigurationTransformer.ts @@ -8,6 +8,7 @@ export enum TransformerType { PermissionBoolean, String, Paragraph, + Timezone } type ConfigurationTransformerItem = { @@ -35,6 +36,7 @@ export class ConfigurationTransformer { return configValue.value; case TransformerType.PermissionBoolean: return configValue.value === '1'; + case TransformerType.Timezone: case TransformerType.Paragraph: case TransformerType.String: return configValue.value; diff --git a/source/Configuration/Groups/GroupConfigurationProvider.ts b/source/Configuration/Groups/GroupConfigurationProvider.ts index 18c1287..101366d 100644 --- a/source/Configuration/Groups/GroupConfigurationProvider.ts +++ b/source/Configuration/Groups/GroupConfigurationProvider.ts @@ -11,7 +11,8 @@ import {ConfigurationTransformer, TransformerType} from "../ConfigurationTransfo export type RuntimeGroupConfiguration = { channels: Nullable, permissions: PermissionRuntimeGroupConfiguration, - calendar: CalendarRuntimeGroupConfiguration + calendar: CalendarRuntimeGroupConfiguration, + timezone: string|null }; export type ChannelRuntimeGroupConfiguration = { @@ -49,7 +50,8 @@ export class GroupConfigurationProvider implements ConfigurationProvider< title: null, description: null, location: null - } + }, + timezone: null } } @@ -97,6 +99,10 @@ export class GroupConfigurationProvider implements ConfigurationProvider< { path: ['calendar', 'location'], type: TransformerType.String + }, + { + path: ['timezone'], + type: TransformerType.Timezone } ] ) diff --git a/source/Configuration/Server/ServerConfigurationProvider.ts b/source/Configuration/Server/ServerConfigurationProvider.ts index df329ab..defd058 100644 --- a/source/Configuration/Server/ServerConfigurationProvider.ts +++ b/source/Configuration/Server/ServerConfigurationProvider.ts @@ -8,7 +8,8 @@ import { Nullable } from "../../types/Nullable"; import {ConfigurationTransformer, TransformerType} from "../ConfigurationTransformer"; export type RuntimeServerConfiguration = { - permissions: PermissionRuntimeServerConfiguration + permissions: PermissionRuntimeServerConfiguration, + timezone: string } export type PermissionRuntimeServerConfiguration = { @@ -35,7 +36,8 @@ export class ServerConfigurationProvider implements ConfigurationProvider< groupCreation: { allowEveryone: false } - } + }, + timezone: '', } } get(path: string): Nullable { @@ -62,6 +64,10 @@ export class ServerConfigurationProvider implements ConfigurationProvider< { path: ['permissions', 'groupCreation', 'allowEveryone'], type: TransformerType.PermissionBoolean + }, + { + path: ['timezone'], + type: TransformerType.Timezone } ] ) diff --git a/source/Configuration/TimezoneHandler.ts b/source/Configuration/TimezoneHandler.ts new file mode 100644 index 0000000..65cfdcb --- /dev/null +++ b/source/Configuration/TimezoneHandler.ts @@ -0,0 +1,93 @@ +import {GroupModel} from "../Database/Models/GroupModel"; +import {ConfigurationHandler, PathConfigurationFrom} from "./ConfigurationHandler"; +import {GroupConfigurationProvider} from "./Groups/GroupConfigurationProvider"; +import {Container} from "../Container/Container"; +import {GroupConfigurationRepository} from "../Database/Repositories/GroupConfigurationRepository"; +import {ServerConfigurationProvider} from "./Server/ServerConfigurationProvider"; +import {Snowflake, time} from "discord.js"; +import {ServerConfigurationRepository} from "../Database/Repositories/ServerConfigurationRepository"; +import tzdata from 'tzdata'; +import {Nullable} from "../types/Nullable"; + +export type Timezone = { + zone: string, + gmt: string, + name: string, +} + +export enum TimezoneSaveTarget { + Server, + Group +} + +export class TimezoneHandler { + public static ALL_TIMEZONES: string[] = Object.keys(tzdata.zones) + + constructor( + private readonly serverid: Snowflake, + private readonly group: GroupModel|null = null + ) { + } + + public use(callback: () => TReturn): TReturn { + const previousTZ = process.env.TZ; + + process.env.TZ = this.getCurrentTimezone(); + const result = callback(); + process.env.TZ = previousTZ; + + return result; + } + + public getCurrentTimezone(): string { + const configs = [ + this.getGroupConfiguration(), + this.getServerConfiguration() + ]; + + for (const config of configs) { + if (!config) { + continue; + } + + const timezone = config.getConfigurationByPath('timezone'); + if (timezone.from === PathConfigurationFrom.Default) { + continue; + } + + return timezone.value; + } + + return process.env.TZ ?? "Europe/London"; + } + + public save(timezone: string, target: TimezoneSaveTarget) { + const config = target === TimezoneSaveTarget.Server ? this.getServerConfiguration() : this.getGroupConfiguration(); + if (!config) { + return; + } + + config.save('timezone', timezone); + } + + private getGroupConfiguration(): Nullable { + if (!this.group) { + return null; + } + + return new ConfigurationHandler( + new GroupConfigurationProvider( + Container.get(GroupConfigurationRepository.name), + this.group + ) + ); + } + private getServerConfiguration(): ConfigurationHandler { + return new ConfigurationHandler( + new ServerConfigurationProvider( + Container.get(ServerConfigurationRepository.name), + this.serverid + ) + ) + } +} \ No newline at end of file diff --git a/source/Database/Repositories/PlaydateRepository.ts b/source/Database/Repositories/PlaydateRepository.ts index b36680f..660ddde 100644 --- a/source/Database/Repositories/PlaydateRepository.ts +++ b/source/Database/Repositories/PlaydateRepository.ts @@ -36,11 +36,11 @@ export class PlaydateRepository extends Repository { } findPlaydatesInRange(fromDate: Date, toDate: Date | undefined = undefined, group: GroupModel | undefined = undefined) { - let sql = `SELECT * FROM ${this.schema.name} WHERE time_from > ?`; + let sql = `SELECT * FROM ${this.schema.name} WHERE time_from >= ?`; const params = [fromDate.getTime()]; if (toDate) { - sql = `${sql} AND time_from < ?` + sql = `${sql} AND time_from <= ?` params.push(toDate.getTime()); } diff --git a/source/Discord/Commands/Groups.ts b/source/Discord/Commands/Groups.ts index b30e701..318796c 100644 --- a/source/Discord/Commands/Groups.ts +++ b/source/Discord/Commands/Groups.ts @@ -1,9 +1,10 @@ import { AutocompleteInteraction, ChatInputCommandInteraction, - EmbedBuilder, GuildMember, GuildMemberRoleManager, + hyperlink, + inlineCode, InteractionReplyOptions, MessageFlags, PermissionFlagsBits, @@ -35,8 +36,8 @@ import {PermissionError} from "../PermissionError"; import {EmbedLibrary, EmbedType} from "../EmbedLibrary"; import {EventHandler} from "../../Events/EventHandler"; import {ElementChangedEvent} from "../../Events/EventClasses/ElementChangedEvent"; -import GroupConfiguration from "../../Database/tables/GroupConfiguration"; import Groups from "../../Database/tables/Groups"; +import {TimezoneHandler, TimezoneSaveTarget} from "../../Configuration/TimezoneHandler"; export class GroupCommand implements Command, ChatInteractionCommand, AutocompleteCommand { private static GOODBYE_MESSAGES: string[] = [ @@ -95,6 +96,16 @@ export class GroupCommand implements Command, ChatInteractionCommand, Autocomple .setDescription("The member, that is the new leader") .setRequired(true) ) + ) + .addSubcommand(command => command + .setName("timezone") + .setDescription("Sets the timezone for the group, if a value is provided. If not, the current timezone is displayed.") + .addIntegerOption(GroupSelection.createOptionSetup()) + .addStringOption(option => option + .setName('timezone') + .setDescription("The timezone the group should use.") + .setRequired(false) + ) ); } @@ -115,6 +126,9 @@ export class GroupCommand implements Command, ChatInteractionCommand, Autocomple case "transfer": await this.transferLeadership(interaction); break; + case "timezone": + await this.handleTimezone(interaction); + break; default: throw new Error("Unsupported command"); } @@ -395,4 +409,51 @@ export class GroupCommand implements Command, ChatInteractionCommand, Autocomple flags: MessageFlags.Ephemeral, }) } + + private async handleTimezone(interaction: ChatInputCommandInteraction) { + const group = GroupSelection.getGroup(interaction); + const enteredTimezone = interaction.options.getString('timezone'); + + const timezoneHandler = new TimezoneHandler( + interaction.guildId ?? '', + group + ); + if (!enteredTimezone) { + await interaction.reply( + { + embeds: [ + EmbedLibrary.withGroup( + group, + "Timezone", + `The group currently uses the timezone ${inlineCode(timezoneHandler.getCurrentTimezone())}.`, + EmbedType.Info + ) + ] + } + ) + return; + } + + if (!TimezoneHandler.ALL_TIMEZONES.includes(enteredTimezone)) { + throw new UserError( + `Invalid timezone provided: ${enteredTimezone}`, + `Try using timezones found in this list using the 'TZ Identifier'. ${hyperlink("List", 'https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List')}` + ) + } + + timezoneHandler.save(enteredTimezone, TimezoneSaveTarget.Group); + await interaction.reply( + { + embeds: [ + EmbedLibrary.withGroup( + group, + "Timezone changed", + `The group now uses the timezone ${inlineCode(enteredTimezone)}.`, + EmbedType.Info + ) + ] + } + ) + + } } \ No newline at end of file diff --git a/source/Discord/Commands/Playdates.ts b/source/Discord/Commands/Playdates.ts index c4f2e67..7959084 100644 --- a/source/Discord/Commands/Playdates.ts +++ b/source/Discord/Commands/Playdates.ts @@ -29,6 +29,8 @@ import {PermissionError} from "../PermissionError"; import {EmbedLibrary, EmbedType} from "../EmbedLibrary"; import {GroupConfigurationModel} from "../../Database/Models/GroupConfigurationModel"; import parser from "any-date-parser"; +import {TimezoneHandler} from "../../Configuration/TimezoneHandler"; +import _ from "lodash"; export class PlaydatesCommand implements Command, AutocompleteCommand, ChatInteractionCommand { definition(): SlashCommandBuilder { @@ -123,8 +125,18 @@ export class PlaydatesCommand implements Command, AutocompleteCommand, ChatInter ) } - const fromDate = parser.fromString(interaction.options.get("from")?.value ?? ''); - const toDate = parser.fromString(interaction.options.get("to")?.value ?? ''); + const timezoneHandler = new TimezoneHandler( + interaction.guildId ?? '', + group + ); + + const [fromDate, toDate] = timezoneHandler.use(() => { + return [ + parser.fromString(interaction.options.get("from")?.value ?? ''), + parser.fromString(interaction.options.get("to")?.value ?? '') + ] + }) + if (!fromDate.isValid()) { throw new UserError("No date or invalid date format for the from parameter."); @@ -184,13 +196,19 @@ export class PlaydatesCommand implements Command, AutocompleteCommand, ChatInter const group = GroupSelection.getGroup(interaction); + const timezone = new TimezoneHandler( + interaction.guildId ?? '', + group + ); const playdates = Container.get(PlaydateRepository.name).findFromGroup(group); await interaction.respond( - playdates.map(playdate => { - return { - name: `${playdate.from_time.toLocaleString()} - ${playdate.to_time.toLocaleString()}`, - value: playdate.id - } + timezone.use(() => { + return _.slice(playdates, 0, 25).map(playdate => { + return { + name: `${playdate.from_time.toLocaleString()} - ${playdate.to_time.toLocaleString()}`, + value: playdate.id + } + }) }) ) } diff --git a/source/Discord/Commands/Server.ts b/source/Discord/Commands/Server.ts index 202b476..bea1d23 100644 --- a/source/Discord/Commands/Server.ts +++ b/source/Discord/Commands/Server.ts @@ -1,16 +1,24 @@ -import {CacheType, ChatInputCommandInteraction, PermissionFlagsBits, SlashCommandBuilder, Snowflake} from "discord.js"; +import { + CacheType, + ChatInputCommandInteraction, + hyperlink, + inlineCode, + PermissionFlagsBits, + SlashCommandBuilder, + Snowflake +} from "discord.js"; import {ChatInteractionCommand, Command} from "./Command"; -import {GroupSelection} from "../CommandPartials/GroupSelection"; import {MenuHandler} from "../../Configuration/MenuHandler"; import {ConfigurationHandler} from "../../Configuration/ConfigurationHandler"; -import {GroupConfigurationProvider} from "../../Configuration/Groups/GroupConfigurationProvider"; import {Container} from "../../Container/Container"; -import {GroupConfigurationRepository} from "../../Database/Repositories/GroupConfigurationRepository"; import {MenuRenderer} from "../../Menu/MenuRenderer"; import {MenuTraversal} from "../../Menu/MenuTraversal"; import {MenuItemType} from "../../Menu/MenuRenderer.types"; import {ServerConfigurationProvider} from "../../Configuration/Server/ServerConfigurationProvider"; import {ServerConfigurationRepository} from "../../Database/Repositories/ServerConfigurationRepository"; +import {TimezoneHandler, TimezoneSaveTarget} from "../../Configuration/TimezoneHandler"; +import {EmbedLibrary, EmbedType} from "../EmbedLibrary"; +import {UserError} from "../UserError"; export class ServerCommand implements Command, ChatInteractionCommand { definition(): SlashCommandBuilder { @@ -22,6 +30,15 @@ export class ServerCommand implements Command, ChatInteractionCommand { .setName("config") .setDescription("Starts the configurator for the server settings") ) + .addSubcommand(command => command + .setName("timezone") + .setDescription("Sets the timezone for the server, if a value is provided. If not, the current timezone is displayed.") + .addStringOption(option => option + .setName('timezone') + .setDescription("The timezone the server should use.") + .setRequired(false) + ) + ) } async execute(interaction: ChatInputCommandInteraction): Promise { @@ -29,6 +46,9 @@ export class ServerCommand implements Command, ChatInteractionCommand { case "config": await this.startConfiguration(interaction); break + case "timezone": + await this.handleTimezone(interaction); + break; } } @@ -79,4 +99,46 @@ export class ServerCommand implements Command, ChatInteractionCommand { menu.display(interaction); } + + private async handleTimezone(interaction: ChatInputCommandInteraction) { + const enteredTimezone = interaction.options.getString('timezone'); + + const timezoneHandler = new TimezoneHandler( + interaction.guildId ?? '' + ); + if (!enteredTimezone) { + await interaction.reply( + { + embeds: [ + EmbedLibrary.base( + "Timezone", + `The group currently uses the timezone ${inlineCode(timezoneHandler.getCurrentTimezone())}.`, + EmbedType.Info + ) + ] + } + ) + return; + } + + if (!TimezoneHandler.ALL_TIMEZONES.includes(enteredTimezone)) { + throw new UserError( + `Invalid timezone provided: ${enteredTimezone}`, + `Try using timezones found in this list using the 'TZ Identifier'. ${hyperlink("List", 'https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List')}` + ) + } + + timezoneHandler.save(enteredTimezone, TimezoneSaveTarget.Server); + await interaction.reply( + { + embeds: [ + EmbedLibrary.base( + "Timezone changed", + `The server now uses the timezone ${inlineCode(enteredTimezone)}.`, + EmbedType.Success + ) + ] + } + ) + } } \ No newline at end of file diff --git a/source/Discord/UserError.ts b/source/Discord/UserError.ts index 6918922..9b53e74 100644 --- a/source/Discord/UserError.ts +++ b/source/Discord/UserError.ts @@ -15,7 +15,7 @@ export class UserError extends Error { public getEmbed(e: UserError): EmbedBuilder { const embed = EmbedLibrary.base( "Please validate your request!", - inlineCode(e.message), + e.message, EmbedType.Error ).setFooter({ text: "Type: Request"