Adds initial progress
This commit is contained in:
commit
a0b668cb90
34 changed files with 2680 additions and 0 deletions
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
/node_modules/
|
||||||
|
/dist/
|
||||||
|
|
||||||
|
/environment/
|
||||||
|
/data/
|
||||||
|
/logs/
|
||||||
4
build/build-cli.mjs
Normal file
4
build/build-cli.mjs
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
import context from './context.mjs';
|
||||||
|
|
||||||
|
await context.rebuild();
|
||||||
|
await context.dispose();
|
||||||
16
build/context.mjs
Normal file
16
build/context.mjs
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import * as esbuild from "esbuild";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
const context = await esbuild.context({
|
||||||
|
entryPoints: [
|
||||||
|
path.join('source', 'main.ts'),
|
||||||
|
path.join('source', 'deploy.ts')
|
||||||
|
],
|
||||||
|
bundle: true,
|
||||||
|
outdir: './dist/',
|
||||||
|
platform: 'node',
|
||||||
|
target: 'node10.4',
|
||||||
|
sourcemap: 'linked',
|
||||||
|
})
|
||||||
|
|
||||||
|
export default context
|
||||||
3
build/watch.mjs
Normal file
3
build/watch.mjs
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
import context from './context.mjs'
|
||||||
|
|
||||||
|
await context.watch();
|
||||||
1432
package-lock.json
generated
Normal file
1432
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
26
package.json
Normal file
26
package.json
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
{
|
||||||
|
"name": "dsa-scheduler",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"author": "",
|
||||||
|
"type": "commonjs",
|
||||||
|
"main": "main.ts",
|
||||||
|
"scripts": {
|
||||||
|
"build": "node ./build/build-cli.mjs",
|
||||||
|
"watch": "node ./build/watch.mjs",
|
||||||
|
"deploy": "node ./dist/deploy.js",
|
||||||
|
"start": "node ./dist/main.js",
|
||||||
|
"edge-start": "npm run deploy && npm run start"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@types/better-sqlite3": "^7.6.12",
|
||||||
|
"@types/log4js": "^0.0.33",
|
||||||
|
"@types/node": "^22.13.9",
|
||||||
|
"better-sqlite3": "^11.8.1",
|
||||||
|
"discord.js": "^14.18.0",
|
||||||
|
"dotenv": "^16.4.7",
|
||||||
|
"esbuild": "^0.25.0",
|
||||||
|
"log4js": "^6.9.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
26
source/Container/Container.ts
Normal file
26
source/Container/Container.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
60
source/Container/Services.ts
Normal file
60
source/Container/Services.ts
Normal 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)))
|
||||||
|
}
|
||||||
|
}
|
||||||
44
source/Database/DatabaseConnection.ts
Normal file
44
source/Database/DatabaseConnection.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
13
source/Database/DatabaseDefinition.ts
Normal file
13
source/Database/DatabaseDefinition.ts
Normal 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[];
|
||||||
|
}
|
||||||
81
source/Database/DatabaseUpdater.ts
Normal file
81
source/Database/DatabaseUpdater.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
source/Database/definitions.ts
Normal file
10
source/Database/definitions.ts
Normal 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;
|
||||||
39
source/Database/tables/Groups.ts
Normal file
39
source/Database/tables/Groups.ts
Normal 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;
|
||||||
35
source/Database/tables/Playdate.ts
Normal file
35
source/Database/tables/Playdate.ts
Normal 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;
|
||||||
46
source/Discord/CommandPartials/GroupSelection.ts
Normal file
46
source/Discord/CommandPartials/GroupSelection.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
17
source/Discord/Commands/Command.ts
Normal file
17
source/Discord/Commands/Command.ts
Normal 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>;
|
||||||
41
source/Discord/Commands/Commands.ts
Normal file
41
source/Discord/Commands/Commands.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
100
source/Discord/Commands/Groups.ts
Normal file
100
source/Discord/Commands/Groups.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
source/Discord/Commands/HelloWorldCommand.ts
Normal file
25
source/Discord/Commands/HelloWorldCommand.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
206
source/Discord/Commands/Playdates.ts
Normal file
206
source/Discord/Commands/Playdates.ts
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
120
source/Discord/DiscordClient.ts
Normal file
120
source/Discord/DiscordClient.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
3
source/Discord/UserError.ts
Normal file
3
source/Discord/UserError.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export class UserError extends Error {
|
||||||
|
|
||||||
|
}
|
||||||
34
source/Environment.ts
Normal file
34
source/Environment.ts
Normal 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"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
8
source/Models/GroupModel.ts
Normal file
8
source/Models/GroupModel.ts
Normal 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
3
source/Models/Model.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export interface Model {
|
||||||
|
id: number;
|
||||||
|
}
|
||||||
9
source/Models/PlaydateModel.ts
Normal file
9
source/Models/PlaydateModel.ts
Normal 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,
|
||||||
|
}
|
||||||
82
source/Repositories/GroupRepository.ts
Normal file
82
source/Repositories/GroupRepository.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
53
source/Repositories/PlaydateRepository.ts
Normal file
53
source/Repositories/PlaydateRepository.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
52
source/Repositories/Repository.ts
Normal file
52
source/Repositories/Repository.ts
Normal 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
46
source/deploy.ts
Normal 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
12
source/main.ts
Normal 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)
|
||||||
9
source/types/DiscordTypes.ts
Normal file
9
source/types/DiscordTypes.ts
Normal 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
1
source/types/Nullable.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export type Nullable<T> = T | null | undefined;
|
||||||
15
tsconfig.json
Normal file
15
tsconfig.json
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/tsconfig",
|
||||||
|
"_version": "20.1.0",
|
||||||
|
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["es2023"],
|
||||||
|
"module": "node16",
|
||||||
|
"target": "es2022",
|
||||||
|
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "node16"
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue