This commit is contained in:
Michel Fedde 2025-06-18 22:53:54 +02:00
parent 441715675c
commit a79898b2e9
48 changed files with 2062 additions and 1503 deletions

View file

@ -1,29 +1,28 @@
import {Class} from "../types/Class";
export class Container {
static instance: Container;
private instances: Map<string, object> = new Map();
public set<T extends Class>(instance: T, name: string|null = null): void
{
const settingName = name ?? instance.constructor.name;
this.instances.set(settingName.toLowerCase(), instance);
}
public get<T extends Class>(name: string): T
{
return <T>this.instances.get(name.toLowerCase());
}
static getInstance(): Container {
if (!Container.instance) {
Container.instance = new Container();
}
return Container.instance;
}
public static get<T>(name: string): T {
return Container.instance.get<T>(name);
static instance: Container;
private instances: Map<string, object> = new Map();
public set<T extends Class>(instance: T, name: string | null = null): void {
const settingName = name ?? instance.constructor.name;
this.instances.set(settingName.toLowerCase(), instance);
}
public get<T extends Class>(name: string): T {
return <T>this.instances.get(name.toLowerCase());
}
static getInstance(): Container {
if (!Container.instance) {
Container.instance = new Container();
}
return Container.instance;
}
public static get<T>(name: string): T {
return Container.instance.get<T>(name);
}
}

View file

@ -11,69 +11,77 @@ import {IconCache} from "../Icons/IconCache";
import {EventHandler} from "../Events/EventHandler";
import {InteractionRouter} from "../Discord/InteractionRouter";
import Commands from "../Discord/Commands/Commands";
import {CommandDeployer} from "../Discord/CommandDeployer";
import {REST} from "discord.js";
import {log} from "node:util";
export enum ServiceHint {
App,
Deploy
App,
Deploy
}
export class Services {
public static setup(container: Container, hint: ServiceHint) {
const env = new Environment();
env.setup();
container.set<Environment>(env);
const logger = this.setupLogger(hint);
container.set<Logger>(logger, 'logger');
public static setup(container: Container, hint: ServiceHint) {
const env = new Environment();
env.setup();
container.set<Environment>(env);
const logger = this.setupLogger(hint);
container.set<Logger>(logger, 'logger');
const database = new DatabaseConnection(env.database);
container.set<DatabaseConnection>(database);
const restClient = new REST();
const commands = new Commands();
const discordClient = new DiscordClient(
env.discord.clientId,
new InteractionRouter(commands, logger),
new CommandDeployer(env.discord.clientId, restClient, commands, logger),
logger,
restClient
);
container.set<DiscordClient>(discordClient);
const iconCache = new IconCache(discordClient);
container.set<IconCache>(iconCache);
container.set<EventHandler>(new EventHandler());
this.setupRepositories(container);
}
private static setupRepositories(container: Container) {
const db = container.get<DatabaseConnection>(DatabaseConnection.name);
container.set<GroupRepository>(new GroupRepository(db));
container.set<PlaydateRepository>(new PlaydateRepository(db, container.get<GroupRepository>(GroupRepository.name)))
container.set<GroupConfigurationRepository>(new GroupConfigurationRepository(db, container.get<GroupRepository>(GroupRepository.name)))
}
private static setupLogger(hint: ServiceHint): Logger {
configure({
appenders: {
out: {type: "stdout"},
appLogFile: {type: "file", filename: path.resolve("logs/run.log")},
deployLogFile: {type: "file", filename: path.resolve("logs/deploy.log")},
},
categories: {
default: {appenders: ['out'], level: 'debug'},
app: {appenders: ["out", "appLogFile"], level: "debug"},
deploy: {appenders: ["out", "deployLogFile"], level: "debug"},
}
})
let loggername = '';
switch (hint) {
case ServiceHint.App:
loggername = "app";
break;
case ServiceHint.Deploy:
loggername = "deploy";
break;
const database = new DatabaseConnection(env.database);
container.set<DatabaseConnection>(database);
const discordClient = new DiscordClient(
env.discord.clientId,
new InteractionRouter(new Commands(), logger)
);
container.set<DiscordClient>(discordClient);
const iconCache = new IconCache(discordClient);
container.set<IconCache>(iconCache);
container.set<EventHandler>(new EventHandler());
this.setupRepositories(container);
}
private static setupRepositories(container: Container) {
const db = container.get<DatabaseConnection>(DatabaseConnection.name);
container.set<GroupRepository>(new GroupRepository(db));
container.set<PlaydateRepository>(new PlaydateRepository(db, container.get<GroupRepository>(GroupRepository.name)))
container.set<GroupConfigurationRepository>(new GroupConfigurationRepository(db, container.get<GroupRepository>(GroupRepository.name)))
}
private static setupLogger(hint: ServiceHint): Logger {
configure({
appenders: {
out: { type: "stdout" },
appLogFile: { type: "file", filename: path.resolve("logs/run.log")},
deployLogFile: { type: "file", filename: path.resolve("logs/deploy.log")},
},
categories: {
default: { appenders: ['out'], level: 'debug' },
app: { appenders: ["out", "appLogFile"], level: "debug" },
deploy: { appenders: ["out", "deployLogFile"], level: "debug" },
}
})
let loggername = '';
switch (hint) {
case ServiceHint.App:
loggername = "app";
break;
case ServiceHint.Deploy:
loggername = "deploy";
break;
}
return getLogger(loggername);
}
return getLogger(loggername);
}
}

View file

@ -5,38 +5,39 @@ import {Container} from "../Container/Container";
import {Logger} from "log4js";
export class DatabaseConnection {
private database: Sqlite3.Database;
constructor(env: DatabaseEnvironment) {
this.database = new Database(env.path, {
nativeBinding: "node_modules/better-sqlite3/build/Release/better_sqlite3.node",
})
this.database.pragma('journal_mode = WAL');
}
public execute(query: string, ...args: unknown[]): Sqlite3.RunResult {
try {
const preparedQuery = this.database.prepare(query);
return preparedQuery.run(args);
} catch (error) {
Container.get<Logger>("logger").error("Failed to execute database connection", error, query, args);
throw error;
}
}
private database: Sqlite3.Database;
public fetch<BindParameters extends unknown[] | {} = unknown[], Result = unknown>(query: string, ...args: unknown[]): Result|undefined {
const preparedQuery = this.database.prepare<BindParameters, Result>(query);
return preparedQuery.get(args);
}
public fetchAll<BindParameters extends unknown[] | {} = unknown[], Result = unknown>(query: string, ...args: unknown[]): Result[] {
const preparedQuery = this.database.prepare<BindParameters, Result>(query);
return preparedQuery.all(args);
}
public hasTable(tableName: string): boolean {
const sql = "SELECT COUNT(*) as tableCount FROM sqlite_master WHERE type='table' AND name=? LIMIT 1;";
const result = this.fetch<string[], {tableCount: number}>(sql, tableName);
return result != undefined && result.tableCount > 0;
constructor(env: DatabaseEnvironment) {
this.database = new Database(env.path, {
nativeBinding: "node_modules/better-sqlite3/build/Release/better_sqlite3.node",
})
this.database.pragma('journal_mode = WAL');
}
public execute(query: string, ...args: unknown[]): Sqlite3.RunResult {
try {
const preparedQuery = this.database.prepare(query);
return preparedQuery.run(args);
} catch (error) {
Container.get<Logger>("logger").error("Failed to execute database connection", error, query, args);
throw error;
}
}
public fetch<BindParameters extends unknown[] | {} = unknown[], Result = unknown>(query: string, ...args: unknown[]): Result | undefined {
const preparedQuery = this.database.prepare<BindParameters, Result>(query);
return preparedQuery.get(args);
}
public fetchAll<BindParameters extends unknown[] | {} = unknown[], Result = unknown>(query: string, ...args: unknown[]): Result[] {
const preparedQuery = this.database.prepare<BindParameters, Result>(query);
return preparedQuery.all(args);
}
public hasTable(tableName: string): boolean {
const sql = "SELECT COUNT(*) as tableCount FROM sqlite_master WHERE type='table' AND name=? LIMIT 1;";
const result = this.fetch<string[], { tableCount: number }>(sql, tableName);
return result != undefined && result.tableCount > 0;
}
}

View file

@ -1,13 +1,13 @@
export type DatabaseColumnDefinition = {
name: string;
type: string;
primaryKey?: boolean;
autoIncrement?: boolean;
notNull?: boolean;
options?: string;
name: string;
type: string;
primaryKey?: boolean;
autoIncrement?: boolean;
notNull?: boolean;
options?: string;
}
export type DatabaseDefinition = {
name: string;
columns: DatabaseColumnDefinition[];
name: string;
columns: DatabaseColumnDefinition[];
}

View file

@ -4,78 +4,82 @@ import {Container} from "../Container/Container";
import {Logger} from "log4js";
export class DatabaseUpdater {
constructor(private readonly database: DatabaseConnection) {}
public ensureAvaliablity(definitions: Iterable<DatabaseDefinition>) {
for (const definition of definitions) {
this.ensureDefinition(definition);
}
}
private ensureDefinition(definition: DatabaseDefinition) {
if (this.database.hasTable(definition.name)) {
this.ensureTableColumns(definition);
return;
}
this.createTable(definition);
}
private ensureTableColumns(definition: DatabaseDefinition) {
const DBSQLColumns = this.database.fetchAll<object, {name: string, type: string}>(
`PRAGMA table_info("${definition.name}")`
);
if (!DBSQLColumns) {
Container.get<Logger>("logger").log("Request failed...");
return;
}
const missingColumns = definition.columns.filter(
(column: DatabaseColumnDefinition) => {
return !DBSQLColumns.some((dbColumn: DatabaseColumnDefinition) => {
return column.name === dbColumn.name
});
}
)
if (missingColumns.length < 1) {
Container.get<Logger>("logger").log(`No new columns found for ${definition.name}`)
return;
}
const columnsSQL = missingColumns.map((column: DatabaseColumnDefinition) => {
const values = [
"ADD",
column.name,
column.type,
column.primaryKey ? `PRIMARY KEY` : '',
column.notNull ? 'NOT NULL' : '',
column.autoIncrement ? 'AUTOINCREMENT' : '',
]
constructor(private readonly database: DatabaseConnection) {
}
return values.join(' ');
}).join(', ');
const sql = `ALTER TABLE ${definition.name} ${columnsSQL}`;
this.database.execute(sql);
public ensureAvaliablity(definitions: Iterable<DatabaseDefinition>) {
for (const definition of definitions) {
this.ensureDefinition(definition);
}
}
private ensureDefinition(definition: DatabaseDefinition) {
if (this.database.hasTable(definition.name)) {
this.ensureTableColumns(definition);
return;
}
this.createTable(definition);
}
private createTable(definition: DatabaseDefinition) {
const columnsSQL = definition.columns.map((column: DatabaseColumnDefinition) => {
const values = [
column.name,
column.type,
column.primaryKey ? `PRIMARY KEY` : '',
column.notNull ? 'NOT NULL' : '',
column.autoIncrement ? 'AUTOINCREMENT' : '',
]
return values.join(' ');
}).join(', ');
const sql = `CREATE TABLE IF NOT EXISTS ${definition.name} (${columnsSQL})`;
this.database.execute(sql);
private ensureTableColumns(definition: DatabaseDefinition) {
const DBSQLColumns = this.database.fetchAll<object, { name: string, type: string }>(
`PRAGMA table_info("${definition.name}")`
);
if (!DBSQLColumns) {
Container.get<Logger>("logger").log("Request failed...");
return;
}
const missingColumns = definition.columns.filter(
(column: DatabaseColumnDefinition) => {
return !DBSQLColumns.some((dbColumn: DatabaseColumnDefinition) => {
return column.name === dbColumn.name
});
}
)
if (missingColumns.length < 1) {
Container.get<Logger>("logger").log(`No new columns found for ${definition.name}`)
return;
}
const columnsSQL = missingColumns.map((column: DatabaseColumnDefinition) => {
const values = [
"ADD",
column.name,
column.type,
column.primaryKey ? `PRIMARY KEY` : '',
column.notNull ? 'NOT NULL' : '',
column.autoIncrement ? 'AUTOINCREMENT' : '',
]
return values.join(' ');
}).join(', ');
const sql = `ALTER TABLE ${definition.name} ${columnsSQL}`;
this.database.execute(sql);
}
private createTable(definition: DatabaseDefinition) {
const columnsSQL = definition.columns.map((column: DatabaseColumnDefinition) => {
const values = [
column.name,
column.type,
column.primaryKey ? `PRIMARY KEY` : '',
column.notNull ? 'NOT NULL' : '',
column.autoIncrement ? 'AUTOINCREMENT' : '',
]
return values.join(' ');
}).join(', ');
const sql = `CREATE TABLE IF NOT EXISTS ${definition.name}
(
${columnsSQL}
)`;
this.database.execute(sql);
}
}

View file

@ -4,9 +4,9 @@ import Playdate from "./tables/Playdate";
import GroupConfiguration from "./tables/GroupConfiguration";
const definitions = new Set<DatabaseDefinition>([
Groups,
Playdate,
GroupConfiguration
Groups,
Playdate,
GroupConfiguration
]);
export default definitions;

View file

@ -1,34 +1,34 @@
import {DatabaseDefinition} from "../DatabaseDefinition";
export type DBGroupConfiguration = {
id: number;
groupid: number;
key: string;
value: string;
id: number;
groupid: number;
key: string;
value: string;
}
const dbDefinition: DatabaseDefinition = {
name: "groupConfiguration",
columns: [
{
name: "id",
type: "INTEGER",
autoIncrement: true,
primaryKey: true,
},
{
name: "groupid",
type: "VARCHAR(32)",
},
{
name: "key",
type: "VARCHAR(32)",
},
{
name: "value",
type: "VARCHAR(128)",
}
]
name: "groupConfiguration",
columns: [
{
name: "id",
type: "INTEGER",
autoIncrement: true,
primaryKey: true,
},
{
name: "groupid",
type: "VARCHAR(32)",
},
{
name: "key",
type: "VARCHAR(32)",
},
{
name: "value",
type: "VARCHAR(128)",
}
]
}
export default dbDefinition;

View file

@ -1,39 +1,39 @@
import {DatabaseDefinition} from "../DatabaseDefinition";
export type DBGroup = {
id: number;
name: string;
server: string;
leader: string;
role: string;
id: number;
name: string;
server: string;
leader: string;
role: string;
}
const dbDefinition: DatabaseDefinition = {
name: "groups",
columns: [
{
name: "id",
type: "INTEGER",
autoIncrement: true,
primaryKey: true,
},
{
name: "server",
type: "VARCHAR(32)"
},
{
name: "name",
type: "VARCHAR(32)",
},
{
name: "leader",
type: "VARCHAR(32)",
},
{
name: "role",
type: "VARCHAR(32)",
}
]
name: "groups",
columns: [
{
name: "id",
type: "INTEGER",
autoIncrement: true,
primaryKey: true,
},
{
name: "server",
type: "VARCHAR(32)"
},
{
name: "name",
type: "VARCHAR(32)",
},
{
name: "leader",
type: "VARCHAR(32)",
},
{
name: "role",
type: "VARCHAR(32)",
}
]
}
export default dbDefinition;

View file

@ -1,35 +1,35 @@
import {DatabaseDefinition} from "../DatabaseDefinition";
export type DBPlaydate = {
id: number;
groupid: number;
id: number;
groupid: number;
time_from: number;
time_to: number;
time_from: number;
time_to: number;
}
const dbDefinition: DatabaseDefinition = {
name: "playdates",
columns: [
{
name: "id",
type: "INTEGER",
autoIncrement: true,
primaryKey: true,
},
{
name: "groupid",
type: "INTEGER",
},
{
name: "time_from",
type: "TIMESTAMP",
},
{
name: "time_to",
type: "TIMESTAMP",
}
]
name: "playdates",
columns: [
{
name: "id",
type: "INTEGER",
autoIncrement: true,
primaryKey: true,
},
{
name: "groupid",
type: "INTEGER",
},
{
name: "time_from",
type: "TIMESTAMP",
},
{
name: "time_to",
type: "TIMESTAMP",
}
]
}
export default dbDefinition;

View file

@ -1,36 +1,28 @@
import {DiscordClient} from "./DiscordClient";
import {Logger} from "log4js";
import {Routes, Snowflake} from "discord.js";
import {REST, Routes, Snowflake} from "discord.js";
import Commands from "./Commands/Commands";
export class CommandDeployer {
constructor(
private readonly client: DiscordClient,
private readonly logger: Logger
) {
}
public async deployAvailableServers() {
const commandInfos: object[] = [];
this.client.Commands.allCommands.forEach((command) => {
commandInfos.push(command.definition().toJSON())
})
const guilds = await this.client.RESTClient.get(Routes.userGuilds());
const deployments = guilds.map(guild => {
return this.deployServer(commandInfos, guild.id)
})
await Promise.all(deployments);
}
private async deployServer(commandInfos: object[], serverId: Snowflake) {
this.logger.log(`Started refreshing ${commandInfos.length} application (/) commands for ${serverId}.`);
constructor(
private readonly applicationId: string,
private readonly restClient: REST,
private readonly commands: Commands,
private readonly logger: Logger
) {
}
// The put method is used to fully refresh all commands in the guild with the current set
await this.client.RESTClient.put(
Routes.applicationGuildCommands(this.client.ApplicationId, serverId),
{ body: commandInfos },
);
this.logger.log(`Successfully reloaded ${commandInfos.length} application (/) commands for ${serverId}.`);
}
public async deployServer(serverId: Snowflake) {
const commandInfos = this.commands.getJsonDefinitions();
this.logger.debug(`Started refreshing ${commandInfos.length} application (/) commands for ${serverId}.`);
// The put method is used to fully refresh all commands in the guild with the current set
await this.restClient.put(
Routes.applicationGuildCommands(this.applicationId, serverId),
{body: commandInfos},
);
this.logger.debug(`Successfully reloaded ${commandInfos.length} application (/) commands for ${serverId}.`);
}
}

View file

@ -1,7 +1,7 @@
import {
AutocompleteInteraction,
CommandInteraction,
GuildMember, SlashCommandIntegerOption,
AutocompleteInteraction,
CommandInteraction,
GuildMember, SlashCommandIntegerOption,
} from "discord.js";
import {Container} from "../../Container/Container";
import {GroupRepository} from "../../Repositories/GroupRepository";
@ -9,36 +9,36 @@ import {GroupModel} from "../../Models/GroupModel";
import {UserError} from "../UserError";
export class GroupSelection {
public static createOptionSetup(): SlashCommandIntegerOption {
return new SlashCommandIntegerOption()
.setName("group")
.setDescription("Defines the group this action is for")
.setRequired(true)
.setAutocomplete(true)
public static createOptionSetup(): SlashCommandIntegerOption {
return new SlashCommandIntegerOption()
.setName("group")
.setDescription("Defines the group this action is for")
.setRequired(true)
.setAutocomplete(true)
}
public static async handleAutocomplete(interaction: AutocompleteInteraction, onlyLeaders: boolean = false): Promise<void> {
const value = interaction.options.getFocused();
const repo = Container.get<GroupRepository>(GroupRepository.name);
const groups = repo.findGroupsByMember(<GuildMember>interaction.member, onlyLeaders);
await interaction.respond(
groups
.filter((group) => group.name.startsWith(value))
.map((group) => ({name: group.name, value: group.id}))
)
}
public static getGroup(interaction: CommandInteraction | AutocompleteInteraction): GroupModel {
const groupname = interaction.options.get("group", true);
if (!groupname) {
throw new UserError("No group name provided");
}
public static async handleAutocomplete(interaction: AutocompleteInteraction, onlyLeaders: boolean = false): Promise<void> {
const value = interaction.options.getFocused();
const repo = Container.get<GroupRepository>(GroupRepository.name);
const groups = repo.findGroupsByMember(<GuildMember>interaction.member, onlyLeaders);
await interaction.respond(
groups
.filter((group) => group.name.startsWith(value))
.map((group) => ({name: group.name, value: group.id }))
)
}
public static getGroup(interaction: CommandInteraction|AutocompleteInteraction): GroupModel {
const groupname = interaction.options.get("group", true);
if (!groupname) {
throw new UserError("No group name provided");
}
const group = Container.get<GroupRepository>(GroupRepository.name).getById(<number>(groupname.value ?? 0));
if (!group) {
throw new UserError("No group found");
}
return <GroupModel>group;
const group = Container.get<GroupRepository>(GroupRepository.name).getById(<number>(groupname.value ?? 0));
if (!group) {
throw new UserError("No group found");
}
return <GroupModel>group;
}
}

View file

@ -1,16 +1,16 @@
import {ChatInputCommandInteraction, Interaction, SlashCommandBuilder} from "discord.js";
export interface Command {
definition(): SlashCommandBuilder;
definition(): SlashCommandBuilder;
}
export interface ChatInteractionCommand {
execute(interaction: ChatInputCommandInteraction): Promise<void>;
execute(interaction: ChatInputCommandInteraction): Promise<void>;
}
export interface AutocompleteCommand {
handleAutocomplete(interaction: Interaction): Promise<void>;
handleAutocomplete(interaction: Interaction): Promise<void>;
}
export type CommandUnion =
Command | Partial<ChatInteractionCommand> | Partial<AutocompleteCommand>;
export type CommandUnion =
Command | Partial<ChatInteractionCommand> | Partial<AutocompleteCommand>;

View file

@ -2,40 +2,54 @@ import {HelloWorldCommand} from "./HelloWorldCommand";
import {Command, CommandUnion} from "./Command";
import {GroupCommand} from "./Groups";
import {PlaydatesCommand} from "./Playdates";
import {RESTPostAPIChatInputApplicationCommandsJSONBody} from "discord.js";
import {Nullable} from "../../types/Nullable";
const commands: Set<Command> = new Set<Command>([
new HelloWorldCommand(),
new GroupCommand(),
new PlaydatesCommand()
new HelloWorldCommand(),
new GroupCommand(),
new PlaydatesCommand()
]);
export default class Commands {
private mappedCommands: Map<string, Command> = new Map<string, Command>();
constructor() {
this.mappedCommands = this.getMap();
private mappedCommands: Map<string, Command> = new Map<string, Command>();
private definitions: Nullable<RESTPostAPIChatInputApplicationCommandsJSONBody[]>;
constructor() {
this.mappedCommands = this.getMap();
}
public getCommand(commandName: string): CommandUnion | undefined {
if (!this.mappedCommands.has(commandName)) {
throw new Error(`Unknown command: ${commandName}`);
}
public getCommand(commandName: string): CommandUnion|undefined {
if (!this.mappedCommands.has(commandName)) {
throw new Error(`Unknown command: ${commandName}`);
}
return this.mappedCommands.get(commandName);
return this.mappedCommands.get(commandName);
}
public get allCommands(): Set<Command> {
return commands;
}
public getJsonDefinitions(): RESTPostAPIChatInputApplicationCommandsJSONBody[] {
if (this.definitions) {
return this.definitions;
}
public get allCommands(): Set<Command> {
return commands;
}
private getMap(): Map<string, Command>
{
const map = new Map<string, Command>();
for (const command of commands) {
const definition = command.definition()
map.set(definition.name, command);
}
return map;
this.definitions = [];
commands.forEach((command) => {
this.definitions?.push(command.definition().toJSON())
})
return this.definitions;
}
private getMap(): Map<string, Command> {
const map = new Map<string, Command>();
for (const command of commands) {
const definition = command.definition()
map.set(definition.name, command);
}
return map;
}
}

View file

@ -1,12 +1,12 @@
import {
SlashCommandBuilder,
ChatInputCommandInteraction,
MessageFlags,
InteractionReplyOptions,
GuildMember,
EmbedBuilder,
AutocompleteInteraction,
roleMention, time, userMention, GuildMemberRoleManager
MessageFlags,
InteractionReplyOptions,
GuildMember,
EmbedBuilder,
AutocompleteInteraction,
roleMention, time, userMention, GuildMemberRoleManager
} from "discord.js";
import {AutocompleteCommand, ChatInteractionCommand, Command} from "./Command";
import {GroupModel} from "../../Models/GroupModel";
@ -142,10 +142,10 @@ export class GroupCommand implements Command, ChatInteractionCommand, Autocomple
Container.get<GroupRepository>(GroupRepository.name).create(group);
interaction.reply({content: `:white_check_mark: Created group \`${name}\``, flags: MessageFlags.Ephemeral })
interaction.reply({content: `:white_check_mark: Created group \`${name}\``, flags: MessageFlags.Ephemeral})
}
private validateGroupName(name: string): true|string{
private validateGroupName(name: string): true | string {
const lowercaseName = name.toLowerCase();
for (const invalidcharactersequence of GroupCommand.INVALID_CHARACTER_SEQUENCES) {
if (!lowercaseName.includes(invalidcharactersequence)) {
@ -162,7 +162,7 @@ export class GroupCommand implements Command, ChatInteractionCommand, Autocomple
const groups = repo.findGroupsByMember(<GuildMember>interaction.member);
const playdateRepo = Container.get<PlaydateRepository>(PlaydateRepository.name);
const embed = new EmbedBuilder()
.setTitle("Your groups on this server:")
.setFields(
@ -191,7 +191,7 @@ export class GroupCommand implements Command, ChatInteractionCommand, Autocomple
embeds: [
embed
],
allowedMentions: { roles: [] },
allowedMentions: {roles: []},
flags: MessageFlags.Ephemeral
}

View file

@ -2,24 +2,24 @@ import {SlashCommandBuilder, CommandInteraction} from "discord.js";
import {Command} from "./Command";
export class HelloWorldCommand implements Command {
private static RESPONSES: string[] = [
'Hello :)',
'zzzZ... ZzzzZ... huh? I am awake. I am awake!',
'Roll for initiative!',
'I was an adventurer like you...',
'Hello :) How are you?',
]
definition(): SlashCommandBuilder
{
return new SlashCommandBuilder()
.setName("hello")
.setDescription("Displays a random response. (commonly used to test if the bot is online)")
}
async execute(interaction: CommandInteraction): Promise<void> {
const random = Math.floor(Math.random() * HelloWorldCommand.RESPONSES.length);
await interaction.reply(HelloWorldCommand.RESPONSES[random]);
return Promise.resolve();
}
private static RESPONSES: string[] = [
'Hello :)',
'zzzZ... ZzzzZ... huh? I am awake. I am awake!',
'Roll for initiative!',
'I was an adventurer like you...',
'Hello :) How are you?',
]
definition(): SlashCommandBuilder {
return new SlashCommandBuilder()
.setName("hello")
.setDescription("Displays a random response. (commonly used to test if the bot is online)")
}
async execute(interaction: CommandInteraction): Promise<void> {
const random = Math.floor(Math.random() * HelloWorldCommand.RESPONSES.length);
await interaction.reply(HelloWorldCommand.RESPONSES[random]);
return Promise.resolve();
}
}

View file

@ -1,8 +1,8 @@
import {
SlashCommandBuilder,
CommandInteraction,
AutocompleteInteraction,
EmbedBuilder, MessageFlags, ChatInputCommandInteraction, time
SlashCommandBuilder,
CommandInteraction,
AutocompleteInteraction,
EmbedBuilder, MessageFlags, ChatInputCommandInteraction, time, AttachmentBuilder, ActivityFlagsBitField, Options
} from "discord.js";
import {AutocompleteCommand, ChatInteractionCommand, Command} from "./Command";
import {Container} from "../../Container/Container";
@ -11,195 +11,325 @@ import {UserError} from "../UserError";
import {PlaydateModel} from "../../Models/PlaydateModel";
import {PlaydateRepository} from "../../Repositories/PlaydateRepository";
import {GroupModel} from "../../Models/GroupModel";
import * as ics from 'ics';
import ical from 'node-ical';
export class PlaydatesCommand implements Command, AutocompleteCommand, ChatInteractionCommand {
static REGEX = [
]
definition(): SlashCommandBuilder {
// @ts-expect-error Command builder is improperly marked as incomplete.
return new SlashCommandBuilder()
.setName("playdates")
.setDescription("Manage your playdates")
.addSubcommand((subcommand) => subcommand
.setName("create")
.setDescription("Creates a new playdate")
.addIntegerOption(GroupSelection.createOptionSetup())
.addStringOption((option) => option
.setName("from")
.setDescription("Defines the start date & time. Format: YYYY-MM-DD HH:mm")
)
.addStringOption((option) => option
.setName("to")
.setDescription("Defines the end date & time. Format: YYYY-MM-DD HH:mm")
)
)
.addSubcommand((subcommand) => subcommand
.setName("list")
.setDescription("Lists all playdates")
.addIntegerOption(GroupSelection.createOptionSetup())
)
.addSubcommand((subcommand) => subcommand
.setName("remove")
.setDescription("Removes a playdate")
.addIntegerOption(GroupSelection.createOptionSetup())
.addIntegerOption((option) => option
.setName("playdate")
.setDescription("Selects a playdate")
.setRequired(true)
.setAutocomplete(true)
)
);
}
async execute(interaction: ChatInputCommandInteraction): Promise<void> {
const group = GroupSelection.getGroup(interaction);
switch (interaction.options.getSubcommand()) {
case "create":
await this.create(interaction, group);
break;
case "remove":
await this.delete(interaction, group);
break;
case "list":
await this.list(interaction, group);
break;
default:
throw new UserError("This subcommand is not yet implemented.");
}
}
async create(interaction: CommandInteraction, group: GroupModel): Promise<void> {
const fromDate = Date.parse(<string>interaction.options.get("from")?.value ?? '');
const toDate = Date.parse(<string>interaction.options.get("to")?.value ?? '');
if (isNaN(fromDate)) {
throw new UserError("No date or invalid date format for the from parameter.");
}
if (isNaN(toDate)) {
throw new UserError("No date or invalid date format for the to parameter.");
}
if (fromDate > toDate) {
throw new UserError("The to-date can't be earlier than the from-date");
}
const playdateRepo = Container.get<PlaydateRepository>(PlaydateRepository.name);
const collidingTimes = playdateRepo.findPlaydatesInRange(fromDate, toDate, group);
if (collidingTimes.length > 0) {
throw new UserError("The playdate collides with another playdate. Please either remove the old one or choose a different time.")
}
const playdate: Partial<PlaydateModel> = {
group: group,
from_time: new Date(fromDate),
to_time: new Date(toDate),
}
playdateRepo.create(playdate);
const embed = new EmbedBuilder()
.setTitle("Created a play-date.")
.setDescription(":white_check_mark: Your playdate has been created! You and your group get notified, when its time.")
.setFields({
name: "Created playdate",
value: `${time(new Date(fromDate),'F')} - ${time(new Date(toDate), 'F')}`,
})
.setFooter({
text: `Group: ${group.name}`
})
await interaction.reply({
embeds: [
embed
],
flags: MessageFlags.Ephemeral,
})
}
async handleAutocomplete(interaction: AutocompleteInteraction): Promise<void> {
const option = interaction.options.getFocused(true);
if (option.name == "group") {
await GroupSelection.handleAutocomplete(interaction);
return;
}
if (option.name != 'playdate') {
return;
}
const group = GroupSelection.getGroup(interaction);
const playdates = Container.get<PlaydateRepository>(PlaydateRepository.name).findFromGroup(group);
await interaction.respond(
playdates.map(playdate => {
return {
name: `${playdate.from_time.toLocaleString()} - ${playdate.to_time.toLocaleString()}`,
value: playdate.id
}
})
definition(): SlashCommandBuilder {
// @ts-expect-error Command builder is improperly marked as incomplete.
return new SlashCommandBuilder()
.setName("playdates")
.setDescription("Manage your playdates")
.addSubcommand((subcommand) => subcommand
.setName("create")
.setDescription("Creates a new playdate")
.addIntegerOption(GroupSelection.createOptionSetup())
.addStringOption((option) => option
.setName("from")
.setDescription("Defines the start date & time. Format: YYYY-MM-DD HH:mm")
)
.addStringOption((option) => option
.setName("to")
.setDescription("Defines the end date & time. Format: YYYY-MM-DD HH:mm")
)
)
.addSubcommand((subcommand) => subcommand
.setName("list")
.setDescription("Lists all playdates")
.addIntegerOption(GroupSelection.createOptionSetup())
)
.addSubcommand((subcommand) => subcommand
.setName("remove")
.setDescription("Removes a playdate")
.addIntegerOption(GroupSelection.createOptionSetup())
.addIntegerOption((option) => option
.setName("playdate")
.setDescription("Selects a playdate")
.setRequired(true)
.setAutocomplete(true)
)
)
.addSubcommand(subcommand => subcommand
.setName('import')
.setDescription("Imports playdates from iCal files")
.addIntegerOption(GroupSelection.createOptionSetup())
.addAttachmentOption(attachment => attachment
.setName("file")
.setDescription("The iCal File to import")
.setRequired(true)
)
)
.addSubcommand((subcommand) => subcommand
.setName("export")
.setDescription("Exports a playdate as iCal file.")
.addIntegerOption(GroupSelection.createOptionSetup())
.addIntegerOption((option) => option
.setName("playdate")
.setDescription("Selects a playdate to export")
.setAutocomplete(true)
)
.addBooleanOption(option => option
.setName("future-dates")
.setDescription("Exports the next playdates as a ical file")
)
);
}
async execute(interaction: ChatInputCommandInteraction): Promise<void> {
const group = GroupSelection.getGroup(interaction);
switch (interaction.options.getSubcommand()) {
case "create":
await this.create(interaction, group);
break;
case "remove":
await this.delete(interaction, group);
break;
case "list":
await this.list(interaction, group);
break;
case "import":
await this.import(interaction, group);
break;
case "export":
await this.export(interaction, group);
break;
default:
throw new UserError("This subcommand is not yet implemented.");
}
}
async create(interaction: CommandInteraction, group: GroupModel): Promise<void> {
const fromDate = Date.parse(<string>interaction.options.get("from")?.value ?? '');
const toDate = Date.parse(<string>interaction.options.get("to")?.value ?? '');
if (isNaN(fromDate)) {
throw new UserError("No date or invalid date format for the from parameter.");
}
private async list(interaction: ChatInputCommandInteraction, group: GroupModel) {
const playdates = Container.get<PlaydateRepository>(PlaydateRepository.name).findFromGroup(group);
if (isNaN(toDate)) {
throw new UserError("No date or invalid date format for the to parameter.");
}
const embed = new EmbedBuilder()
.setTitle("The next playdates:")
.setFields(
playdates.map((playdate) =>
{
return {
name: `${time(playdate.from_time, 'F')} - ${time(playdate.to_time, 'F')}`,
value: `${time(playdate.from_time, 'R')}`
}
})
)
.setFooter({
text: `Group: ${group.name}`
})
await interaction.reply({
embeds: [
embed
],
flags: MessageFlags.Ephemeral,
if (fromDate > toDate) {
throw new UserError("The to-date can't be earlier than the from-date");
}
const playdateRepo = Container.get<PlaydateRepository>(PlaydateRepository.name);
const collidingTimes = playdateRepo.findPlaydatesInRange(fromDate, toDate, group);
if (collidingTimes.length > 0) {
throw new UserError("The playdate collides with another playdate. Please either remove the old one or choose a different time.")
}
const playdate: Partial<PlaydateModel> = {
group: group,
from_time: new Date(fromDate),
to_time: new Date(toDate),
}
playdateRepo.create(playdate);
const embed = new EmbedBuilder()
.setTitle("Created a play-date.")
.setDescription(":white_check_mark: Your playdate has been created! You and your group get notified, when its time.")
.setFields({
name: "Created playdate",
value: `${time(new Date(fromDate), 'F')} - ${time(new Date(toDate), 'F')}`,
})
.setFooter({
text: `Group: ${group.name}`
})
await interaction.reply({
embeds: [
embed
],
flags: MessageFlags.Ephemeral,
})
}
async handleAutocomplete(interaction: AutocompleteInteraction): Promise<void> {
const option = interaction.options.getFocused(true);
if (option.name == "group") {
await GroupSelection.handleAutocomplete(interaction);
return;
}
if (option.name != 'playdate') {
return;
}
const group = GroupSelection.getGroup(interaction);
const playdates = Container.get<PlaydateRepository>(PlaydateRepository.name).findFromGroup(group);
await interaction.respond(
playdates.map(playdate => {
return {
name: `${playdate.from_time.toLocaleString()} - ${playdate.to_time.toLocaleString()}`,
value: <number>playdate.id
}
})
)
}
private async list(interaction: ChatInputCommandInteraction, group: GroupModel) {
const playdates = Container.get<PlaydateRepository>(PlaydateRepository.name).findFromGroup(group);
const embed = new EmbedBuilder()
.setTitle("The next playdates:")
.setFields(
playdates.map((playdate) => {
return {
name: `${time(playdate.from_time, 'F')} - ${time(playdate.to_time, 'F')}`,
value: `${time(playdate.from_time, 'R')}`
}
})
)
.setFooter({
text: `Group: ${group.name}`
})
await interaction.reply({
embeds: [
embed
],
flags: MessageFlags.Ephemeral,
})
}
private async delete(interaction: ChatInputCommandInteraction, group: GroupModel): Promise<void> {
const playdateId = interaction.options.getInteger("playdate", true)
const repo = Container.get<PlaydateRepository>(PlaydateRepository.name);
const selected = repo.getById(playdateId);
if (!selected) {
throw new UserError("No playdate found");
}
if (selected.group?.id != group.id) {
throw new UserError("No playdate found");
}
repo.delete(playdateId);
const embed = new EmbedBuilder()
.setTitle("Playdate deleted")
.setDescription(
`:x: Deleted \`${selected.from_time.toLocaleString()} - ${selected.to_time.toLocaleString()}\``
)
.setFooter({
text: `Group: ${group.name}`
})
await interaction.reply({
embeds: [
embed
],
flags: MessageFlags.Ephemeral,
})
}
private async import(interaction: ChatInputCommandInteraction, group: GroupModel): Promise<void> {
const file = interaction.options.getAttachment('file', true);
const mimeType = file.contentType?.split(';')[0];
if (mimeType !== "text/calendar") {
throw new UserError(`Invalid ical file. Got: ${mimeType}`, "Providing a valid iCal file");
}
private async delete(interaction: ChatInputCommandInteraction, group: GroupModel): Promise<void> {
const playdateId = interaction.options.getInteger("playdate", true)
const repo = Container.get<PlaydateRepository>(PlaydateRepository.name);
const selected = repo.getById(playdateId);
if (!selected) {
throw new UserError("No playdate found");
}
if (selected.group?.id != group.id) {
throw new UserError("No playdate found");
}
repo.delete(playdateId);
const embed = new EmbedBuilder()
.setTitle("Playdate deleted")
.setDescription(
`:x: Deleted \`${selected.from_time.toLocaleString()} - ${selected.to_time.toLocaleString()}\``
)
.setFooter({
text: `Group: ${group.name}`
})
await interaction.reply({
embeds: [
embed
],
flags: MessageFlags.Ephemeral,
})
await interaction.deferReply({
flags: MessageFlags.Ephemeral
});
const playdateRepo = Container.get<PlaydateRepository>(PlaydateRepository.name);
const icalFile = await ical.async.fromURL(file.url);
const playdates: PlaydateModel[] = [];
for (const event of Object.values(icalFile)) {
if (event.type !== 'VEVENT') {
continue;
}
const playdate: Partial<PlaydateModel> = {
group: group,
from_time: event.start,
to_time: event.end
}
const id = playdateRepo.create(playdate);
playdates.push(<PlaydateModel>{
id,
...playdate
});
}
const embed = new EmbedBuilder()
.setTitle("Imported play-dates.")
.setDescription(`:white_check_mark: Your ${playdates.length} playdates has been created! You and your group get notified, when its time.`)
.setFields({
name: "Created playdates",
value: playdates.map((playdate) => `${time(playdate.from_time, 'F')} - ${time(playdate.to_time, 'F')}`).join('\n')
})
.setFooter({
text: `Group: ${group.name}`
})
interaction.followUp({
embeds: [embed],
flags: MessageFlags.Ephemeral
})
}
private async export(interaction: ChatInputCommandInteraction, group: GroupModel): Promise<void> {
const playdates = this.getExportTargets(interaction, group);
const result = ics.createEvents(
playdates.map((playdate) => {
return {
start: ics.convertTimestampToArray(playdate.from_time.getTime(), ''),
startInputType: 'utc',
startOutputType: 'utc',
end: ics.convertTimestampToArray(playdate.to_time.getTime(), ''),
endInputType: 'utc',
endOutputType: 'utc',
title: `PnP with ${group.name}`,
status: "CONFIRMED",
busyStatus: "FREE",
categories: ['PnP']
}
})
);
if (!result.value) {
throw new Error("Failed creating ics file...")
}
const attachment = new AttachmentBuilder(Buffer.from(result.value), {
name: "ICSExport.ics",
description: "Your export :)"
});
interaction.reply({
files: [attachment],
flags: MessageFlags.Ephemeral
})
}
private getExportTargets(interaction: ChatInputCommandInteraction, group: GroupModel): PlaydateModel[] {
const repo = Container.get<PlaydateRepository>(PlaydateRepository.name);
const nextPlaydates = interaction.options.getBoolean("future-dates") ?? false;
if (nextPlaydates) {
return repo.findPlaydatesInRange(new Date());
}
const playdateId = interaction.options.getInteger('playdate');
if (!playdateId) {
throw new UserError("Nothing to export", "Please specify what you want to export.");
}
const playdate = repo.getById(playdateId);
if (!playdate) {
throw new UserError("Specified playdate id is invalid");
}
return [playdate];
}
}

View file

@ -1,67 +1,77 @@
import {
Client,
GatewayIntentBits,
Events,
ActivityType, REST
Client,
GatewayIntentBits,
Events,
ActivityType, REST
} from "discord.js";
import Commands from "./Commands/Commands";
import {Container} from "../Container/Container";
import {Logger} from "log4js";
import {InteractionRouter} from "./InteractionRouter";
import {CommandDeployer} from "./CommandDeployer";
export class DiscordClient {
private readonly client: Client;
private readonly client: Client;
public get Client(): Client {
return this.client;
}
public get Client(): Client {
return this.client;
}
public get Commands(): Commands {
return this.router.commands
}
public get Commands(): Commands {
return this.router.commands
}
public get RESTClient(): REST {
return this.restClient;
}
public get RESTClient(): REST {
return this.restClient;
}
public get ApplicationId(): string {
return this.applicationId;
}
public get ApplicationId(): string {
return this.applicationId;
}
constructor(
private readonly applicationId: string,
private readonly router: InteractionRouter,
private readonly restClient: REST = new REST()
) {
this.client = new Client({
intents: [GatewayIntentBits.Guilds]
})
}
constructor(
private readonly applicationId: string,
private readonly router: InteractionRouter,
private readonly deployer: CommandDeployer,
private readonly logger: Logger,
private readonly restClient: REST = new REST()
) {
this.client = new Client({
intents: [GatewayIntentBits.Guilds]
})
}
applyEvents() {
this.client.once(Events.ClientReady, () => {
if (!this.client.user) {
return;
}
applyEvents() {
this.client.once(Events.ClientReady, () => {
if (!this.client.user) {
return;
}
Container.get<Logger>("logger").info(`Ready! Logged in as ${this.client.user.tag}`);
this.client.user.setActivity('your PnP playdates', {
type: ActivityType.Watching,
});
})
this.logger.info(`Ready! Logged in as ${this.client.user.tag}`);
this.client.user.setActivity('your PnP playdates', {
type: ActivityType.Watching,
});
})
this.client.on(Events.GuildAvailable, () => {
Container.get<Logger>("logger").info("Joined Guild?")
})
this.client.on(Events.GuildCreate, (guild) => {
this.logger.info(`Joined ${guild.name}`);
this.deployer.deployServer(guild.id);
})
this.client.on(Events.GuildDelete, (guild) => {
this.logger.info(`Left ${guild.name}`);
})
this.client.on(Events.GuildAvailable, (guild) => {
this.deployer.deployServer(guild.id);
})
this.client.on(Events.InteractionCreate, this.router.route.bind(this.router));
}
this.client.on(Events.InteractionCreate, this.router.route.bind(this.router));
}
connect(token: string) {
this.client.login(token);
}
connect(token: string) {
this.client.login(token);
}
connectRESTClient(token: string) {
this.restClient.setToken(token);
}
connectRESTClient(token: string) {
this.restClient.setToken(token);
}
}

View file

@ -50,20 +50,20 @@ export class InteractionRouter {
return InteractionRoutingType.Unrouted;
}
private async handleCommand(interaction: ChatInputCommandInteraction) {
try {
const command = this.commands.getCommand(interaction.commandName);
if (!command) {
throw new UserError(`Requested command not found.`);
}
if (!('execute' in command)) {
throw new UserError(`Requested command is not setup for a chat command.`);
}
this.logger.debug(`Found chat command ${interaction.commandName}: running...`);
await command.execute?.call(command, interaction);
} catch (e: any) {
this.logger.error(e)
@ -79,13 +79,13 @@ ${inlineCode(e.tryInstead)}`
}
}
if (interaction.replied || interaction.deferred) {
await interaction.followUp({ content: userMessage, flags: MessageFlags.Ephemeral });
await interaction.followUp({content: userMessage, flags: MessageFlags.Ephemeral});
} else {
await interaction.reply({ content: userMessage, flags: MessageFlags.Ephemeral });
await interaction.reply({content: userMessage, flags: MessageFlags.Ephemeral});
}
}
}
private async handleAutocomplete(interaction: AutocompleteInteraction) {
const command = this.commands.getCommand(interaction.commandName);
@ -104,6 +104,6 @@ ${inlineCode(e.tryInstead)}`
} catch (e: unknown) {
Container.get<Logger>('logger').error(e);
}
}
}

View file

@ -1,10 +1,10 @@
export class UserError extends Error {
constructor(
message: string,
public readonly tryInstead: string|null = null
) {
super(message);
}
constructor(
message: string,
public readonly tryInstead: string | null = null
) {
super(message);
}
}

View file

@ -2,33 +2,33 @@ import dotenv from "dotenv";
import path from "node:path";
type DiscordEnvironment = {
token: string;
guildId: string;
clientId: string;
token: string;
guildId: string;
clientId: string;
}
export type DatabaseEnvironment = {
path: string;
path: string;
}
export class Environment {
get discord(): DiscordEnvironment {
return {
token: process.env.DISCORD_API_KEY ?? '',
guildId: process.env.DISCORD_GUILD_ID ?? '',
clientId: process.env.DISCORD_CLIENT_ID ?? '',
}
get discord(): DiscordEnvironment {
return {
token: process.env.DISCORD_API_KEY ?? '',
guildId: process.env.DISCORD_GUILD_ID ?? '',
clientId: process.env.DISCORD_CLIENT_ID ?? '',
}
get database(): DatabaseEnvironment {
return {
path: path.resolve(process.env.DB_PATH ?? ''),
}
}
public setup() {
dotenv.config({
path: path.resolve(__dirname, "../environment/.env"),
});
}
get database(): DatabaseEnvironment {
return {
path: path.resolve(process.env.DB_PATH ?? ''),
}
}
public setup() {
dotenv.config({
path: path.resolve(__dirname, "../environment/.env"),
});
}
}

View file

@ -10,17 +10,17 @@ export class DefaultEvents {
const events: TimedEvent[] = [
new ReminderEvent()
]
const eventHandler = Container.get<EventHandler>(EventHandler.name);
events.forEach((event) => {
eventHandler.addTimed(event);
})
}
public static setupHandlers() {
const eventHandler = Container.get<EventHandler>(EventHandler.name);
eventHandler.addHandler<ElementCreatedEvent<PlaydateModel>>(ElementCreatedEvent.name, sendCreatedNotificationEventHandler);
}
}

View file

@ -2,9 +2,9 @@ import {Model} from "../Models/Model";
export class ElementCreatedEvent<T extends Model = Model> {
constructor(
public readonly tableName: string,
public readonly tableName: string,
public readonly instanceValues: Partial<T>,
public readonly instanceId: number|bigint
public readonly instanceId: number | bigint
) {
}
}

View file

@ -1,8 +1,8 @@
import cron from "node-cron";
import {Class} from "../types/Class";
import {Class} from "../types/Class";
export type EventConfiguration = {
name: string,
name: string,
maxExecutions?: number,
}
@ -14,34 +14,34 @@ export interface TimedEvent {
export class EventHandler {
private eventHandlers: Map<string, CallableFunction[]> = new Map();
constructor() {
}
public addHandler<T extends Class>(eventName: string, handler: (event: T) => void) {
if (!this.eventHandlers.has(eventName)) {
this.eventHandlers.set(eventName, []);
}
this.eventHandlers.get(eventName)?.push(handler);
}
public dispatch<T extends Class>(event: T) {
const eventName = event.constructor.name;
if (!this.eventHandlers.has(eventName)) {
return;
}
this.eventHandlers.get(eventName)?.forEach((handler) => {
handler(event);
})
}
public addTimed(event: TimedEvent) {
if (!cron.validate(event.cronExpression)) {
throw new Error(`Can't create event with name '${event.configuration.name}': Invalid cron expression.`)
}
cron.schedule(event.cronExpression, event.execute.bind(event), event.configuration);
}
}

View file

@ -23,18 +23,18 @@ export async function sendCreatedNotificationEventHandler(event: ElementCreatedE
if (!playdate.group || !playdate.from_time || !playdate.to_time) {
return;
}
const configurationHandler = new GroupConfigurationHandler(
Container.get<GroupConfigurationRepository>(GroupConfigurationRepository.name),
playdate.group
);
const targetChannel = configurationHandler.getConfigurationByPath('channels.newPlaydates');
if (!targetChannel) {
return;
}
const channel = await Container.get<DiscordClient>(DiscordClient.name).Client.channels.fetch(<string>targetChannel)
if (!channel) {
return;
@ -67,7 +67,7 @@ export async function sendCreatedNotificationEventHandler(event: ElementCreatedE
embed
],
allowedMentions: {
roles: [ playdate.group.role.roleid ]
roles: [playdate.group.role.roleid]
}
})
}

View file

@ -14,24 +14,24 @@ export class ReminderEvent implements TimedEvent {
1,
7
];
private static REMINDER_NOTIFICATIONS = [
'The darkness approaches. Get ready!',
'Your aid is requested once again.',
'Grab your dice and show them evil-doers how its done!',
]
configuration: EventConfiguration = {
name: "Reminders",
}
cronExpression: string = "0 9 * * *"
private readonly groupConfigurationRepository: GroupConfigurationRepository
private playdateRepository: PlaydateRepository
private discordClient: DiscordClient
constructor() {
this.playdateRepository = Container.get<PlaydateRepository>(PlaydateRepository.name);
this.groupConfigurationRepository = Container.get<GroupConfigurationRepository>(GroupConfigurationRepository.name);
@ -40,61 +40,61 @@ export class ReminderEvent implements TimedEvent {
async execute() {
const today = new Date();
today.setHours(0,0,0,0);
today.setHours(0, 0, 0, 0);
const playdates = ReminderEvent.REMINDER_INTERVALS.flatMap((interval) => {
const fromDate = new Date(today.valueOf())
fromDate.setDate(fromDate.getDate() + interval);
const toDate = new Date(today.valueOf())
toDate.setDate(toDate.getDate() + interval);
toDate.setHours(23,59,59,999);
toDate.setHours(23, 59, 59, 999);
return this.playdateRepository.findPlaydatesInRange(fromDate, toDate);
}, this)
const promises = playdates
.map((playdate) => {
if (!playdate.group) {
return Promise.resolve();
}
const configurationHandler = new GroupConfigurationHandler(
this.groupConfigurationRepository,
playdate.group
);
const config = configurationHandler.getConfiguration();
const targetChannel = config.channels?.playdateReminders;
if (!targetChannel) {
return Promise.resolve();
}
return this.sendReminder(playdate, targetChannel);
}, this)
await Promise.all(promises);
}
private async sendReminder(playdate: PlaydateModel, targetChannel: ChannelId) {
if (!playdate.group) {
return;
}
const channel = await this.discordClient.Client.channels.fetch(targetChannel)
if (!channel) {
return;
}
if (!channel.isTextBased()) {
return;
}
if (!channel.isSendable()) {
return;
}
const embed = new EmbedBuilder()
.setTitle("Playdate reminder")
.setDescription(
@ -114,7 +114,7 @@ export class ReminderEvent implements TimedEvent {
embed
],
allowedMentions: {
roles: [ playdate.group.role.roleid ]
roles: [playdate.group.role.roleid]
}
})
}

View file

@ -10,66 +10,67 @@ import {Nullable} from "../types/Nullable";
import {isPlainObject} from "is-plain-object";
export class GroupConfigurationHandler {
static DEFAULT_CONFIGURATION: RuntimeGroupConfiguration = {
channels: null,
locale: new Intl.Locale('en-GB'),
permissions: {
allowMemberManagingPlaydates: false
}
static DEFAULT_CONFIGURATION: RuntimeGroupConfiguration = {
channels: null,
locale: new Intl.Locale('en-GB'),
permissions: {
allowMemberManagingPlaydates: false
}
private readonly transformers: GroupConfigurationTransformers = new GroupConfigurationTransformers();
constructor(
private readonly repository: GroupConfigurationRepository,
private readonly group: GroupModel
) { }
}
private readonly transformers: GroupConfigurationTransformers = new GroupConfigurationTransformers();
constructor(
private readonly repository: GroupConfigurationRepository,
private readonly group: GroupModel
) {
}
public saveConfiguration(path: string, value: string): void {
const configuration = this.repository.findConfigurationByPath(this.group, path);
if (configuration) {
this.repository.update(
{
...configuration,
value: value
}
)
return;
}
this.repository.create({
group: this.group,
key: path,
value: value,
});
}
public getConfiguration(): RuntimeGroupConfiguration {
return deepmerge(GroupConfigurationHandler.DEFAULT_CONFIGURATION, this.getDatabaseConfiguration(), {
isMergeableObject: isPlainObject
});
}
public getConfigurationByPath(path: string): Nullable<GroupConfigurationResult> {
const configuration = this.repository.findConfigurationByPath(this.group, path);
if (!configuration) {
return null;
}
public saveConfiguration(path: string, value: string): void {
const configuration = this.repository.findConfigurationByPath(this.group, path);
return this.transformers.getValue(configuration);
if (configuration) {
this.repository.update(
{
...configuration,
value: value
}
)
return;
}
private getDatabaseConfiguration(): Partial<RuntimeGroupConfiguration> {
const values = this.repository.findGroupConfigurations(this.group);
const configuration: Partial<RuntimeGroupConfiguration> = {};
values.forEach((configValue) => {
const value = this.transformers.getValue(configValue);
setPath(configuration, configValue.key, value);
})
return configuration;
this.repository.create({
group: this.group,
key: path,
value: value,
});
}
public getConfiguration(): RuntimeGroupConfiguration {
return deepmerge(GroupConfigurationHandler.DEFAULT_CONFIGURATION, this.getDatabaseConfiguration(), {
isMergeableObject: isPlainObject
});
}
public getConfigurationByPath(path: string): Nullable<GroupConfigurationResult> {
const configuration = this.repository.findConfigurationByPath(this.group, path);
if (!configuration) {
return null;
}
return this.transformers.getValue(configuration);
}
private getDatabaseConfiguration(): Partial<RuntimeGroupConfiguration> {
const values = this.repository.findGroupConfigurations(this.group);
const configuration: Partial<RuntimeGroupConfiguration> = {};
values.forEach((configValue) => {
const value = this.transformers.getValue(configValue);
setPath(configuration, configValue.key, value);
})
return configuration;
}
}

View file

@ -1,371 +1,372 @@
import {GroupConfigurationTransformers, TransformerType} from "./GroupConfigurationTransformers";
import {GroupConfigurationHandler} from "./GroupConfigurationHandler";
import {
ActionRowBuilder,
AnySelectMenuInteraction,
ButtonBuilder,
ButtonStyle, channelMention,
ChannelSelectMenuBuilder, ChannelType,
ChatInputCommandInteraction, EmbedBuilder, inlineCode, Interaction,
InteractionReplyOptions,
InteractionUpdateOptions, italic, MessageFlags,
StringSelectMenuBuilder,
StringSelectMenuOptionBuilder, UserSelectMenuBuilder
ActionRowBuilder,
AnySelectMenuInteraction,
ButtonBuilder,
ButtonStyle, channelMention,
ChannelSelectMenuBuilder, ChannelType,
ChatInputCommandInteraction, EmbedBuilder, inlineCode, Interaction,
InteractionReplyOptions,
InteractionUpdateOptions, italic, MessageFlags,
StringSelectMenuBuilder,
StringSelectMenuOptionBuilder, UserSelectMenuBuilder
} from "discord.js";
import {Logger} from "log4js";
import {Container} from "../Container/Container";
import {Nullable} from "../types/Nullable";
import {
MentionableSelectMenuBuilder,
MessageActionRowComponentBuilder,
RoleSelectMenuBuilder
MentionableSelectMenuBuilder,
MessageActionRowComponentBuilder,
RoleSelectMenuBuilder
} from "@discordjs/builders";
import {ChannelId} from "../types/DiscordTypes";
import {IconCache} from "../Icons/IconCache";
type UIElementCollection = Record<string, UIElement>;
type UIElement = {
label: string,
key: string,
description: string,
childrenElements?: UIElementCollection,
isConfiguration?: true
label: string,
key: string,
description: string,
childrenElements?: UIElementCollection,
isConfiguration?: true
}
export class GroupConfigurationRenderer {
private static MOVETO_COMMAND = 'moveto_';
private static SETVALUE_COMMAND = 'setvalue_';
private static MOVEBACK_COMMAND = 'back';
private breadcrumbs: string[] = [];
private static UI_ELEMENTS : UIElementCollection = {
channels: {
label: 'Channels',
key: 'channels',
description: "Provides settings to define in what channels the bot sends messages, when not directly interacting with it.",
childrenElements: {
newPlaydates: {
label: 'New Playdates',
key: 'newPlaydates',
description: "Sets the channel, where the group get notified when new Playdates are set.",
isConfiguration: true
},
playdateReminders: {
label: 'Playdate Reminders',
key: 'playdateReminders',
description: "Sets the channel, where the group gets reminded of upcoming playdates.",
isConfiguration: true
}
}
private static MOVETO_COMMAND = 'moveto_';
private static SETVALUE_COMMAND = 'setvalue_';
private static MOVEBACK_COMMAND = 'back';
private breadcrumbs: string[] = [];
private static UI_ELEMENTS: UIElementCollection = {
channels: {
label: 'Channels',
key: 'channels',
description: "Provides settings to define in what channels the bot sends messages, when not directly interacting with it.",
childrenElements: {
newPlaydates: {
label: 'New Playdates',
key: 'newPlaydates',
description: "Sets the channel, where the group get notified when new Playdates are set.",
isConfiguration: true
},
locale: {
label: "Locale",
key: 'locale',
description: "Provides locale to be used for this group. This mostly sets how the dates are displayed, but this can also be later used for translations.",
isConfiguration: true
},
permissions: {
label: "Permissions",
key: "permissions",
description: "Allows customization, how the members are allowed to interact with the data stored in the group.",
childrenElements: {
allowMemberManagingPlaydates: {
label: "Manage Playdates",
key: "allowMemberManagingPlaydates",
description: "Defines if the members are allowed to manage playdates like adding or deleting them.",
isConfiguration: true
}
}
playdateReminders: {
label: 'Playdate Reminders',
key: 'playdateReminders',
description: "Sets the channel, where the group gets reminded of upcoming playdates.",
isConfiguration: true
}
}
},
locale: {
label: "Locale",
key: 'locale',
description: "Provides locale to be used for this group. This mostly sets how the dates are displayed, but this can also be later used for translations.",
isConfiguration: true
},
permissions: {
label: "Permissions",
key: "permissions",
description: "Allows customization, how the members are allowed to interact with the data stored in the group.",
childrenElements: {
allowMemberManagingPlaydates: {
label: "Manage Playdates",
key: "allowMemberManagingPlaydates",
description: "Defines if the members are allowed to manage playdates like adding or deleting them.",
isConfiguration: true
}
}
}
constructor(
private readonly configurationHandler: GroupConfigurationHandler,
private readonly transformers: GroupConfigurationTransformers,
) {}
public async setup(interaction: ChatInputCommandInteraction) {
let response = await interaction.reply(this.getReplyOptions());
let exit = false;
let eventResponse;
const filter = (i: Interaction) => i.user.id === interaction.user.id;
do {
if (eventResponse) {
response = await eventResponse.update(this.getReplyOptions());
}
try {
eventResponse = await response.resource?.message?.awaitMessageComponent({
dispose: true,
filter: filter,
time: 60_000
});
} catch (_: unknown) {
break;
}
if (!eventResponse || eventResponse.customId === 'exit') {
exit = true;
continue;
}
if (eventResponse.customId === GroupConfigurationRenderer.MOVEBACK_COMMAND) {
this.breadcrumbs.pop()
continue;
}
if (eventResponse.customId.startsWith(GroupConfigurationRenderer.MOVETO_COMMAND)) {
this.breadcrumbs.push(
eventResponse.customId.substring(GroupConfigurationRenderer.MOVETO_COMMAND.length)
)
continue;
}
if (eventResponse.customId.startsWith(GroupConfigurationRenderer.SETVALUE_COMMAND)) {
this.handleSelection(<AnySelectMenuInteraction>eventResponse);
continue;
}
} while(!exit);
if (eventResponse) {
try {
await eventResponse.update(
this.getReplyOptions()
);
} catch (_) {
}
await eventResponse.deleteReply();
return;
}
const message = response.resource?.message
if (!message) {
return;
}
if (message.deletable) {
await message.delete()
}
}
private getReplyOptions(): InteractionUpdateOptions & InteractionReplyOptions & { withResponse: true } {
const embed = this.createEmbed();
const icons = Container.get<IconCache>(IconCache.name);
embed.setAuthor({
name: "/ " + this.breadcrumbs.join(" / ")
}
constructor(
private readonly configurationHandler: GroupConfigurationHandler,
private readonly transformers: GroupConfigurationTransformers,
) {
}
public async setup(interaction: ChatInputCommandInteraction) {
let response = await interaction.reply(this.getReplyOptions());
let exit = false;
let eventResponse;
const filter = (i: Interaction) => i.user.id === interaction.user.id;
do {
if (eventResponse) {
response = await eventResponse.update(this.getReplyOptions());
}
try {
eventResponse = await response.resource?.message?.awaitMessageComponent({
dispose: true,
filter: filter,
time: 60_000
});
const exitButton = new ButtonBuilder()
.setLabel("Exit")
.setStyle(ButtonStyle.Danger)
.setCustomId("exit")
.setEmoji(icons.get("door_open_solid_white") ?? '');
const actionrow = new ActionRowBuilder<ButtonBuilder>()
if (this.breadcrumbs.length > 0) {
const backButton = new ButtonBuilder()
.setLabel("Back")
.setStyle(ButtonStyle.Secondary)
.setCustomId(GroupConfigurationRenderer.MOVEBACK_COMMAND)
.setEmoji(icons.get("angle_left_solid") ?? '');
actionrow.addComponents(backButton)
}
actionrow.addComponents(exitButton)
return {
content: "",
embeds: [embed],
components: [...this.createActionRowBuildersForMenu(), actionrow],
withResponse: true,
flags: MessageFlags.Ephemeral
};
} catch (_: unknown) {
break;
}
if (!eventResponse || eventResponse.customId === 'exit') {
exit = true;
continue;
}
if (eventResponse.customId === GroupConfigurationRenderer.MOVEBACK_COMMAND) {
this.breadcrumbs.pop()
continue;
}
if (eventResponse.customId.startsWith(GroupConfigurationRenderer.MOVETO_COMMAND)) {
this.breadcrumbs.push(
eventResponse.customId.substring(GroupConfigurationRenderer.MOVETO_COMMAND.length)
)
continue;
}
if (eventResponse.customId.startsWith(GroupConfigurationRenderer.SETVALUE_COMMAND)) {
this.handleSelection(<AnySelectMenuInteraction>eventResponse);
continue;
}
} while (!exit);
if (eventResponse) {
try {
await eventResponse.update(
this.getReplyOptions()
);
} catch (_) {
}
await eventResponse.deleteReply();
return;
}
private createEmbed(): EmbedBuilder {
const {currentElement} = this.findCurrentUI();
if (currentElement === null) {
return new EmbedBuilder()
.setTitle("Group Configuration")
.setDescription("This UI allows you to change settings for your group.")
}
const embed = new EmbedBuilder()
.setTitle(currentElement?.label ?? '')
.setDescription(currentElement?.description ?? '');
if (currentElement?.isConfiguration ?? false) {
embed.addFields(
{ name: "Current Value", value: this.getCurrentValueAsUI(), inline: false }
)
}
return embed;
const message = response.resource?.message
if (!message) {
return;
}
if (message.deletable) {
await message.delete()
}
}
private getReplyOptions(): InteractionUpdateOptions & InteractionReplyOptions & { withResponse: true } {
const embed = this.createEmbed();
const icons = Container.get<IconCache>(IconCache.name);
embed.setAuthor({
name: "/ " + this.breadcrumbs.join(" / ")
});
const exitButton = new ButtonBuilder()
.setLabel("Exit")
.setStyle(ButtonStyle.Danger)
.setCustomId("exit")
.setEmoji(icons.get("door_open_solid_white") ?? '');
const actionrow = new ActionRowBuilder<ButtonBuilder>()
if (this.breadcrumbs.length > 0) {
const backButton = new ButtonBuilder()
.setLabel("Back")
.setStyle(ButtonStyle.Secondary)
.setCustomId(GroupConfigurationRenderer.MOVEBACK_COMMAND)
.setEmoji(icons.get("angle_left_solid") ?? '');
actionrow.addComponents(backButton)
}
actionrow.addComponents(exitButton)
return {
content: "",
embeds: [embed],
components: [...this.createActionRowBuildersForMenu(), actionrow],
withResponse: true,
flags: MessageFlags.Ephemeral
};
}
private createEmbed(): EmbedBuilder {
const {currentElement} = this.findCurrentUI();
if (currentElement === null) {
return new EmbedBuilder()
.setTitle("Group Configuration")
.setDescription("This UI allows you to change settings for your group.")
}
const embed = new EmbedBuilder()
.setTitle(currentElement?.label ?? '')
.setDescription(currentElement?.description ?? '');
if (currentElement?.isConfiguration ?? false) {
embed.addFields(
{name: "Current Value", value: this.getCurrentValueAsUI(), inline: false}
)
}
return embed;
}
private getCurrentValueAsUI(): string {
const path = this.breadcrumbs.join(".");
const value = this.configurationHandler.getConfigurationByPath(path);
if (value === undefined) return italic("None");
const type = this.transformers.getTransformerType(path);
if (type === undefined) {
throw new Error("Could not find the type for " + path);
}
private getCurrentValueAsUI(): string {
const path = this.breadcrumbs.join(".");
const value = this.configurationHandler.getConfigurationByPath(path);
if (value === undefined) return italic("None");
const type = this.transformers.getTransformerType(path);
if (type === undefined) {
throw new Error("Could not find the type for " + path);
}
const displaynames = new Intl.DisplayNames(["en"], { type: "language" });
switch (type) {
case TransformerType.Locale:
if (!value) {
return inlineCode("Default");
}
return displaynames.of((<Intl.Locale>value)?.baseName) ?? "Unknown";
case TransformerType.Channel:
if (!value) {
return inlineCode("None");
}
return channelMention(<ChannelId>value);
case TransformerType.PermissionBoolean:
return value ? "Allowed" : "Disallowed"
default:
return "None";
}
}
private createActionRowBuildersForMenu() : ActionRowBuilder<MessageActionRowComponentBuilder>[] {
const {currentCollection, currentElement} = this.findCurrentUI();
const icons = Container.get<IconCache>(IconCache.name);
const displaynames = new Intl.DisplayNames(["en"], {type: "language"});
if (currentElement?.isConfiguration ?? false) {
return [
new ActionRowBuilder<ChannelSelectMenuBuilder | MentionableSelectMenuBuilder | RoleSelectMenuBuilder | StringSelectMenuBuilder | UserSelectMenuBuilder>()
.addComponents(this.getSelectForBreadcrumbs())
]
switch (type) {
case TransformerType.Locale:
if (!value) {
return inlineCode("Default");
}
return [
new ActionRowBuilder<ButtonBuilder>()
.setComponents(
...Object.values(currentCollection).map(elem => new ButtonBuilder()
.setLabel(` ${elem.label}`)
.setStyle(ButtonStyle.Primary)
.setCustomId(GroupConfigurationRenderer.MOVETO_COMMAND + elem.key)
.setEmoji(icons.get(elem.isConfiguration ? 'pen_solid' : "folder_solid") ?? '')
)
)
return displaynames.of((<Intl.Locale>value)?.baseName) ?? "Unknown";
case TransformerType.Channel:
if (!value) {
return inlineCode("None");
}
return channelMention(<ChannelId>value);
case TransformerType.PermissionBoolean:
return value ? "Allowed" : "Disallowed"
default:
return "None";
}
}
private createActionRowBuildersForMenu(): ActionRowBuilder<MessageActionRowComponentBuilder>[] {
const {currentCollection, currentElement} = this.findCurrentUI();
const icons = Container.get<IconCache>(IconCache.name);
if (currentElement?.isConfiguration ?? false) {
return [
new ActionRowBuilder<ChannelSelectMenuBuilder | MentionableSelectMenuBuilder | RoleSelectMenuBuilder | StringSelectMenuBuilder | UserSelectMenuBuilder>()
.addComponents(this.getSelectForBreadcrumbs())
]
}
return [
new ActionRowBuilder<ButtonBuilder>()
.setComponents(
...Object.values(currentCollection).map(elem => new ButtonBuilder()
.setLabel(` ${elem.label}`)
.setStyle(ButtonStyle.Primary)
.setCustomId(GroupConfigurationRenderer.MOVETO_COMMAND + elem.key)
.setEmoji(icons.get(elem.isConfiguration ? 'pen_solid' : "folder_solid") ?? '')
)
)
]
}
private getSelectForBreadcrumbs(): ChannelSelectMenuBuilder | MentionableSelectMenuBuilder | RoleSelectMenuBuilder | StringSelectMenuBuilder | UserSelectMenuBuilder {
const breadcrumbPath = this.breadcrumbs.join('.')
const transformerType = this.transformers.getTransformerType(breadcrumbPath);
if (transformerType === undefined) {
throw new Error(`Can not find transformer type for ${breadcrumbPath}`)
}
switch (transformerType) {
case TransformerType.Locale:
const options = [
'en-US',
'fr-FR',
'it-IT',
'de-DE'
]
}
private getSelectForBreadcrumbs(): ChannelSelectMenuBuilder | MentionableSelectMenuBuilder | RoleSelectMenuBuilder | StringSelectMenuBuilder | UserSelectMenuBuilder {
const breadcrumbPath = this.breadcrumbs.join('.')
const transformerType = this.transformers.getTransformerType(breadcrumbPath);
if (transformerType === undefined) {
throw new Error(`Can not find transformer type for ${breadcrumbPath}`)
}
switch (transformerType) {
case TransformerType.Locale:
const options = [
'en-US',
'fr-FR',
'it-IT',
'de-DE'
]
const displaynames = new Intl.DisplayNames(["en"], { type: "language" });
return new StringSelectMenuBuilder()
.setCustomId(GroupConfigurationRenderer.SETVALUE_COMMAND + breadcrumbPath)
.setOptions(
options.map(intl => new StringSelectMenuOptionBuilder()
.setLabel(displaynames.of(intl) ?? '')
.setValue(intl)
)
)
case TransformerType.Channel:
return new ChannelSelectMenuBuilder()
.setCustomId(GroupConfigurationRenderer.SETVALUE_COMMAND + breadcrumbPath)
.setChannelTypes(ChannelType.GuildText)
.setPlaceholder("New Value");
case TransformerType.PermissionBoolean:
return new StringSelectMenuBuilder()
.setCustomId(GroupConfigurationRenderer.SETVALUE_COMMAND + breadcrumbPath)
.setOptions(
[
{
label: "Allow",
value: "1"
},
{
label: "Disallow",
value: "0"
}
]
)
default:
return new StringSelectMenuBuilder()
.setCustomId("...")
.setOptions(
new StringSelectMenuOptionBuilder()
.setLabel("Nothing to see here")
.setValue("0")
)
}
}
private handleSelection(interaction: AnySelectMenuInteraction) {
const path = interaction.customId.substring(GroupConfigurationRenderer.SETVALUE_COMMAND.length);
const savingValue = this.getSaveValue(interaction, path);
Container.get<Logger>("logger").debug(`Saving '${savingValue}' to '${path}'`);
this.configurationHandler.saveConfiguration(path, savingValue);
}
private getSaveValue(interaction: AnySelectMenuInteraction, path: string): string {
const transformerType = this.transformers.getTransformerType(path);
if (transformerType === undefined || transformerType === null) {
throw new Error(`Can not find transformer type for ${path}`)
}
const displaynames = new Intl.DisplayNames(["en"], {type: "language"});
return new StringSelectMenuBuilder()
.setCustomId(GroupConfigurationRenderer.SETVALUE_COMMAND + breadcrumbPath)
.setOptions(
options.map(intl => new StringSelectMenuOptionBuilder()
.setLabel(displaynames.of(intl) ?? '')
.setValue(intl)
)
)
case TransformerType.Channel:
return new ChannelSelectMenuBuilder()
.setCustomId(GroupConfigurationRenderer.SETVALUE_COMMAND + breadcrumbPath)
.setChannelTypes(ChannelType.GuildText)
.setPlaceholder("New Value");
case TransformerType.PermissionBoolean:
return new StringSelectMenuBuilder()
.setCustomId(GroupConfigurationRenderer.SETVALUE_COMMAND + breadcrumbPath)
.setOptions(
[
{
label: "Allow",
value: "1"
},
{
label: "Disallow",
value: "0"
}
]
)
switch (transformerType) {
case TransformerType.Locale:
case TransformerType.Channel:
case TransformerType.PermissionBoolean:
return interaction.values.join('; ');
default:
throw new Error("Unhandled select menu");
}
default:
return new StringSelectMenuBuilder()
.setCustomId("...")
.setOptions(
new StringSelectMenuOptionBuilder()
.setLabel("Nothing to see here")
.setValue("0")
)
}
private findCurrentUI(): {currentElement: Nullable<UIElement>, currentCollection: UIElementCollection } {
let currentCollection: UIElementCollection = GroupConfigurationRenderer.UI_ELEMENTS;
let currentElement: Nullable<UIElement> = null;
}
for (const breadcrumb of this.breadcrumbs) {
currentElement = currentCollection[breadcrumb];
private handleSelection(interaction: AnySelectMenuInteraction) {
const path = interaction.customId.substring(GroupConfigurationRenderer.SETVALUE_COMMAND.length);
if (currentElement.isConfiguration ?? false) {
break;
}
const savingValue = this.getSaveValue(interaction, path);
Container.get<Logger>("logger").debug(`Saving '${savingValue}' to '${path}'`);
currentCollection = currentElement.childrenElements ?? {};
}
return {
currentElement,
currentCollection,
}
this.configurationHandler.saveConfiguration(path, savingValue);
}
private getSaveValue(interaction: AnySelectMenuInteraction, path: string): string {
const transformerType = this.transformers.getTransformerType(path);
if (transformerType === undefined || transformerType === null) {
throw new Error(`Can not find transformer type for ${path}`)
}
switch (transformerType) {
case TransformerType.Locale:
case TransformerType.Channel:
case TransformerType.PermissionBoolean:
return interaction.values.join('; ');
default:
throw new Error("Unhandled select menu");
}
}
private findCurrentUI(): { currentElement: Nullable<UIElement>, currentCollection: UIElementCollection } {
let currentCollection: UIElementCollection = GroupConfigurationRenderer.UI_ELEMENTS;
let currentElement: Nullable<UIElement> = null;
for (const breadcrumb of this.breadcrumbs) {
currentElement = currentCollection[breadcrumb];
if (currentElement.isConfiguration ?? false) {
break;
}
currentCollection = currentElement.childrenElements ?? {};
}
return {
currentElement,
currentCollection,
}
}
}

View file

@ -4,63 +4,63 @@ import {Nullable} from "../types/Nullable";
import {ArrayUtils} from "../Utilities/ArrayUtils";
export enum TransformerType {
Locale,
Channel,
PermissionBoolean,
Locale,
Channel,
PermissionBoolean,
}
type GroupConfigurationTransformer = {
path: string[];
type: TransformerType,
path: string[];
type: TransformerType,
}
export type GroupConfigurationResult =
ChannelId | Intl.Locale | boolean
export type GroupConfigurationResult =
ChannelId | Intl.Locale | boolean
export class GroupConfigurationTransformers {
static TRANSFORMERS: GroupConfigurationTransformer[] = [
{
path: ['channels', 'newPlaydates'],
type: TransformerType.Channel,
},
{
path: ['channels', 'playdateReminders'],
type: TransformerType.Channel,
},
{
path: ['locale'],
type: TransformerType.Locale,
},
{
path: ['permissions', 'allowMemberManagingPlaydates'],
type: TransformerType.PermissionBoolean
}
];
public getValue(configValue: GroupConfigurationModel): GroupConfigurationResult {
const transformerType = this.getTransformerType(configValue.key);
if (transformerType === undefined || transformerType === null) {
throw new Error(`Can't find transformer for ${configValue.key}`);
}
switch (transformerType) {
case TransformerType.Locale:
return new Intl.Locale(configValue.value)
case TransformerType.Channel:
return <ChannelId>configValue.value;
case TransformerType.PermissionBoolean:
return configValue.value === '1';
}
static TRANSFORMERS: GroupConfigurationTransformer[] = [
{
path: ['channels', 'newPlaydates'],
type: TransformerType.Channel,
},
{
path: ['channels', 'playdateReminders'],
type: TransformerType.Channel,
},
{
path: ['locale'],
type: TransformerType.Locale,
},
{
path: ['permissions', 'allowMemberManagingPlaydates'],
type: TransformerType.PermissionBoolean
}
public getTransformerType(configKey: string): Nullable<TransformerType> {
const path = configKey.split('.');
return GroupConfigurationTransformers.TRANSFORMERS.find(
transformer => {
return ArrayUtils.arraysEqual(transformer.path, path);
}
)?.type;
];
public getValue(configValue: GroupConfigurationModel): GroupConfigurationResult {
const transformerType = this.getTransformerType(configValue.key);
if (transformerType === undefined || transformerType === null) {
throw new Error(`Can't find transformer for ${configValue.key}`);
}
switch (transformerType) {
case TransformerType.Locale:
return new Intl.Locale(configValue.value)
case TransformerType.Channel:
return <ChannelId>configValue.value;
case TransformerType.PermissionBoolean:
return configValue.value === '1';
}
}
public getTransformerType(configKey: string): Nullable<TransformerType> {
const path = configKey.split('.');
return GroupConfigurationTransformers.TRANSFORMERS.find(
transformer => {
return ArrayUtils.arraysEqual(transformer.path, path);
}
)?.type;
}
}

View file

@ -2,16 +2,16 @@ import {ChannelId} from "../types/DiscordTypes";
import {Nullable} from "../types/Nullable";
export type RuntimeGroupConfiguration = {
channels: Nullable<ChannelRuntimeGroupConfiguration>,
locale: Intl.Locale,
permissions: PermissionRuntimeGroupConfiguration
channels: Nullable<ChannelRuntimeGroupConfiguration>,
locale: Intl.Locale,
permissions: PermissionRuntimeGroupConfiguration
};
export type ChannelRuntimeGroupConfiguration = {
newPlaydates: ChannelId,
playdateReminders: ChannelId
newPlaydates: ChannelId,
playdateReminders: ChannelId
}
export type PermissionRuntimeGroupConfiguration = {
allowMemberManagingPlaydates: boolean
allowMemberManagingPlaydates: boolean
}

View file

@ -4,30 +4,30 @@ import {Nullable} from "../types/Nullable";
export class IconCache {
private existingIcons: Map<string, string> | undefined;
constructor(
private readonly client: DiscordClient
) {
}
public get(iconName: string): Snowflake | null {
if (!this.existingIcons?.has(iconName)) {
return null;
}
return this.existingIcons?.get(iconName) ?? null;
}
public getEmoji(iconName: string): string {
const id = this.get(iconName);
return formatEmoji({
id,
name: iconName
});
}
public async set(iconName: string, pngBuffer: Buffer) {
const pngBase64 = pngBuffer.toString("base64");
const iconDataUrl = `data:image/png;base64,${pngBase64}`;
@ -42,23 +42,23 @@ export class IconCache {
}
)
}
public async populate() {
if (this.existingIcons != null) {
return;
}
const existingEmojis: Nullable<DiscordIconRequest> = await this.client.RESTClient.get(
Routes.applicationEmojis(this.client.ApplicationId)
)
if (!existingEmojis) {
return;
}
this.existingIcons = new Map<string, string>(
existingEmojis.items.map((item) => {
return [ item.name, item.id ]
return [item.name, item.id]
})
)
}

View file

@ -5,29 +5,30 @@ import {IconCache} from "./IconCache";
export class IconDeployer {
static ICON_PATH = path.resolve('public/icons')
constructor(
private readonly iconCache: IconCache
) {}
) {
}
public async ensureExistance() {
const directory = await fs.promises.opendir(IconDeployer.ICON_PATH);
const addIconPromises: Promise<void>[] = [];
for await (const dirname of directory) {
const iconName = path.basename(dirname.name, '.svg').replaceAll('-','_');
const iconName = path.basename(dirname.name, '.svg').replaceAll('-', '_');
if (this.iconCache.get(iconName) !== null) {
continue;
}
addIconPromises.push(
this.addIcon(path.resolve(dirname.parentPath, dirname.name), iconName)
);
}
await Promise.all(addIconPromises);
}
private async addIcon(iconPath: string, iconName: string) {
const svgBuffer = await fs.promises.readFile(iconPath, 'utf-8');
const pngBuffer = await new Promise<Buffer>(resolve => {
@ -48,8 +49,8 @@ export class IconDeployer {
)
}
)
await this.iconCache.set(iconName, pngBuffer);
}
}

View file

View file

@ -2,7 +2,7 @@ import {Model} from "./Model";
import {GroupModel} from "./GroupModel";
export interface GroupConfigurationModel extends Model {
group: GroupModel;
key: string;
value: string;
group: GroupModel;
key: string;
value: string;
}

View file

@ -2,7 +2,7 @@ import {Model} from "./Model";
import {GuildMember, Role} from "../types/DiscordTypes";
export interface GroupModel extends Model {
name: string;
leader: GuildMember;
role: Role;
name: string;
leader: GuildMember;
role: Role;
}

View file

@ -1,3 +1,3 @@
export interface Model {
id: number;
id: number | bigint;
}

View file

@ -3,7 +3,7 @@ import {GroupModel} from "./GroupModel";
import {Nullable} from "../types/Nullable";
export interface PlaydateModel extends Model {
group: Nullable<GroupModel>
from_time: Date,
to_time: Date,
group: Nullable<GroupModel>
from_time: Date,
to_time: Date,
}

View file

@ -1,65 +1,65 @@
import {Repository} from "./Repository";
import GroupConfiguration, {DBGroupConfiguration} from "../Database/tables/GroupConfiguration";
import {GroupConfigurationModel} from "../Models/GroupConfigurationModel";
import { GroupModel } from "../Models/GroupModel";
import {GroupModel} from "../Models/GroupModel";
import {Nullable} from "../types/Nullable";
import {DatabaseConnection} from "../Database/DatabaseConnection";
import {GroupRepository} from "./GroupRepository";
export class GroupConfigurationRepository extends Repository<GroupConfigurationModel, DBGroupConfiguration> {
constructor(
protected readonly database: DatabaseConnection,
private readonly groupRepository: GroupRepository,
) {
super(
database,
GroupConfiguration
);
}
public findGroupConfigurations(group: GroupModel): GroupConfigurationModel[] {
return this.database.fetchAll<number, DBGroupConfiguration>(`
constructor(
protected readonly database: DatabaseConnection,
private readonly groupRepository: GroupRepository,
) {
super(
database,
GroupConfiguration
);
}
public findGroupConfigurations(group: GroupModel): GroupConfigurationModel[] {
return this.database.fetchAll<number, DBGroupConfiguration>(`
SELECT * FROM groupConfiguration WHERE groupid = ?`,
group.id
).map((config) => {
return this.convertToModelType(config, group);
})
}
public findConfigurationByPath(group: GroupModel, path: string): Nullable<GroupConfigurationModel> {
const result = this.database.fetch<number, DBGroupConfiguration>(`
group.id
).map((config) => {
return this.convertToModelType(config, group);
})
}
public findConfigurationByPath(group: GroupModel, path: string): Nullable<GroupConfigurationModel> {
const result = this.database.fetch<number, DBGroupConfiguration>(`
SELECT * FROM groupConfiguration WHERE groupid = ? AND key = ?`,
group.id,
path
);
if (!result) {
return null;
}
return this.convertToModelType(result, group);
group.id,
path
);
if (!result) {
return null;
}
protected convertToModelType(intermediateModel: DBGroupConfiguration | undefined, group: Nullable<GroupModel> = null): GroupConfigurationModel {
if (!intermediateModel) {
throw new Error("No intermediate model provided");
}
return {
id: intermediateModel.id,
group: group ?? this.groupRepository.getById(intermediateModel.id),
key: intermediateModel.key,
value: intermediateModel.value,
}
return this.convertToModelType(result, group);
}
protected convertToModelType(intermediateModel: DBGroupConfiguration | undefined, group: Nullable<GroupModel> = null): GroupConfigurationModel {
if (!intermediateModel) {
throw new Error("No intermediate model provided");
}
protected convertToCreateObject(instance: Partial<GroupConfigurationModel>): object {
return {
groupid: instance.group?.id ?? undefined,
key: instance.key ?? undefined,
value: instance.value ?? undefined,
}
return {
id: intermediateModel.id,
group: group ?? this.groupRepository.getById(intermediateModel.id),
key: intermediateModel.key,
value: intermediateModel.value,
}
}
protected convertToCreateObject(instance: Partial<GroupConfigurationModel>): object {
return {
groupid: instance.group?.id ?? undefined,
key: instance.key ?? undefined,
value: instance.value ?? undefined,
}
}
}

View file

@ -9,94 +9,94 @@ import {Container} from "../Container/Container";
export class GroupRepository extends Repository<GroupModel, DBGroup> {
constructor(
protected readonly database: DatabaseConnection,
) {
super(
database,
Groups
);
constructor(
protected readonly database: DatabaseConnection,
) {
super(
database,
Groups
);
}
public findGroupByName(name: string): Nullable<GroupModel> {
const result = this.database.fetch<string, DBGroup>(
`SELECT * FROM groups WHERE name = ? LIMIT 1`,
name
)
if (!result) {
return undefined;
}
public findGroupByName(name: string): Nullable<GroupModel> {
const result = this.database.fetch<string, DBGroup>(
`SELECT * FROM groups WHERE name = ? LIMIT 1`,
name
)
if (!result) {
return undefined;
}
return this.convertToModelType(result);
}
public findGroupsByRoles(server: string, roleIds: string[]): GroupModel[] {
const template = roleIds.map(_roleId => '?').join(',');
const dbResult = this.database.fetchAll<number[], DBGroup>(`
return this.convertToModelType(result);
}
public findGroupsByRoles(server: string, roleIds: string[]): GroupModel[] {
const template = roleIds.map(_roleId => '?').join(',');
const dbResult = this.database.fetchAll<number[], DBGroup>(`
SELECT * FROM groups WHERE server = ? AND role IN (${template})
`,
server,
...roleIds)
return dbResult.map((result) => this.convertToModelType(result));
server,
...roleIds)
return dbResult.map((result) => this.convertToModelType(result));
}
public findGroupsByMember(member: GuildMember, onlyLeader: boolean = false) {
if (!member) {
throw new Error("Can't find member for guild: none given");
}
public findGroupsByMember(member: GuildMember, onlyLeader: boolean = false) {
if (!member) {
throw new Error("Can't find member for guild: none given");
}
const groups = this.findGroupsByRoles(member.guild.id, [...member.roles.cache.keys()])
if (!onlyLeader) {
return groups;
}
return groups.filter((group: GroupModel) => {
return group.leader.memberid === member.id;
})
}
public deleteGroup(group: GroupModel): void {
this.delete(group.id);
const repo = Container.get<PlaydateRepository>(PlaydateRepository.name);
const playdates = repo.findFromGroup(group, true)
playdates.forEach((playdate) => {
repo.delete(playdate.id);
})
}
protected convertToModelType(intermediateModel: DBGroup | undefined): GroupModel {
if (!intermediateModel) {
throw new Error("No intermediate model provided");
}
return {
id: intermediateModel.id,
name: intermediateModel.name,
leader: {
server: intermediateModel.server,
memberid: intermediateModel.leader
},
role: {
server: intermediateModel.server,
roleid: intermediateModel.role
}
}
const groups = this.findGroupsByRoles(member.guild.id, [...member.roles.cache.keys()])
if (!onlyLeader) {
return groups;
}
protected convertToCreateObject(instance: Partial<GroupModel>): object {
return {
name: instance.name ?? '',
server: instance.role?.server ?? null,
leader: instance.leader?.memberid ?? null,
role: instance.role?.roleid ?? null,
}
return groups.filter((group: GroupModel) => {
return group.leader.memberid === member.id;
})
}
public deleteGroup(group: GroupModel): void {
this.delete(group.id);
const repo = Container.get<PlaydateRepository>(PlaydateRepository.name);
const playdates = repo.findFromGroup(group, true)
playdates.forEach((playdate) => {
repo.delete(playdate.id);
})
}
protected convertToModelType(intermediateModel: DBGroup | undefined): GroupModel {
if (!intermediateModel) {
throw new Error("No intermediate model provided");
}
return {
id: intermediateModel.id,
name: intermediateModel.name,
leader: {
server: intermediateModel.server,
memberid: intermediateModel.leader
},
role: {
server: intermediateModel.server,
roleid: intermediateModel.role
}
}
}
protected convertToCreateObject(instance: Partial<GroupModel>): object {
return {
name: instance.name ?? '',
server: instance.role?.server ?? null,
leader: instance.leader?.memberid ?? null,
role: instance.role?.roleid ?? null,
}
}
}

View file

@ -7,90 +7,96 @@ import {GroupModel} from "../Models/GroupModel";
import {Nullable} from "../types/Nullable";
export class PlaydateRepository extends Repository<PlaydateModel, DBPlaydate> {
constructor(
protected readonly database: DatabaseConnection,
private readonly groupRepository: GroupRepository,
) {
super(
database,
Playdate
);
}
findFromGroup(group: GroupModel, all = false) {
let sql = `SELECT * FROM ${this.schema.name} WHERE groupid = ?`;
const params = [group.id];
if (!all) {
sql += " AND time_from > ?"
params.push(new Date().getTime())
}
const finds = this.database.fetchAll<number, DBPlaydate>(
sql,
...params
);
return finds.map((playdate) => this.convertToModelType(playdate, group));
}
findPlaydatesInRange(fromDate: Date|number, toDate: Date|number, group: GroupModel | undefined = undefined) {
if (fromDate instanceof Date) {
fromDate = fromDate.getTime();
}
if (toDate instanceof Date) {
toDate = toDate.getTime();
}
let sql = `SELECT * FROM ${this.schema.name} WHERE time_from > ? AND time_from < ?`;
const params = [fromDate, toDate];
if (group) {
sql = `${sql} AND groupid = ?`
params.push(group.id)
}
const finds = this.database.fetchAll<number, DBPlaydate>(
sql,
...params
);
constructor(
protected readonly database: DatabaseConnection,
private readonly groupRepository: GroupRepository,
) {
super(
database,
Playdate
);
}
return finds.map((playdate) => this.convertToModelType(playdate, group));
findFromGroup(group: GroupModel, all = false) {
let sql = `SELECT * FROM ${this.schema.name} WHERE groupid = ?`;
const params = [group.id];
if (!all) {
sql += " AND time_from > ?"
params.push(new Date().getTime())
}
const finds = this.database.fetchAll<number, DBPlaydate>(
sql,
...params
);
return finds.map((playdate) => this.convertToModelType(playdate, group));
}
findPlaydatesInRange(fromDate: Date | number, toDate: Date | number | undefined = undefined, group: GroupModel | undefined = undefined) {
if (fromDate instanceof Date) {
fromDate = fromDate.getTime();
}
if (toDate instanceof Date) {
toDate = toDate.getTime();
}
let sql = `SELECT * FROM ${this.schema.name} WHERE time_from > ?`;
const params = [fromDate];
if (toDate) {
sql = `${sql} AND time_from < ?`
params.push(toDate);
}
getNextPlaydateForGroup(group: GroupModel): PlaydateModel | null {
const sql = `SELECT * FROM ${this.schema.name} WHERE groupid = ? AND time_from > ? ORDER BY time_from LIMIT 1`;
const find = this.database.fetch<number, DBPlaydate>(
sql,
group.id,
Date.now()
)
if (!find) {
return null;
}
return this.convertToModelType(find, group)
if (group) {
sql = `${sql} AND groupid = ?`
params.push(<number>group.id)
}
protected convertToModelType(intermediateModel: DBPlaydate | undefined, fixedGroup: Nullable<GroupModel> = null): PlaydateModel {
if (!intermediateModel) {
throw new Error("Unable to convert the playdate model");
}
return {
id: intermediateModel.id,
group: fixedGroup ?? this.groupRepository.getById(intermediateModel.groupid),
from_time: new Date(intermediateModel.time_from),
to_time: new Date(intermediateModel.time_to),
};
const finds = this.database.fetchAll<number, DBPlaydate>(
sql,
...params
);
return finds.map((playdate) => this.convertToModelType(playdate, group));
}
getNextPlaydateForGroup(group: GroupModel): PlaydateModel | null {
const sql = `SELECT * FROM ${this.schema.name} WHERE groupid = ? AND time_from > ? ORDER BY time_from LIMIT 1`;
const find = this.database.fetch<number, DBPlaydate>(
sql,
group.id,
Date.now()
)
if (!find) {
return null;
}
protected convertToCreateObject(instance: Partial<PlaydateModel>): object {
return {
groupid: instance.group?.id ?? null,
time_from: instance.from_time?.getTime() ?? 0,
time_to: instance.to_time?.getTime() ?? 0,
}
return this.convertToModelType(find, group)
}
protected convertToModelType(intermediateModel: DBPlaydate | undefined, fixedGroup: Nullable<GroupModel> = null): PlaydateModel {
if (!intermediateModel) {
throw new Error("Unable to convert the playdate model");
}
return {
id: intermediateModel.id,
group: fixedGroup ?? this.groupRepository.getById(intermediateModel.groupid),
from_time: new Date(intermediateModel.time_from),
to_time: new Date(intermediateModel.time_to),
};
}
protected convertToCreateObject(instance: Partial<PlaydateModel>): object {
return {
groupid: instance.group?.id ?? null,
time_from: instance.from_time?.getTime() ?? 0,
time_to: instance.to_time?.getTime() ?? 0,
}
}
}

View file

@ -1,83 +1,85 @@
import {DatabaseConnection} from "../Database/DatabaseConnection";
import {Model} from "../Models/Model";
import { Nullable } from "../types/Nullable";
import {Nullable} from "../types/Nullable";
import {DatabaseDefinition} from "../Database/DatabaseDefinition";
import {Container} from "../Container/Container";
import {EventHandler} from "../Events/EventHandler";
import {ElementCreatedEvent} from "../Events/ElementCreatedEvent";
export class Repository<ModelType extends Model, IntermediateModelType = unknown> {
constructor(
protected readonly database: DatabaseConnection,
public readonly schema: DatabaseDefinition,
) {}
public create(instance: Partial<ModelType>): number|bigint {
const columnNames = this.schema.columns.filter((column) => {
return !column.primaryKey
}).map((column) => {
return column.name;
});
const createObject = this.convertToCreateObject(instance);
const keys = Object.keys(createObject);
const missingColumns = columnNames.filter((columnName) => {
return !keys.includes(columnName);
})
if (missingColumns.length > 0) {
throw new Error("Can't create instance, due to missing column values: " + missingColumns);
}
const sql = `INSERT INTO ${this.schema.name}(${Object.keys(createObject).join(',')})
VALUES (${Object.keys(createObject).map(() => "?").join(',')})`;
const result = this.database.execute(sql, ...Object.values(createObject));
const id = result.lastInsertRowid;
Container.get<EventHandler>(EventHandler.name).dispatch(new ElementCreatedEvent<ModelType>(this.schema.name, instance, id));
return id;
constructor(
protected readonly database: DatabaseConnection,
public readonly schema: DatabaseDefinition,
) {
}
public create(instance: Partial<ModelType>): number | bigint {
const columnNames = this.schema.columns.filter((column) => {
return !column.primaryKey
}).map((column) => {
return column.name;
});
const createObject = this.convertToCreateObject(instance);
const keys = Object.keys(createObject);
const missingColumns = columnNames.filter((columnName) => {
return !keys.includes(columnName);
})
if (missingColumns.length > 0) {
throw new Error("Can't create instance, due to missing column values: " + missingColumns);
}
public update(instance: Partial<ModelType>&{id: number}): boolean {
const columnNames = this.schema.columns.filter((column) => {
return !column.primaryKey
}).map((column) => {
return column.name;
});
const createObject = this.convertToCreateObject(instance);
const keys = Object.keys(createObject);
const missingColumns = columnNames.filter((columnName) => {
return !keys.includes(columnName);
})
if (missingColumns.length > 0) {
throw new Error("Can't create instance, due to missing column values: " + missingColumns);
}
const sql = `INSERT INTO ${this.schema.name}(${Object.keys(createObject).join(',')})
VALUES (${Object.keys(createObject).map(() => "?").join(',')})`;
const result = this.database.execute(sql, ...Object.values(createObject));
const id = result.lastInsertRowid;
const sql = `UPDATE ${this.schema.name}
Container.get<EventHandler>(EventHandler.name).dispatch(new ElementCreatedEvent<ModelType>(this.schema.name, instance, id));
return id;
}
public update(instance: Partial<ModelType> & { id: number }): boolean {
const columnNames = this.schema.columns.filter((column) => {
return !column.primaryKey
}).map((column) => {
return column.name;
});
const createObject = this.convertToCreateObject(instance);
const keys = Object.keys(createObject);
const missingColumns = columnNames.filter((columnName) => {
return !keys.includes(columnName);
})
if (missingColumns.length > 0) {
throw new Error("Can't create instance, due to missing column values: " + missingColumns);
}
const sql = `UPDATE ${this.schema.name}
SET ${Object.keys(createObject).map((key) => `${key} = ?`).join(',')}
WHERE id = ?`;
const result = this.database.execute(sql, ...Object.values(createObject), instance.id);
return result.changes > 0;
}
public getById(id: number): Nullable<ModelType> {
const sql = `SELECT * FROM ${this.schema.name} WHERE id = ? LIMIT 1`;
return this.convertToModelType(this.database.fetch<number, IntermediateModelType>(sql, id));
}
public delete(id: number) {
const sql = `DELETE FROM ${this.schema.name} WHERE id = ?`;
return this.database.execute(sql, id);
}
protected convertToModelType(intermediateModel: IntermediateModelType | undefined): ModelType {
return intermediateModel as unknown as ModelType;
}
protected convertToCreateObject(instance: Partial<ModelType>): object {
return instance;
}
const result = this.database.execute(sql, ...Object.values(createObject), instance.id);
return result.changes > 0;
}
public getById(id: number): Nullable<ModelType> {
const sql = `SELECT * FROM ${this.schema.name} WHERE id = ? LIMIT 1`;
return this.convertToModelType(this.database.fetch<number, IntermediateModelType>(sql, id));
}
public delete(id: number) {
const sql = `DELETE FROM ${this.schema.name} WHERE id = ?`;
return this.database.execute(sql, id);
}
protected convertToModelType(intermediateModel: IntermediateModelType | undefined): ModelType {
return intermediateModel as unknown as ModelType;
}
protected convertToCreateObject(instance: Partial<ModelType>): object {
return instance;
}
}

View file

@ -1,17 +1,17 @@
export class ArrayUtils {
public static chooseRandom<T>(array: Array<T>):T {
const index = Math.floor(Math.random() * array.length);
return array[index];
}
public static chooseRandom<T>(array: Array<T>): T {
const index = Math.floor(Math.random() * array.length);
return array[index];
}
public static arraysEqual<T>(a: Array<T>, b: Array<T>): boolean {
if (a === b) return true;
if (a == null || b == null) return false;
if (a.length !== b.length) return false;
public static arraysEqual<T>(a: Array<T>, b: Array<T>): boolean {
if (a === b) return true;
if (a == null || b == null) return false;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; ++i) {
if (a[i] !== b[i]) return false;
}
return true;
for (let i = 0; i < a.length; ++i) {
if (a[i] !== b[i]) return false;
}
return true;
}
}

View file

@ -21,26 +21,18 @@ const logger = container.get<Logger>("logger");
const client = container.get<DiscordClient>(DiscordClient.name);
client.connectRESTClient(environment.discord.token)
logger.log("Deploying Commands...")
const deployer = new CommandDeployer(client, logger);
(async () => {
await deployer.deployAvailableServers()
})()
logger.log("Ensuring Database...");
const updater = new DatabaseUpdater(container.get<DatabaseConnection>(DatabaseConnection.name));
updater.ensureAvaliablity(Definitions);
logger.log("Ensuring icons...");
(async () => {
const iconCache = container.get<IconCache>(IconCache.name);
await iconCache.populate();
const deployer = new IconDeployer(
iconCache
);
const iconCache = container.get<IconCache>(IconCache.name);
await iconCache.populate();
deployer.ensureExistance()
const deployer = new IconDeployer(
iconCache
);
deployer.ensureExistance()
})()

View file

@ -14,9 +14,9 @@ DefaultEvents.setupHandlers();
const client = container.get<DiscordClient>(DiscordClient.name);
client.connectRESTClient(env.discord.token);
await container.get<IconCache>(IconCache.name).populate()
client.applyEvents()
client.connect(env.discord.token)
})()

View file

@ -1,2 +1,2 @@
export type Class = {constructor: {name: string}}
export type ClassNamed = {name: string}
export type Class = { constructor: { name: string } }
export type ClassNamed = { name: string }

View file

@ -1,11 +1,11 @@
export type GuildMember = {
server: string;
memberid: string;
server: string;
memberid: string;
}
export type Role = {
server: string;
roleid: string;
server: string;
roleid: string;
};
export type ChannelId = string;