feat(timezone): Adds timezone as option

This commit is contained in:
Michel Fedde 2025-06-29 21:52:53 +02:00
parent 0b9089ffae
commit b852c06f83
11 changed files with 278 additions and 22 deletions

9
package-lock.json generated
View file

@ -27,7 +27,8 @@
"node-cron": "^4.0.7", "node-cron": "^4.0.7",
"node-ical": "^0.20.1", "node-ical": "^0.20.1",
"object-path-set": "^1.0.2", "object-path-set": "^1.0.2",
"svg2img": "^1.0.0-beta.2" "svg2img": "^1.0.0-beta.2",
"tzdata": "^1.0.44"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.29.0", "@eslint/js": "^9.29.0",
@ -4518,6 +4519,12 @@
"typescript": ">=4.8.4 <5.9.0" "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": { "node_modules/undici": {
"version": "6.21.1", "version": "6.21.1",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.21.1.tgz", "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.1.tgz",

View file

@ -33,7 +33,8 @@
"node-cron": "^4.0.7", "node-cron": "^4.0.7",
"node-ical": "^0.20.1", "node-ical": "^0.20.1",
"object-path-set": "^1.0.2", "object-path-set": "^1.0.2",
"svg2img": "^1.0.0-beta.2" "svg2img": "^1.0.0-beta.2",
"tzdata": "^1.0.44"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.29.0", "@eslint/js": "^9.29.0",

View file

@ -8,6 +8,7 @@ export enum TransformerType {
PermissionBoolean, PermissionBoolean,
String, String,
Paragraph, Paragraph,
Timezone
} }
type ConfigurationTransformerItem = { type ConfigurationTransformerItem = {
@ -35,6 +36,7 @@ export class ConfigurationTransformer {
return <ChannelId>configValue.value; return <ChannelId>configValue.value;
case TransformerType.PermissionBoolean: case TransformerType.PermissionBoolean:
return configValue.value === '1'; return configValue.value === '1';
case TransformerType.Timezone:
case TransformerType.Paragraph: case TransformerType.Paragraph:
case TransformerType.String: case TransformerType.String:
return configValue.value; return configValue.value;

View file

@ -11,7 +11,8 @@ import {ConfigurationTransformer, TransformerType} from "../ConfigurationTransfo
export type RuntimeGroupConfiguration = { export type RuntimeGroupConfiguration = {
channels: Nullable<ChannelRuntimeGroupConfiguration>, channels: Nullable<ChannelRuntimeGroupConfiguration>,
permissions: PermissionRuntimeGroupConfiguration, permissions: PermissionRuntimeGroupConfiguration,
calendar: CalendarRuntimeGroupConfiguration calendar: CalendarRuntimeGroupConfiguration,
timezone: string|null
}; };
export type ChannelRuntimeGroupConfiguration = { export type ChannelRuntimeGroupConfiguration = {
@ -49,7 +50,8 @@ export class GroupConfigurationProvider implements ConfigurationProvider<
title: null, title: null,
description: null, description: null,
location: null location: null
} },
timezone: null
} }
} }
@ -97,6 +99,10 @@ export class GroupConfigurationProvider implements ConfigurationProvider<
{ {
path: ['calendar', 'location'], path: ['calendar', 'location'],
type: TransformerType.String type: TransformerType.String
},
{
path: ['timezone'],
type: TransformerType.Timezone
} }
] ]
) )

View file

@ -8,7 +8,8 @@ import { Nullable } from "../../types/Nullable";
import {ConfigurationTransformer, TransformerType} from "../ConfigurationTransformer"; import {ConfigurationTransformer, TransformerType} from "../ConfigurationTransformer";
export type RuntimeServerConfiguration = { export type RuntimeServerConfiguration = {
permissions: PermissionRuntimeServerConfiguration permissions: PermissionRuntimeServerConfiguration,
timezone: string
} }
export type PermissionRuntimeServerConfiguration = { export type PermissionRuntimeServerConfiguration = {
@ -35,7 +36,8 @@ export class ServerConfigurationProvider implements ConfigurationProvider<
groupCreation: { groupCreation: {
allowEveryone: false allowEveryone: false
} }
} },
timezone: '',
} }
} }
get(path: string): Nullable<ServerConfigurationModel> { get(path: string): Nullable<ServerConfigurationModel> {
@ -62,6 +64,10 @@ export class ServerConfigurationProvider implements ConfigurationProvider<
{ {
path: ['permissions', 'groupCreation', 'allowEveryone'], path: ['permissions', 'groupCreation', 'allowEveryone'],
type: TransformerType.PermissionBoolean type: TransformerType.PermissionBoolean
},
{
path: ['timezone'],
type: TransformerType.Timezone
} }
] ]
) )

View file

@ -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<TReturn>(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 <string>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<ConfigurationHandler> {
if (!this.group) {
return null;
}
return new ConfigurationHandler(
new GroupConfigurationProvider(
Container.get<GroupConfigurationRepository>(GroupConfigurationRepository.name),
this.group
)
);
}
private getServerConfiguration(): ConfigurationHandler {
return new ConfigurationHandler(
new ServerConfigurationProvider(
Container.get<ServerConfigurationRepository>(ServerConfigurationRepository.name),
this.serverid
)
)
}
}

View file

@ -36,11 +36,11 @@ export class PlaydateRepository extends Repository<PlaydateModel, DBPlaydate> {
} }
findPlaydatesInRange(fromDate: Date, toDate: Date | undefined = undefined, group: GroupModel | undefined = undefined) { 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()]; const params = [fromDate.getTime()];
if (toDate) { if (toDate) {
sql = `${sql} AND time_from < ?` sql = `${sql} AND time_from <= ?`
params.push(toDate.getTime()); params.push(toDate.getTime());
} }

View file

@ -1,9 +1,10 @@
import { import {
AutocompleteInteraction, AutocompleteInteraction,
ChatInputCommandInteraction, ChatInputCommandInteraction,
EmbedBuilder,
GuildMember, GuildMember,
GuildMemberRoleManager, GuildMemberRoleManager,
hyperlink,
inlineCode,
InteractionReplyOptions, InteractionReplyOptions,
MessageFlags, MessageFlags,
PermissionFlagsBits, PermissionFlagsBits,
@ -35,8 +36,8 @@ import {PermissionError} from "../PermissionError";
import {EmbedLibrary, EmbedType} from "../EmbedLibrary"; import {EmbedLibrary, EmbedType} from "../EmbedLibrary";
import {EventHandler} from "../../Events/EventHandler"; import {EventHandler} from "../../Events/EventHandler";
import {ElementChangedEvent} from "../../Events/EventClasses/ElementChangedEvent"; import {ElementChangedEvent} from "../../Events/EventClasses/ElementChangedEvent";
import GroupConfiguration from "../../Database/tables/GroupConfiguration";
import Groups from "../../Database/tables/Groups"; import Groups from "../../Database/tables/Groups";
import {TimezoneHandler, TimezoneSaveTarget} from "../../Configuration/TimezoneHandler";
export class GroupCommand implements Command, ChatInteractionCommand, AutocompleteCommand { export class GroupCommand implements Command, ChatInteractionCommand, AutocompleteCommand {
private static GOODBYE_MESSAGES: string[] = [ private static GOODBYE_MESSAGES: string[] = [
@ -95,6 +96,16 @@ export class GroupCommand implements Command, ChatInteractionCommand, Autocomple
.setDescription("The member, that is the new leader") .setDescription("The member, that is the new leader")
.setRequired(true) .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": case "transfer":
await this.transferLeadership(interaction); await this.transferLeadership(interaction);
break; break;
case "timezone":
await this.handleTimezone(interaction);
break;
default: default:
throw new Error("Unsupported command"); throw new Error("Unsupported command");
} }
@ -395,4 +409,51 @@ export class GroupCommand implements Command, ChatInteractionCommand, Autocomple
flags: MessageFlags.Ephemeral, 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
)
]
}
)
}
} }

View file

@ -29,6 +29,8 @@ import {PermissionError} from "../PermissionError";
import {EmbedLibrary, EmbedType} from "../EmbedLibrary"; import {EmbedLibrary, EmbedType} from "../EmbedLibrary";
import {GroupConfigurationModel} from "../../Database/Models/GroupConfigurationModel"; import {GroupConfigurationModel} from "../../Database/Models/GroupConfigurationModel";
import parser from "any-date-parser"; import parser from "any-date-parser";
import {TimezoneHandler} from "../../Configuration/TimezoneHandler";
import _ from "lodash";
export class PlaydatesCommand implements Command, AutocompleteCommand, ChatInteractionCommand { export class PlaydatesCommand implements Command, AutocompleteCommand, ChatInteractionCommand {
definition(): SlashCommandBuilder { definition(): SlashCommandBuilder {
@ -123,8 +125,18 @@ export class PlaydatesCommand implements Command, AutocompleteCommand, ChatInter
) )
} }
const fromDate = parser.fromString(<string>interaction.options.get("from")?.value ?? ''); const timezoneHandler = new TimezoneHandler(
const toDate = parser.fromString(<string>interaction.options.get("to")?.value ?? ''); interaction.guildId ?? '',
group
);
const [fromDate, toDate] = timezoneHandler.use(() => {
return [
parser.fromString(<string>interaction.options.get("from")?.value ?? ''),
parser.fromString(<string>interaction.options.get("to")?.value ?? '')
]
})
if (!fromDate.isValid()) { if (!fromDate.isValid()) {
throw new UserError("No date or invalid date format for the from parameter."); throw new UserError("No date or invalid date format for the from parameter.");
@ -184,14 +196,20 @@ export class PlaydatesCommand implements Command, AutocompleteCommand, ChatInter
const group = GroupSelection.getGroup(interaction); const group = GroupSelection.getGroup(interaction);
const timezone = new TimezoneHandler(
interaction.guildId ?? '',
group
);
const playdates = Container.get<PlaydateRepository>(PlaydateRepository.name).findFromGroup(group); const playdates = Container.get<PlaydateRepository>(PlaydateRepository.name).findFromGroup(group);
await interaction.respond( await interaction.respond(
playdates.map(playdate => { timezone.use(() => {
return _.slice(playdates, 0, 25).map(playdate => {
return { return {
name: `${playdate.from_time.toLocaleString()} - ${playdate.to_time.toLocaleString()}`, name: `${playdate.from_time.toLocaleString()} - ${playdate.to_time.toLocaleString()}`,
value: <number>playdate.id value: <number>playdate.id
} }
}) })
})
) )
} }

View file

@ -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 {ChatInteractionCommand, Command} from "./Command";
import {GroupSelection} from "../CommandPartials/GroupSelection";
import {MenuHandler} from "../../Configuration/MenuHandler"; import {MenuHandler} from "../../Configuration/MenuHandler";
import {ConfigurationHandler} from "../../Configuration/ConfigurationHandler"; import {ConfigurationHandler} from "../../Configuration/ConfigurationHandler";
import {GroupConfigurationProvider} from "../../Configuration/Groups/GroupConfigurationProvider";
import {Container} from "../../Container/Container"; import {Container} from "../../Container/Container";
import {GroupConfigurationRepository} from "../../Database/Repositories/GroupConfigurationRepository";
import {MenuRenderer} from "../../Menu/MenuRenderer"; import {MenuRenderer} from "../../Menu/MenuRenderer";
import {MenuTraversal} from "../../Menu/MenuTraversal"; import {MenuTraversal} from "../../Menu/MenuTraversal";
import {MenuItemType} from "../../Menu/MenuRenderer.types"; import {MenuItemType} from "../../Menu/MenuRenderer.types";
import {ServerConfigurationProvider} from "../../Configuration/Server/ServerConfigurationProvider"; import {ServerConfigurationProvider} from "../../Configuration/Server/ServerConfigurationProvider";
import {ServerConfigurationRepository} from "../../Database/Repositories/ServerConfigurationRepository"; 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 { export class ServerCommand implements Command, ChatInteractionCommand {
definition(): SlashCommandBuilder { definition(): SlashCommandBuilder {
@ -22,6 +30,15 @@ export class ServerCommand implements Command, ChatInteractionCommand {
.setName("config") .setName("config")
.setDescription("Starts the configurator for the server settings") .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<CacheType>): Promise<void> { async execute(interaction: ChatInputCommandInteraction<CacheType>): Promise<void> {
@ -29,6 +46,9 @@ export class ServerCommand implements Command, ChatInteractionCommand {
case "config": case "config":
await this.startConfiguration(interaction); await this.startConfiguration(interaction);
break break
case "timezone":
await this.handleTimezone(interaction);
break;
} }
} }
@ -79,4 +99,46 @@ export class ServerCommand implements Command, ChatInteractionCommand {
menu.display(interaction); menu.display(interaction);
} }
private async handleTimezone(interaction: ChatInputCommandInteraction<CacheType>) {
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
)
]
}
)
}
} }

View file

@ -15,7 +15,7 @@ export class UserError extends Error {
public getEmbed(e: UserError): EmbedBuilder { public getEmbed(e: UserError): EmbedBuilder {
const embed = EmbedLibrary.base( const embed = EmbedLibrary.base(
"Please validate your request!", "Please validate your request!",
inlineCode(e.message), e.message,
EmbedType.Error EmbedType.Error
).setFooter({ ).setFooter({
text: "Type: Request" text: "Type: Request"