Adds initial progress

This commit is contained in:
Michel Fedde 2025-03-28 23:19:54 +01:00
commit a0b668cb90
34 changed files with 2680 additions and 0 deletions

View file

@ -0,0 +1,26 @@
export class Container {
static instance: Container;
private instances: Map<string, object> = new Map();
public set<T extends {constructor: {name: string}}>(instance: T, name: string|null = null): void
{
this.instances.set(name ?? instance.constructor.name, instance);
}
public get<T>(name: string): T
{
return <T>this.instances.get(name);
}
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

@ -0,0 +1,60 @@
import {Environment} from "../Environment";
import {Container} from "./Container";
import {DatabaseConnection} from "../Database/DatabaseConnection";
import {getLogger, configure, Logger} from "log4js";
import path from "node:path";
import {GroupRepository} from "../Repositories/GroupRepository";
import {PlaydateRepository} from "../Repositories/PlaydateRepository";
import {GuildEmojiRoleManager} from "discord.js";
export enum ServiceHint {
App,
Deploy
}
export class Services {
public static setup(container: Container, hint: ServiceHint) {
const env = new Environment();
env.setup();
container.set<Environment>(env);
const database = new DatabaseConnection(env.database);
container.set<DatabaseConnection>(database);
// @ts-ignore
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 logger = getLogger(loggername);
container.set<Logger>(logger, 'logger');
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)))
}
}

View file

@ -0,0 +1,44 @@
import {DatabaseEnvironment, Environment} from "../Environment";
import Sqlite3 from "better-sqlite3";
import Database from "better-sqlite3";
import {Container} from "../Container/Container";
import {Logger} from "log4js";
export class DatabaseConnection {
private static connection: DatabaseConnection;
private database: Sqlite3.Database;
constructor(private readonly 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: any[]): 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: any[]): 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: any[]): 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

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

View file

@ -0,0 +1,81 @@
import {DatabaseConnection} from "./DatabaseConnection";
import {DatabaseColumnDefinition, DatabaseDefinition} from "./DatabaseDefinition";
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<{}, {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})`;
const result = this.database.execute(sql);
}
}

View file

@ -0,0 +1,10 @@
import Groups from "./tables/Groups";
import {DatabaseDefinition} from "./DatabaseDefinition";
import Playdate from "./tables/Playdate";
const definitions = new Set<DatabaseDefinition>([
Groups,
Playdate
]);
export default definitions;

View file

@ -0,0 +1,39 @@
import {DatabaseDefinition} from "../DatabaseDefinition";
export type DBGroup = {
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)",
}
]
}
export default dbDefinition;

View file

@ -0,0 +1,35 @@
import {DatabaseDefinition} from "../DatabaseDefinition";
export type DBPlaydate = {
id: number;
groupid: 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",
}
]
}
export default dbDefinition;

View file

@ -0,0 +1,46 @@
import {
AutocompleteInteraction,
ChatInputCommandInteraction,
CommandInteraction,
GuildMember,
SlashCommandStringOption
} from "discord.js";
import {Container} from "../../Container/Container";
import {GroupRepository} from "../../Repositories/GroupRepository";
import {GroupModel} from "../../Models/GroupModel";
import {UserError} from "../UserError";
export class GroupSelection {
public static createOptionSetup(): SlashCommandStringOption {
return new SlashCommandStringOption()
.setName("group")
.setDescription("Defines the group you want to manage the playdates for")
.setRequired(true)
.setAutocomplete(true)
}
public static async handleAutocomplete(interaction: AutocompleteInteraction): Promise<void> {
const value = interaction.options.getFocused();
const repo = Container.get<GroupRepository>(GroupRepository.name);
const groups = repo.findGroupsByMember(<GuildMember>interaction.member);
await interaction.respond(
groups
.filter((group) => group.name.startsWith(value))
.map((group) => ({name: group.name, value: group.name }))
)
}
public static getGroup(interaction: CommandInteraction): GroupModel {
const groupname = interaction.options.get("group");
if (!groupname) {
throw new UserError("No group name provided");
}
const group = Container.get<GroupRepository>(GroupRepository.name).findGroupByName((groupname.value ?? '').toString());
if (!group) {
throw new UserError("No group found");
}
return <GroupModel>group;
}
}

View file

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

View file

@ -0,0 +1,41 @@
import {HelloWorldCommand} from "./HelloWorldCommand";
import {Command, CommandUnion} from "./Command";
import {GroupCommand} from "./Groups";
import {PlaydatesCommand} from "./Playdates";
const commands: Set<Command> = new Set<Command>([
new HelloWorldCommand(),
new GroupCommand(),
new PlaydatesCommand()
]);
export default class Commands {
private mappedCommands: Map<string, Command> = new Map<string, Command>();
constructor() {
this.mappedCommands = this.getMap();
}
public getCommand(commandName: string): CommandUnion|undefined {
if (!this.mappedCommands.has(commandName)) {
throw new Error(`Unknown command: ${commandName}`);
}
return this.mappedCommands.get(commandName);
}
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;
}
}

View file

@ -0,0 +1,100 @@
import {
SlashCommandBuilder,
Interaction,
CommandInteraction,
ChatInputCommandInteraction,
MessageFlags, GuildMemberRoleManager, InteractionReplyOptions, GuildMember
} from "discord.js";
import {ChatInteractionCommand, Command} from "./Command";
import {GroupModel} from "../../Models/GroupModel";
import {GroupRepository} from "../../Repositories/GroupRepository";
import {DatabaseConnection} from "../../Database/DatabaseConnection";
import {Container} from "../../Container/Container";
export class GroupCommand implements Command, ChatInteractionCommand {
definition(): SlashCommandBuilder {
// @ts-ignore
return new SlashCommandBuilder()
.setName('groups')
.setDescription(`Manages groups`)
.addSubcommand(create =>
create.setName("create")
.setDescription("Creates a new group, with executing user being the leader")
.addStringOption((option) =>
option.setName("name")
.setDescription("Defines the name for the group.")
.setRequired(true)
)
.addRoleOption((builder) =>
builder.setName("role")
.setDescription("Defines the role, where all the members are located in.")
.setRequired(true)
)
)
.addSubcommand(listCommand =>
listCommand
.setName("list")
.setDescription("Displays the groups you are apart of.")
);
}
execute(interaction: ChatInputCommandInteraction): Promise<void> {
switch (interaction.options.getSubcommand()) {
case "create":
this.create(interaction);
break;
case "list":
this.list(interaction);
break;
default:
throw new Error("Unsupported command");
}
return Promise.resolve();
}
private create(interaction: ChatInputCommandInteraction): void {
const name = interaction.options.getString("name") ?? '';
const role = interaction.options.getRole("role");
const group: GroupModel = {
id: -1,
name: name,
leader: {
server: interaction.guildId ?? '',
memberid: interaction.member?.user.id ?? ''
},
role: {
server: interaction.guildId ?? '',
roleid: role?.id ?? ''
}
}
Container.get<GroupRepository>(GroupRepository.name).create(group);
interaction.reply({content: `:white_check_mark: Created group \`${name}\``, flags: MessageFlags.Ephemeral })
}
private list(interaction: ChatInputCommandInteraction) {
const repo = Container.get<GroupRepository>(GroupRepository.name);
const groups = repo.findGroupsByMember(<GuildMember>interaction.member);
const reply: InteractionReplyOptions = {
embeds: [
{
title: "Your groups on this server:",
fields: groups.map((group) => {
return {
name: group.name,
value: ""
}
})
}
],
flags: MessageFlags.Ephemeral
}
interaction.reply(reply);
}
}

View file

@ -0,0 +1,25 @@
import {SlashCommandBuilder, Interaction, 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 adventurerer 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

@ -0,0 +1,206 @@
import {
SlashCommandBuilder,
Interaction,
CommandInteraction,
AutocompleteInteraction,
GuildMember,
EmbedBuilder, MessageFlags, ChatInputCommandInteraction, ModalSubmitFields
} from "discord.js";
import {AutocompleteCommand, ChatInteractionCommand, Command} from "./Command";
import {Container} from "../../Container/Container";
import {GroupRepository} from "../../Repositories/GroupRepository";
import {GroupSelection} from "../CommandPartials/GroupSelection";
import {setFlagsFromString} from "node:v8";
import {UserError} from "../UserError";
import Playdate from "../../Database/tables/Playdate";
import {PlaydateModel} from "../../Models/PlaydateModel";
import {PlaydateRepository} from "../../Repositories/PlaydateRepository";
import {GroupModel} from "../../Models/GroupModel";
import playdate from "../../Database/tables/Playdate";
export class PlaydatesCommand implements Command, AutocompleteCommand, ChatInteractionCommand {
static REGEX = [
]
definition(): SlashCommandBuilder {
// @ts-ignore
return new SlashCommandBuilder()
.setName("playdates")
.setDescription("Manage your playdates")
.addSubcommand((subcommand) => subcommand
.setName("create")
.setDescription("Creates a new playdate")
.addStringOption(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")
)
.addAttachmentOption((option) => option
.setName("calendar-entry")
.setDescription("Optional, you can upload a iCal file and the from and to-values are read from it.")
)
)
.addSubcommand((subcommand) => subcommand
.setName("list")
.setDescription("Lists all playdates")
.addStringOption(GroupSelection.createOptionSetup())
)
.addSubcommand((subcommand) => subcommand
.setName("remove")
.setDescription("Removes a playdate")
.addStringOption(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.");
}
const playdate: Partial<PlaydateModel> = {
group: group,
from_time: new Date(fromDate),
to_time: new Date(toDate),
}
const id = Container.get<PlaydateRepository>(PlaydateRepository.name).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.")
.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 groupname = interaction.options.getString("group")
const group = Container.get<GroupRepository>(GroupRepository.name).findGroupByName((groupname ?? '').toString());
if (!group) {
throw new UserError("No group found");
}
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
}
})
)
}
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: `${playdate.from_time.toLocaleString()} - ${playdate.to_time.toLocaleString()}`,
value: ``
}
})
)
.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");
}
console.log(selected, group);
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,
})
}
}

View file

@ -0,0 +1,120 @@
import {
Client,
GatewayIntentBits,
Events,
Interaction,
ChatInputCommandInteraction,
MessageFlags,
Activity,
ActivityType
} from "discord.js";
import Commands from "./Commands/Commands";
import {Container} from "../Container/Container";
import {Logger} from "log4js";
import {UserError} from "./UserError";
export class DiscordClient {
private readonly client: Client;
private commands: Commands;
public get Client (): Client {
return this.client;
}
constructor() {
this.client = new Client({
intents: [GatewayIntentBits.Guilds]
})
this.commands = new Commands();
}
applyEvents() {
this.client.once(Events.ClientReady, () => {
Container.get<Logger>("logger").info(`Ready! Logged in as ${this.client.user.tag}`);
this.client.user.setActivity('your PnP playdates', {
type: ActivityType.Watching,
});
})
this.client.on(Events.InteractionCreate, async (interaction: Interaction) => {
const command = this.commands.getCommand(interaction.commandName);
if (command === null) {
Container.get<Logger>("logger").error(`Could not find command for '${interaction.commandName}'`);
return;
}
const method = this.findCommandMethod(interaction);
if (!method) {
Container.get<Logger>("logger").error(`Could not find method for '${interaction.commandName}'`);
return;
}
await method();
})
}
connect(token: string) {
this.client.login(token);
}
private findCommandMethod(interaction: Interaction) {
if (interaction.isChatInputCommand()) {
const command = this.commands.getCommand(interaction.commandName);
if (!command) {
return null;
}
if (!('execute' in command)) {
return null;
}
return async () => {
Container.get<Logger>("logger").debug(`Found chat command ${interaction.commandName}: running...`);
try {
await command.execute(interaction)
}
catch (e: Error) {
Container.get<Logger>("logger").error(e)
let userMessage = ":x: There was an error while executing this command!";
if (e.constructor.name === UserError.name) {
userMessage = `:x: \`${e.message}\` - Please validate your request!`
}
if (interaction.replied || interaction.deferred) {
await interaction.followUp({ content: userMessage, flags: MessageFlags.Ephemeral });
} else {
await interaction.reply({ content: userMessage, flags: MessageFlags.Ephemeral });
}
}
}
}
if (interaction.isAutocomplete()) {
const command = this.commands.getCommand(interaction.commandName);
if (!command) {
return null;
}
if (!('handleAutocomplete' in command)) {
return null;
}
return async () => {
Container.get<Logger>("logger").debug(`Found command ${interaction.commandName} for autocomplete: handling...`);
try {
await command.handleAutocomplete(interaction);
} catch (e: any) {
Container.get<Logger>('logger').error(e);
}
}
}
return null;
}
}

View file

@ -0,0 +1,3 @@
export class UserError extends Error {
}

34
source/Environment.ts Normal file
View file

@ -0,0 +1,34 @@
import dotenv from "dotenv";
import path from "node:path";
type DiscordEnvironment = {
token: string;
guildId: string;
clientId: string;
}
export type DatabaseEnvironment = {
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 database(): DatabaseEnvironment {
return {
path: path.resolve(process.env.DB_PATH ?? ''),
}
}
public setup() {
dotenv.config({
path: path.resolve(__dirname, "../environment/.env"),
});
}
}

View file

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

3
source/Models/Model.ts Normal file
View file

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

View file

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

View file

@ -0,0 +1,82 @@
import {Repository} from "./Repository";
import {GroupModel} from "../Models/GroupModel";
import Groups, {DBGroup} from "../Database/tables/Groups";
import {DatabaseConnection} from "../Database/DatabaseConnection";
import {CacheType, CacheTypeReducer, Guild, GuildMember, GuildMemberRoleManager} from "discord.js";
import {Nullable} from "../types/Nullable";
export class GroupRepository extends Repository<GroupModel, DBGroup> {
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;
}
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));
}
public findGroupsByMember(member: GuildMember) {
if (!member) {
throw new Error("Can't find member for guild: none given");
}
return this.findGroupsByRoles(member.guild.id, [...member.roles.cache.keys()])
}
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

@ -0,0 +1,53 @@
import {Repository} from "./Repository";
import {PlaydateModel} from "../Models/PlaydateModel";
import Playdate, {DBPlaydate} from "../Database/tables/Playdate";
import {DatabaseConnection} from "../Database/DatabaseConnection";
import {GroupRepository} from "./GroupRepository";
import {GroupModel} from "../Models/GroupModel";
import {Nullable} from "../types/Nullable";
import playdate from "../Database/tables/Playdate";
export class PlaydateRepository extends Repository<PlaydateModel, DBPlaydate> {
constructor(
protected readonly database: DatabaseConnection,
private readonly groupRepository: GroupRepository,
) {
super(
database,
Playdate
);
}
findFromGroup(group: GroupModel) {
const finds = this.database.fetchAll<number, DBPlaydate>(
`SELECT * FROM ${this.schema.name} WHERE groupid = ? AND time_from > ?`,
group.id,
new Date().getTime()
);
return finds.map((playdate) => this.convertToModelType(playdate, group));
}
protected convertToModelType(intermediateModel: DBPlaydate | undefined, fixedGroup: Nullable<GroupModel> = null): PlaydateModel {
if (!intermediateModel) {
throw new Error("Unable to convert the playdate model");
}
const result: PlaydateModel = {
id: intermediateModel.id,
group: fixedGroup ?? this.groupRepository.getById(intermediateModel.groupid),
from_time: new Date(intermediateModel.time_from),
to_time: new Date(intermediateModel.time_to),
}
return result;
}
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

@ -0,0 +1,52 @@
import {DatabaseConnection} from "../Database/DatabaseConnection";
import {Model} from "../Models/Model";
import { Nullable } from "../types/Nullable";
import {DatabaseDefinition} from "../Database/DatabaseDefinition";
import {debug} from "node:util";
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));
return result.lastInsertRowid;
}
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;
}
}

46
source/deploy.ts Normal file
View file

@ -0,0 +1,46 @@
import Commands from "./Discord/Commands/Commands";
import {Environment} from "./Environment";
import {DatabaseConnection} from "./Database/DatabaseConnection";
import {DatabaseUpdater} from "./Database/DatabaseUpdater";
import Definitions from "./Database/definitions";
import {Container} from "./Container/Container";
import {ServiceHint, Services} from "./Container/Services";
import {Logger} from "log4js";
const { REST, Routes } = require('discord.js');
const container = Container.getInstance();
Services.setup(container, ServiceHint.Deploy)
const commands = new Commands().allCommands;
const environment = container.get<Environment>(Environment.name);
const logger = container.get<Logger>("logger");
// Construct and prepare an instance of the REST module
const rest = new REST().setToken(environment.discord.token);
// and deploy your commands!
(async () => {
try {
const commandInfos = [];
commands.forEach((command) => {
commandInfos.push(command.definition().toJSON())
})
logger.log(`Started refreshing ${commandInfos.length} application (/) commands.`);
// The put method is used to fully refresh all commands in the guild with the current set
const data = await rest.put(
Routes.applicationGuildCommands(environment.discord.clientId, environment.discord.guildId),
{ body: commandInfos },
);
logger.log(`Successfully reloaded ${commandInfos.length} application (/) commands.`);
} catch (error) {
// And of course, make sure you catch and log any errors!
logger.error(error);
}
})();
logger.log("Ensuring Database...");
const updater = new DatabaseUpdater(container.get<DatabaseConnection>(DatabaseConnection.name));
updater.ensureAvaliablity(Definitions);

12
source/main.ts Normal file
View file

@ -0,0 +1,12 @@
import {DiscordClient} from "./Discord/DiscordClient";
import {Environment} from "./Environment";
import {Container} from "./Container/Container";
import {DatabaseConnection} from "./Database/DatabaseConnection";
import {ServiceHint, Services} from "./Container/Services";
const container = Container.getInstance();
Services.setup(container, ServiceHint.App);
const client = new DiscordClient()
client.applyEvents()
client.connect(container.get<Environment>(Environment.name).discord.token)

View file

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

1
source/types/Nullable.ts Normal file
View file

@ -0,0 +1 @@
export type Nullable<T> = T | null | undefined;