Adds eslint and linted & improved routing for interactions

This commit is contained in:
Michel Fedde 2025-06-17 20:37:53 +02:00
parent 83209f642c
commit 441715675c
35 changed files with 2091 additions and 463 deletions

15
eslint.config.mjs Normal file
View file

@ -0,0 +1,15 @@
import js from "@eslint/js";
import globals from "globals";
import tseslint from "typescript-eslint";
import { defineConfig } from "eslint/config";
export default defineConfig([
{ files: ["**/*.{js,mjs,cjs,ts,mts,cts}"], plugins: { js }, extends: ["js/recommended"] },
{ files: ["**/*.{js,mjs,cjs,ts,mts,cts}"], languageOptions: { globals: globals.browser } },
tseslint.configs.recommended,
{
rules: {
}
}
]);

1599
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -28,5 +28,11 @@
"node-cron": "^4.0.7", "node-cron": "^4.0.7",
"object-path-set": "^1.0.2", "object-path-set": "^1.0.2",
"svg2img": "^1.0.0-beta.2" "svg2img": "^1.0.0-beta.2"
},
"devDependencies": {
"@eslint/js": "^9.29.0",
"eslint": "^9.29.0",
"globals": "^16.2.0",
"typescript-eslint": "^8.34.1"
} }
} }

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--><path d="M41.4 233.4c-12.5 12.5-12.5 32.8 0 45.3l160 160c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L109.3 256 246.6 118.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-160 160z"/></svg>

After

Width:  |  Height:  |  Size: 399 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--><path fill="#ffffff" d="M320 32c0-9.9-4.5-19.2-12.3-25.2S289.8-1.4 280.2 1l-179.9 45C79 51.3 64 70.5 64 92.5L64 448l-32 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l64 0 192 0 32 0 0-32 0-448zM256 256c0 17.7-10.7 32-24 32s-24-14.3-24-32s10.7-32 24-32s24 14.3 24 32zm96-128l96 0 0 352c0 17.7 14.3 32 32 32l64 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-32 0 0-320c0-35.3-28.7-64-64-64l-96 0 0 64z"/></svg>

After

Width:  |  Height:  |  Size: 605 B

View file

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--><path d="M64 480H448c35.3 0 64-28.7 64-64V160c0-35.3-28.7-64-64-64H288c-10.1 0-19.6-4.7-25.6-12.8L243.2 57.6C231.1 41.5 212.1 32 192 32H64C28.7 32 0 60.7 0 96V416c0 35.3 28.7 64 64 64z"/></svg> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--><path fill="white" d="M64 480H448c35.3 0 64-28.7 64-64V160c0-35.3-28.7-64-64-64H288c-10.1 0-19.6-4.7-25.6-12.8L243.2 57.6C231.1 41.5 212.1 32 192 32H64C28.7 32 0 60.7 0 96V416c0 35.3 28.7 64 64 64z"/></svg>

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 419 B

Before After
Before After

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--><path fill="#9a9996" d="M64 32C64 14.3 49.7 0 32 0S0 14.3 0 32l0 96L0 384c0 35.3 28.7 64 64 64l192 0 0-64L64 384l0-224 192 0 0-64L64 96l0-64zM288 192c0 17.7 14.3 32 32 32l224 0c17.7 0 32-14.3 32-32l0-128c0-17.7-14.3-32-32-32l-98.7 0c-8.5 0-16.6-3.4-22.6-9.4L409.4 9.4c-6-6-14.1-9.4-22.6-9.4L320 0c-17.7 0-32 14.3-32 32l0 160zm0 288c0 17.7 14.3 32 32 32l224 0c17.7 0 32-14.3 32-32l0-128c0-17.7-14.3-32-32-32l-98.7 0c-8.5 0-16.6-3.4-22.6-9.4l-13.3-13.3c-6-6-14.1-9.4-22.6-9.4L320 288c-17.7 0-32 14.3-32 32l0 160z"/></svg>

Before

Width:  |  Height:  |  Size: 732 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--><path fill="white" d="M362.7 19.3L314.3 67.7 444.3 197.7l48.4-48.4c25-25 25-65.5 0-90.5L453.3 19.3c-25-25-65.5-25-90.5 0zm-71 71L58.6 323.5c-10.4 10.4-18 23.3-22.2 37.4L1 481.2C-1.5 489.7 .8 498.8 7 505s15.3 8.5 23.7 6.1l120.3-35.4c14.1-4.2 27-11.8 37.4-22.2L421.7 220.3 291.7 90.3z"/></svg>

After

Width:  |  Height:  |  Size: 504 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512"><!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--><path fill="#FFD43B" d="M72 88a56 56 0 1 1 112 0A56 56 0 1 1 72 88zM64 245.7C54 256.9 48 271.8 48 288s6 31.1 16 42.3l0-84.7zm144.4-49.3C178.7 222.7 160 261.2 160 304c0 34.3 12 65.8 32 90.5l0 21.5c0 17.7-14.3 32-32 32l-64 0c-17.7 0-32-14.3-32-32l0-26.8C26.2 371.2 0 332.7 0 288c0-61.9 50.1-112 112-112l32 0c24 0 46.2 7.5 64.4 20.3zM448 416l0-21.5c20-24.7 32-56.2 32-90.5c0-42.8-18.7-81.3-48.4-107.7C449.8 183.5 472 176 496 176l32 0c61.9 0 112 50.1 112 112c0 44.7-26.2 83.2-64 101.2l0 26.8c0 17.7-14.3 32-32 32l-64 0c-17.7 0-32-14.3-32-32zm8-328a56 56 0 1 1 112 0A56 56 0 1 1 456 88zM576 245.7l0 84.7c10-11.3 16-26.1 16-42.3s-6-31.1-16-42.3zM320 32a64 64 0 1 1 0 128 64 64 0 1 1 0-128zM240 304c0 16.2 6 31 16 42.3l0-84.7c-10 11.3-16 26.1-16 42.3zm144-42.3l0 84.7c10-11.3 16-26.1 16-42.3s-6-31.1-16-42.3zM448 304c0 44.7-26.2 83.2-64 101.2l0 42.8c0 17.7-14.3 32-32 32l-64 0c-17.7 0-32-14.3-32-32l0-42.8c-37.8-18-64-56.5-64-101.2c0-61.9 50.1-112 112-112l32 0c61.9 0 112 50.1 112 112z"/></svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -1,15 +1,16 @@
import {Environment} from "../Environment"; import {Environment} from "../Environment";
import {Container} from "./Container"; import {Container} from "./Container";
import {DatabaseConnection} from "../Database/DatabaseConnection"; import {DatabaseConnection} from "../Database/DatabaseConnection";
import {getLogger, configure, Logger} from "log4js"; import {configure, getLogger, Logger} from "log4js";
import path from "node:path"; import path from "node:path";
import {GroupRepository} from "../Repositories/GroupRepository"; import {GroupRepository} from "../Repositories/GroupRepository";
import {PlaydateRepository} from "../Repositories/PlaydateRepository"; import {PlaydateRepository} from "../Repositories/PlaydateRepository";
import {GuildEmojiRoleManager} from "discord.js";
import {GroupConfigurationRepository} from "../Repositories/GroupConfigurationRepository"; import {GroupConfigurationRepository} from "../Repositories/GroupConfigurationRepository";
import {DiscordClient} from "../Discord/DiscordClient"; import {DiscordClient} from "../Discord/DiscordClient";
import {IconCache} from "../Icons/IconCache"; import {IconCache} from "../Icons/IconCache";
import {EventHandler} from "../Events/EventHandler"; import {EventHandler} from "../Events/EventHandler";
import {InteractionRouter} from "../Discord/InteractionRouter";
import Commands from "../Discord/Commands/Commands";
export enum ServiceHint { export enum ServiceHint {
App, App,
@ -21,19 +22,35 @@ export class Services {
const env = new Environment(); const env = new Environment();
env.setup(); env.setup();
container.set<Environment>(env); container.set<Environment>(env);
const logger = this.setupLogger(hint);
container.set<Logger>(logger, 'logger');
const database = new DatabaseConnection(env.database); const database = new DatabaseConnection(env.database);
container.set<DatabaseConnection>(database); container.set<DatabaseConnection>(database);
const discordClient = new DiscordClient(env.discord.clientId); const discordClient = new DiscordClient(
env.discord.clientId,
new InteractionRouter(new Commands(), logger)
);
container.set<DiscordClient>(discordClient); container.set<DiscordClient>(discordClient);
const iconCache = new IconCache(discordClient); const iconCache = new IconCache(discordClient);
container.set<IconCache>(iconCache); container.set<IconCache>(iconCache);
container.set<EventHandler>(new EventHandler()); container.set<EventHandler>(new EventHandler());
this.setupRepositories(container);
// @ts-ignore }
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({ configure({
appenders: { appenders: {
out: { type: "stdout" }, out: { type: "stdout" },
@ -46,7 +63,7 @@ export class Services {
deploy: { appenders: ["out", "deployLogFile"], level: "debug" }, deploy: { appenders: ["out", "deployLogFile"], level: "debug" },
} }
}) })
let loggername = ''; let loggername = '';
switch (hint) { switch (hint) {
case ServiceHint.App: case ServiceHint.App:
@ -57,17 +74,6 @@ export class Services {
break; break;
} }
const logger = getLogger(loggername); return 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)))
container.set<GroupConfigurationRepository>(new GroupConfigurationRepository(db, container.get<GroupRepository>(GroupRepository.name)))
} }
} }

View file

@ -1,22 +1,20 @@
import {DatabaseEnvironment, Environment} from "../Environment"; import {DatabaseEnvironment} from "../Environment";
import Sqlite3 from "better-sqlite3"; import Sqlite3 from "better-sqlite3";
import Database from "better-sqlite3"; import Database from "better-sqlite3";
import {Container} from "../Container/Container"; import {Container} from "../Container/Container";
import {Logger} from "log4js"; import {Logger} from "log4js";
export class DatabaseConnection { export class DatabaseConnection {
private static connection: DatabaseConnection;
private database: Sqlite3.Database; private database: Sqlite3.Database;
constructor(private readonly env: DatabaseEnvironment) { constructor(env: DatabaseEnvironment) {
this.database = new Database(env.path, { this.database = new Database(env.path, {
nativeBinding: "node_modules/better-sqlite3/build/Release/better_sqlite3.node", nativeBinding: "node_modules/better-sqlite3/build/Release/better_sqlite3.node",
}) })
this.database.pragma('journal_mode = WAL'); this.database.pragma('journal_mode = WAL');
} }
public execute(query: string, ...args: any[]): Sqlite3.RunResult { public execute(query: string, ...args: unknown[]): Sqlite3.RunResult {
try { try {
const preparedQuery = this.database.prepare(query); const preparedQuery = this.database.prepare(query);
return preparedQuery.run(args); return preparedQuery.run(args);
@ -26,11 +24,11 @@ export class DatabaseConnection {
} }
} }
public fetch<BindParameters extends unknown[] | {} = unknown[], Result = unknown>(query: string, ...args: any[]): Result|undefined { public fetch<BindParameters extends unknown[] | {} = unknown[], Result = unknown>(query: string, ...args: unknown[]): Result|undefined {
const preparedQuery = this.database.prepare<BindParameters, Result>(query); const preparedQuery = this.database.prepare<BindParameters, Result>(query);
return preparedQuery.get(args); return preparedQuery.get(args);
} }
public fetchAll<BindParameters extends unknown[] | {} = unknown[], Result = unknown>(query: string, ...args: any[]): Result[] { public fetchAll<BindParameters extends unknown[] | {} = unknown[], Result = unknown>(query: string, ...args: unknown[]): Result[] {
const preparedQuery = this.database.prepare<BindParameters, Result>(query); const preparedQuery = this.database.prepare<BindParameters, Result>(query);
return preparedQuery.all(args); return preparedQuery.all(args);
} }

View file

@ -22,7 +22,7 @@ export class DatabaseUpdater {
} }
private ensureTableColumns(definition: DatabaseDefinition) { private ensureTableColumns(definition: DatabaseDefinition) {
const DBSQLColumns = this.database.fetchAll<{}, {name: string, type: string}>( const DBSQLColumns = this.database.fetchAll<object, {name: string, type: string}>(
`PRAGMA table_info("${definition.name}")` `PRAGMA table_info("${definition.name}")`
); );
@ -76,6 +76,6 @@ export class DatabaseUpdater {
}).join(', '); }).join(', ');
const sql = `CREATE TABLE IF NOT EXISTS ${definition.name} (${columnsSQL})`; const sql = `CREATE TABLE IF NOT EXISTS ${definition.name} (${columnsSQL})`;
const result = this.database.execute(sql); this.database.execute(sql);
} }
} }

View file

@ -10,7 +10,7 @@ export class CommandDeployer {
} }
public async deployAvailableServers() { public async deployAvailableServers() {
const commandInfos = []; const commandInfos: object[] = [];
this.client.Commands.allCommands.forEach((command) => { this.client.Commands.allCommands.forEach((command) => {
commandInfos.push(command.definition().toJSON()) commandInfos.push(command.definition().toJSON())
}) })
@ -27,11 +27,10 @@ export class CommandDeployer {
this.logger.log(`Started refreshing ${commandInfos.length} application (/) commands for ${serverId}.`); this.logger.log(`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 // The put method is used to fully refresh all commands in the guild with the current set
const data = await this.client.RESTClient.put( await this.client.RESTClient.put(
Routes.applicationGuildCommands(this.client.ApplicationId, serverId), Routes.applicationGuildCommands(this.client.ApplicationId, serverId),
{ body: commandInfos }, { body: commandInfos },
); );
this.logger.log(`Successfully reloaded ${commandInfos.length} application (/) commands for ${serverId}.`); this.logger.log(`Successfully reloaded ${commandInfos.length} application (/) commands for ${serverId}.`);
} }
} }

View file

@ -1,9 +1,7 @@
import { import {
AutocompleteInteraction, AutocompleteInteraction,
ChatInputCommandInteraction,
CommandInteraction, CommandInteraction,
GuildMember, SlashCommandIntegerOption, GuildMember, SlashCommandIntegerOption,
SlashCommandStringOption
} from "discord.js"; } from "discord.js";
import {Container} from "../../Container/Container"; import {Container} from "../../Container/Container";
import {GroupRepository} from "../../Repositories/GroupRepository"; import {GroupRepository} from "../../Repositories/GroupRepository";
@ -22,7 +20,7 @@ export class GroupSelection {
public static async handleAutocomplete(interaction: AutocompleteInteraction, onlyLeaders: boolean = false): Promise<void> { public static async handleAutocomplete(interaction: AutocompleteInteraction, onlyLeaders: boolean = false): Promise<void> {
const value = interaction.options.getFocused(); const value = interaction.options.getFocused();
const repo = Container.get<GroupRepository>(GroupRepository.name); const repo = Container.get<GroupRepository>(GroupRepository.name);
let groups = repo.findGroupsByMember(<GuildMember>interaction.member, onlyLeaders); const groups = repo.findGroupsByMember(<GuildMember>interaction.member, onlyLeaders);
await interaction.respond( await interaction.respond(
groups groups
.filter((group) => group.name.startsWith(value)) .filter((group) => group.name.startsWith(value))

View file

@ -1,5 +1,4 @@
import {ChatInputCommandInteraction, Interaction, SlashCommandBuilder} from "discord.js"; import {ChatInputCommandInteraction, Interaction, SlashCommandBuilder} from "discord.js";
import Commands from "./Commands";
export interface Command { export interface Command {
definition(): SlashCommandBuilder; definition(): SlashCommandBuilder;

View file

@ -1,12 +1,12 @@
import { import {
SlashCommandBuilder, SlashCommandBuilder,
ChatInputCommandInteraction, ChatInputCommandInteraction,
MessageFlags, MessageFlags,
InteractionReplyOptions, InteractionReplyOptions,
GuildMember, GuildMember,
EmbedBuilder, EmbedBuilder,
AutocompleteInteraction, AutocompleteInteraction,
roleMention, time, userMention roleMention, time, userMention, GuildMemberRoleManager
} from "discord.js"; } from "discord.js";
import {AutocompleteCommand, ChatInteractionCommand, Command} from "./Command"; import {AutocompleteCommand, ChatInteractionCommand, Command} from "./Command";
import {GroupModel} from "../../Models/GroupModel"; import {GroupModel} from "../../Models/GroupModel";
@ -19,265 +19,264 @@ import {GroupConfigurationRenderer} from "../../Groups/GroupConfigurationRendere
import {GroupConfigurationHandler} from "../../Groups/GroupConfigurationHandler"; import {GroupConfigurationHandler} from "../../Groups/GroupConfigurationHandler";
import {GroupConfigurationTransformers} from "../../Groups/GroupConfigurationTransformers"; import {GroupConfigurationTransformers} from "../../Groups/GroupConfigurationTransformers";
import {GroupConfigurationRepository} from "../../Repositories/GroupConfigurationRepository"; import {GroupConfigurationRepository} from "../../Repositories/GroupConfigurationRepository";
import {IconCache} from "../../Icons/IconCache";
import {PlaydateRepository} from "../../Repositories/PlaydateRepository"; import {PlaydateRepository} from "../../Repositories/PlaydateRepository";
import {Nullable} from "../../types/Nullable";
export class GroupCommand implements Command, ChatInteractionCommand, AutocompleteCommand { export class GroupCommand implements Command, ChatInteractionCommand, AutocompleteCommand {
private static GOODBYE_MESSAGES: string[] = [ private static GOODBYE_MESSAGES: string[] = [
'Sad to see you go.', 'Sad to see you go.',
'May your next adventure be fruitful.', 'May your next adventure be fruitful.',
'I hope, I served you well.', 'I hope, I served you well.',
'I wish you and your group good luck on your next adventures.', 'I wish you and your group good luck on your next adventures.',
] ]
private static INVALID_CHARACTER_SEQUENCES: string[] = [
"http://",
"https://"
]
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)
.setMaxLength(64)
)
.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.")
)
.addSubcommand(command => command
.setName('config')
.setDescription("Starts the config manager for the group.")
.addIntegerOption(GroupSelection.createOptionSetup())
)
.addSubcommand(command => command
.setName("remove")
.setDescription("Deletes a group you are the leader for.")
.addIntegerOption(GroupSelection.createOptionSetup())
)
.addSubcommand(command => command
.setName("transfer")
.setDescription("Transfers leadership of a group to a different person")
.addIntegerOption(GroupSelection.createOptionSetup)
.addUserOption(option => option
.setName("target")
.setDescription("The member, that is the new leader")
.setRequired(true)
)
);
}
async execute(interaction: ChatInputCommandInteraction): Promise<void> { private static INVALID_CHARACTER_SEQUENCES: string[] = [
switch (interaction.options.getSubcommand()) { "http://",
case "create": "https://"
this.create(interaction); ]
break;
case "list":
this.list(interaction);
break;
case "remove":
await this.remove(interaction);
break;
case "config":
await this.runConfigurator(interaction);
break;
case "transfer":
await this.transferLeadership(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", true);
if (role.id === interaction.guildId) {
throw new UserError("Creating a group for everyone is not permitted!");
}
if (!interaction.member?.roles.cache.has(role?.id) ?? true) {
throw new UserError(
"You are not part of the role, you try to create a group for.",
"Add yourself to the group or ask your admin to do so."
);
}
const validName = this.validateGroupName(name);
if (name !== true) {
throw new UserError(`Your group name contains one or more invalid character sequences: ${validName}`)
}
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 validateGroupName(name: string): true|string{
const lowercaseName = name.toLowerCase();
for (let invalidcharactersequence of GroupCommand.INVALID_CHARACTER_SEQUENCES) {
if (!lowercaseName.includes(invalidcharactersequence)) {
continue
}
return invalidcharactersequence
}
return true;
}
private list(interaction: ChatInputCommandInteraction) { definition(): SlashCommandBuilder {
const repo = Container.get<GroupRepository>(GroupRepository.name); // @ts-expect-error Slash command expects more than needed.
const groups = repo.findGroupsByMember(<GuildMember>interaction.member); return new SlashCommandBuilder()
.setName('groups')
const playdateRepo = Container.get<PlaydateRepository>(PlaydateRepository.name); .setDescription(`Manages groups`)
.addSubcommand(create =>
const iconCache = Container.get<IconCache>(IconCache.name); create.setName("create")
.setDescription("Creates a new group, with executing user being the leader")
const embed = new EmbedBuilder() .addStringOption((option) =>
.setTitle("Your groups on this server:") option.setName("name")
.setFields( .setDescription("Defines the name for the group.")
groups.map(group => { .setRequired(true)
const nextPlaydate = playdateRepo.getNextPlaydateForGroup(group); .setMaxLength(64)
)
const values = [ .addRoleOption((builder) =>
`Role: ${iconCache.getEmoji("people_group_solid")} ${roleMention(group.role.roleid)}`, builder.setName("role")
`Leader/GM: ${userMention(group.leader.memberid)}` .setDescription("Defines the role, where all the members are located in.")
]; .setRequired(true)
)
if (nextPlaydate) { )
values.push( .addSubcommand(listCommand =>
`Next Playdate: ${iconCache.getEmoji("calendar_days_solid")} ${time(nextPlaydate.from_time, "F")}` listCommand
) .setName("list")
} .setDescription("Displays the groups you are apart of.")
)
return { .addSubcommand(command => command
name: group.name, .setName('config')
value: values.join("\n") .setDescription("Starts the config manager for the group.")
} .addIntegerOption(GroupSelection.createOptionSetup())
}) )
) .addSubcommand(command => command
.setName("remove")
const reply: InteractionReplyOptions = { .setDescription("Deletes a group you are the leader for.")
embeds: [ .addIntegerOption(GroupSelection.createOptionSetup())
embed )
], .addSubcommand(command => command
allowedMentions: { roles: [] }, .setName("transfer")
flags: MessageFlags.Ephemeral .setDescription("Transfers leadership of a group to a different person")
} .addIntegerOption(GroupSelection.createOptionSetup)
.addUserOption(option => option
interaction.reply(reply); .setName("target")
} .setDescription("The member, that is the new leader")
.setRequired(true)
private async remove(interaction: ChatInputCommandInteraction) {
const group = GroupSelection.getGroup(interaction);
const repo = Container.get<GroupRepository>(GroupRepository.name);
if (group.leader.memberid != interaction.member?.user.id) {
throw new UserError("Can't remove group. You are not the leader.");
}
repo.deleteGroup(group);
const embed = new EmbedBuilder()
.setTitle("Group deleted.")
.setDescription(
`:x: Deleted \`${group.name}\`. ${ArrayUtils.chooseRandom(GroupCommand.GOODBYE_MESSAGES)}`
)
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, true);
return;
}
}
private async runConfigurator(interaction: ChatInputCommandInteraction) {
const group = GroupSelection.getGroup(interaction);
const configurationRenderer = new GroupConfigurationRenderer(
new GroupConfigurationHandler(
Container.get<GroupConfigurationRepository>(GroupConfigurationRepository.name),
group
),
new GroupConfigurationTransformers(),
) )
);
await configurationRenderer.setup(interaction); }
async execute(interaction: ChatInputCommandInteraction): Promise<void> {
switch (interaction.options.getSubcommand()) {
case "create":
this.create(interaction);
break;
case "list":
this.list(interaction);
break;
case "remove":
await this.remove(interaction);
break;
case "config":
await this.runConfigurator(interaction);
break;
case "transfer":
await this.transferLeadership(interaction);
break;
default:
throw new Error("Unsupported command");
} }
private async transferLeadership(interaction: ChatInputCommandInteraction) { return Promise.resolve();
const group = GroupSelection.getGroup(interaction); }
const repo = Container.get<GroupRepository>(GroupRepository.name); private create(interaction: ChatInputCommandInteraction): void {
if (group.leader.memberid != interaction.member?.user.id) { const name = interaction.options.getString("name") ?? '';
throw new UserError( const role = interaction.options.getRole("role", true);
"Can't transfer leadership. You are not the leader."
);
}
const newLeader = <GuildMember>interaction.options.getMember("target", true);
if (!newLeader.roles.cache.has(group.role.roleid)) {
throw new UserError(
"Can't transfer leadership: The target member is not part of your group.",
"Add the user to the role this group is part in or ask your server admin to do it."
);
}
group.leader.memberid = newLeader.id
repo.update(group);
if (role.id === interaction.guildId) {
throw new UserError("Creating a group for everyone is not permitted!");
}
const embed = new EmbedBuilder() if (!(<Nullable<GuildMemberRoleManager>>interaction.member?.roles)?.cache.has(role?.id)) {
.setTitle("Leadership transfered") throw new UserError(
.setDescription( "You are not part of the role, you try to create a group for.",
`Leadership was successfully transfered to ${userMention(newLeader.user.id)}` "Add yourself to the group or ask your admin to do so."
);
}
const validName = this.validateGroupName(name);
// @ts-expect-error Is correct, since the valid name can return either true or a string and the error should only be thrown if it's a string.
if (name !== true) {
throw new UserError(`Your group name contains one or more invalid character sequences: ${validName}`)
}
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 validateGroupName(name: string): true|string{
const lowercaseName = name.toLowerCase();
for (const invalidcharactersequence of GroupCommand.INVALID_CHARACTER_SEQUENCES) {
if (!lowercaseName.includes(invalidcharactersequence)) {
continue
}
return invalidcharactersequence
}
return true;
}
private list(interaction: ChatInputCommandInteraction) {
const repo = Container.get<GroupRepository>(GroupRepository.name);
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(
groups.map(group => {
const nextPlaydate = playdateRepo.getNextPlaydateForGroup(group);
const values = [
`Role: ${roleMention(group.role.roleid)}`,
`Leader/GM: ${userMention(group.leader.memberid)}`
];
if (nextPlaydate) {
values.push(
`Next Playdate: ${time(nextPlaydate.from_time, "F")}`
) )
}
await interaction.reply({ return {
embeds: [ name: group.name,
embed value: values.join("\n")
], }
flags: MessageFlags.Ephemeral,
}) })
)
const reply: InteractionReplyOptions = {
embeds: [
embed
],
allowedMentions: { roles: [] },
flags: MessageFlags.Ephemeral
} }
interaction.reply(reply);
}
private async remove(interaction: ChatInputCommandInteraction) {
const group = GroupSelection.getGroup(interaction);
const repo = Container.get<GroupRepository>(GroupRepository.name);
if (group.leader.memberid != interaction.member?.user.id) {
throw new UserError("Can't remove group. You are not the leader.");
}
repo.deleteGroup(group);
const embed = new EmbedBuilder()
.setTitle("Group deleted.")
.setDescription(
`:x: Deleted \`${group.name}\`. ${ArrayUtils.chooseRandom(GroupCommand.GOODBYE_MESSAGES)}`
)
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, true);
return;
}
}
private async runConfigurator(interaction: ChatInputCommandInteraction) {
const group = GroupSelection.getGroup(interaction);
const configurationRenderer = new GroupConfigurationRenderer(
new GroupConfigurationHandler(
Container.get<GroupConfigurationRepository>(GroupConfigurationRepository.name),
group
),
new GroupConfigurationTransformers(),
)
await configurationRenderer.setup(interaction);
}
private async transferLeadership(interaction: ChatInputCommandInteraction) {
const group = GroupSelection.getGroup(interaction);
const repo = Container.get<GroupRepository>(GroupRepository.name);
if (group.leader.memberid != interaction.member?.user.id) {
throw new UserError(
"Can't transfer leadership. You are not the leader."
);
}
const newLeader = <GuildMember>interaction.options.getMember("target");
if (!newLeader.roles.cache.has(group.role.roleid)) {
throw new UserError(
"Can't transfer leadership: The target member is not part of your group.",
"Add the user to the role this group is part in or ask your server admin to do it."
);
}
group.leader.memberid = newLeader.id
repo.update(group);
const embed = new EmbedBuilder()
.setTitle("Leadership transferred")
.setDescription(
`Leadership was successfully transferred to ${userMention(newLeader.user.id)}`
)
await interaction.reply({
embeds: [
embed
],
flags: MessageFlags.Ephemeral,
})
}
} }

View file

@ -1,4 +1,4 @@
import {SlashCommandBuilder, Interaction, CommandInteraction} from "discord.js"; import {SlashCommandBuilder, CommandInteraction} from "discord.js";
import {Command} from "./Command"; import {Command} from "./Command";
export class HelloWorldCommand implements Command { export class HelloWorldCommand implements Command {
@ -6,7 +6,7 @@ export class HelloWorldCommand implements Command {
'Hello :)', 'Hello :)',
'zzzZ... ZzzzZ... huh? I am awake. I am awake!', 'zzzZ... ZzzzZ... huh? I am awake. I am awake!',
'Roll for initiative!', 'Roll for initiative!',
'I was an adventurerer like you...', 'I was an adventurer like you...',
'Hello :) How are you?', 'Hello :) How are you?',
] ]

View file

@ -1,22 +1,16 @@
import { import {
SlashCommandBuilder, SlashCommandBuilder,
Interaction,
CommandInteraction, CommandInteraction,
AutocompleteInteraction, AutocompleteInteraction,
GuildMember, EmbedBuilder, MessageFlags, ChatInputCommandInteraction, time
EmbedBuilder, MessageFlags, ChatInputCommandInteraction, ModalSubmitFields, time, User
} from "discord.js"; } from "discord.js";
import {AutocompleteCommand, ChatInteractionCommand, Command} from "./Command"; import {AutocompleteCommand, ChatInteractionCommand, Command} from "./Command";
import {Container} from "../../Container/Container"; import {Container} from "../../Container/Container";
import {GroupRepository} from "../../Repositories/GroupRepository";
import {GroupSelection} from "../CommandPartials/GroupSelection"; import {GroupSelection} from "../CommandPartials/GroupSelection";
import {setFlagsFromString} from "node:v8";
import {UserError} from "../UserError"; import {UserError} from "../UserError";
import Playdate from "../../Database/tables/Playdate";
import {PlaydateModel} from "../../Models/PlaydateModel"; import {PlaydateModel} from "../../Models/PlaydateModel";
import {PlaydateRepository} from "../../Repositories/PlaydateRepository"; import {PlaydateRepository} from "../../Repositories/PlaydateRepository";
import {GroupModel} from "../../Models/GroupModel"; import {GroupModel} from "../../Models/GroupModel";
import playdate from "../../Database/tables/Playdate";
export class PlaydatesCommand implements Command, AutocompleteCommand, ChatInteractionCommand { export class PlaydatesCommand implements Command, AutocompleteCommand, ChatInteractionCommand {
static REGEX = [ static REGEX = [
@ -24,7 +18,7 @@ export class PlaydatesCommand implements Command, AutocompleteCommand, ChatInter
] ]
definition(): SlashCommandBuilder { definition(): SlashCommandBuilder {
// @ts-ignore // @ts-expect-error Command builder is improperly marked as incomplete.
return new SlashCommandBuilder() return new SlashCommandBuilder()
.setName("playdates") .setName("playdates")
.setDescription("Manage your playdates") .setDescription("Manage your playdates")
@ -106,7 +100,7 @@ export class PlaydatesCommand implements Command, AutocompleteCommand, ChatInter
to_time: new Date(toDate), to_time: new Date(toDate),
} }
const id = playdateRepo.create(playdate); playdateRepo.create(playdate);
const embed = new EmbedBuilder() const embed = new EmbedBuilder()
.setTitle("Created a play-date.") .setTitle("Created a play-date.")

View file

@ -2,142 +2,66 @@ import {
Client, Client,
GatewayIntentBits, GatewayIntentBits,
Events, Events,
Interaction, ActivityType, REST
ChatInputCommandInteraction,
MessageFlags,
Activity,
ActivityType, REST, inlineCode
} from "discord.js"; } from "discord.js";
import Commands from "./Commands/Commands"; import Commands from "./Commands/Commands";
import {Container} from "../Container/Container"; import {Container} from "../Container/Container";
import {Logger} from "log4js"; import {Logger} from "log4js";
import {UserError} from "./UserError"; import {InteractionRouter} from "./InteractionRouter";
export class DiscordClient { export class DiscordClient {
private readonly client: Client; private readonly client: Client;
private commands: Commands;
private readonly restClient: REST; public get Client(): Client {
public get Client (): Client {
return this.client; return this.client;
} }
public get Commands(): Commands { public get Commands(): Commands {
return this.commands return this.router.commands
} }
public get RESTClient(): REST { public get RESTClient(): REST {
return this.restClient; return this.restClient;
} }
public get ApplicationId(): string { public get ApplicationId(): string {
return this.applicationId; return this.applicationId;
} }
constructor( constructor(
private readonly applicationId: string private readonly applicationId: string,
private readonly router: InteractionRouter,
private readonly restClient: REST = new REST()
) { ) {
this.client = new Client({ this.client = new Client({
intents: [GatewayIntentBits.Guilds] intents: [GatewayIntentBits.Guilds]
}) })
this.commands = new Commands();
this.restClient = new REST();
} }
applyEvents() { applyEvents() {
this.client.once(Events.ClientReady, () => { this.client.once(Events.ClientReady, () => {
if (!this.client.user) {
return;
}
Container.get<Logger>("logger").info(`Ready! Logged in as ${this.client.user.tag}`); Container.get<Logger>("logger").info(`Ready! Logged in as ${this.client.user.tag}`);
this.client.user.setActivity('your PnP playdates', { this.client.user.setActivity('your PnP playdates', {
type: ActivityType.Watching, type: ActivityType.Watching,
}); });
}) })
this.client.on(Events.GuildAvailable, () => { this.client.on(Events.GuildAvailable, () => {
Container.get<Logger>("logger").info("Joined Guild?") Container.get<Logger>("logger").info("Joined Guild?")
}) })
this.client.on(Events.InteractionCreate, async (interaction: Interaction) => { this.client.on(Events.InteractionCreate, this.router.route.bind(this.router));
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) { connect(token: string) {
this.client.login(token); this.client.login(token);
} }
connectRESTClient(token: string) { connectRESTClient(token: string) {
this.restClient.setToken(token); this.restClient.setToken(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: any) {
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 (e.tryInstead) {
userMessage += `
You can try the following:
${inlineCode(e.tryInstead)}`
}
}
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,109 @@
import {
AutocompleteInteraction,
ChatInputCommandInteraction,
inlineCode,
Interaction,
MessageFlags,
} from "discord.js";
import Commands from "./Commands/Commands";
import {Logger} from "log4js";
import {UserError} from "./UserError";
import {Container} from "../Container/Container";
enum InteractionRoutingType {
Unrouted,
Command,
AutoComplete,
}
export class InteractionRouter {
constructor(
public readonly commands: Commands,
public readonly logger: Logger
) {
}
async route(interaction: Interaction) {
const interactionType = this.findInteractionType(interaction);
switch (interactionType) {
case InteractionRoutingType.Unrouted:
this.logger.debug("Unroutable interaction found...")
break;
case InteractionRoutingType.Command:
await this.handleCommand(<ChatInputCommandInteraction>interaction);
break;
case InteractionRoutingType.AutoComplete:
await this.handleAutocomplete(<AutocompleteInteraction>interaction)
break;
}
}
private findInteractionType(interaction: Interaction): InteractionRoutingType {
if (interaction.isChatInputCommand()) {
return InteractionRoutingType.Command;
}
if (interaction.isAutocomplete()) {
return InteractionRoutingType.AutoComplete;
}
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)
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 (e.tryInstead) {
userMessage += `
You can try the following:
${inlineCode(e.tryInstead)}`
}
}
if (interaction.replied || interaction.deferred) {
await interaction.followUp({ content: userMessage, flags: MessageFlags.Ephemeral });
} else {
await interaction.reply({ content: userMessage, flags: MessageFlags.Ephemeral });
}
}
}
private async handleAutocomplete(interaction: AutocompleteInteraction) {
const command = this.commands.getCommand(interaction.commandName);
if (!command) {
return null;
}
if (!('handleAutocomplete' in command)) {
return null;
}
Container.get<Logger>("logger").debug(`Found command ${interaction.commandName} for autocomplete: handling...`);
try {
await command.handleAutocomplete?.call(command, interaction);
} catch (e: unknown) {
Container.get<Logger>('logger').error(e);
}
}
}

View file

@ -4,7 +4,7 @@ export class ElementCreatedEvent<T extends Model = Model> {
constructor( constructor(
public readonly tableName: string, public readonly tableName: string,
public readonly instanceValues: Partial<T>, public readonly instanceValues: Partial<T>,
public readonly instanceId: number public readonly instanceId: number|bigint
) { ) {
} }
} }

View file

@ -1,6 +1,5 @@
import cron from "node-cron"; import cron from "node-cron";
import {Nullable} from "../types/Nullable"; import {Class} from "../types/Class";
import {Class, ClassNamed} from "../types/Class";
export type EventConfiguration = { export type EventConfiguration = {
name: string, name: string,

View file

@ -1,12 +1,10 @@
import {ElementCreatedEvent} from "../ElementCreatedEvent"; import {ElementCreatedEvent} from "../ElementCreatedEvent";
import {DefaultHandler} from "../DefaultEvents";
import {PlaydateModel} from "../../Models/PlaydateModel"; import {PlaydateModel} from "../../Models/PlaydateModel";
import PlaydateTableConfiguration from "../../Database/tables/Playdate"; import PlaydateTableConfiguration from "../../Database/tables/Playdate";
import {EmbedBuilder, roleMention, time} from "discord.js"; import {EmbedBuilder, roleMention, time} from "discord.js";
import {ArrayUtils} from "../../Utilities/ArrayUtils"; import {ArrayUtils} from "../../Utilities/ArrayUtils";
import {GroupConfigurationHandler} from "../../Groups/GroupConfigurationHandler"; import {GroupConfigurationHandler} from "../../Groups/GroupConfigurationHandler";
import {Container} from "../../Container/Container"; import {Container} from "../../Container/Container";
import {GroupConfigurationRenderer} from "../../Groups/GroupConfigurationRenderer";
import {GroupConfigurationRepository} from "../../Repositories/GroupConfigurationRepository"; import {GroupConfigurationRepository} from "../../Repositories/GroupConfigurationRepository";
import {DiscordClient} from "../../Discord/DiscordClient"; import {DiscordClient} from "../../Discord/DiscordClient";
@ -37,7 +35,7 @@ export async function sendCreatedNotificationEventHandler(event: ElementCreatedE
return; return;
} }
const channel = await Container.get<DiscordClient>(DiscordClient.name).Client.channels.fetch(targetChannel) const channel = await Container.get<DiscordClient>(DiscordClient.name).Client.channels.fetch(<string>targetChannel)
if (!channel) { if (!channel) {
return; return;
} }

View file

@ -1,13 +1,12 @@
import {CronExpression, Event, EventConfiguration, TimedEvent} from "./EventHandler"; import {EventConfiguration, TimedEvent} from "./EventHandler";
import {Container} from "../Container/Container"; import {Container} from "../Container/Container";
import Playdate from "../Database/tables/Playdate";
import {PlaydateRepository} from "../Repositories/PlaydateRepository"; import {PlaydateRepository} from "../Repositories/PlaydateRepository";
import {GroupConfigurationHandler} from "../Groups/GroupConfigurationHandler"; import {GroupConfigurationHandler} from "../Groups/GroupConfigurationHandler";
import {GroupConfigurationRepository} from "../Repositories/GroupConfigurationRepository"; import {GroupConfigurationRepository} from "../Repositories/GroupConfigurationRepository";
import {PlaydateModel} from "../Models/PlaydateModel"; import {PlaydateModel} from "../Models/PlaydateModel";
import {ChannelId} from "../types/DiscordTypes"; import {ChannelId} from "../types/DiscordTypes";
import {DiscordClient} from "../Discord/DiscordClient"; import {DiscordClient} from "../Discord/DiscordClient";
import {EmbedBuilder, MessageFlags, roleMention, time} from "discord.js"; import {EmbedBuilder, roleMention, time} from "discord.js";
import {ArrayUtils} from "../Utilities/ArrayUtils"; import {ArrayUtils} from "../Utilities/ArrayUtils";
export class ReminderEvent implements TimedEvent { export class ReminderEvent implements TimedEvent {
@ -27,9 +26,9 @@ export class ReminderEvent implements TimedEvent {
name: "Reminders", name: "Reminders",
} }
cronExpression: CronExpression = "0 9 * * *" cronExpression: string = "0 9 * * *"
private groupConfigurationRepository: GroupConfigurationRepository private readonly groupConfigurationRepository: GroupConfigurationRepository
private playdateRepository: PlaydateRepository private playdateRepository: PlaydateRepository
private discordClient: DiscordClient private discordClient: DiscordClient
@ -72,13 +71,13 @@ export class ReminderEvent implements TimedEvent {
return Promise.resolve(); return Promise.resolve();
} }
return this.sendReminder(playdate, targetChannel, config.locale); return this.sendReminder(playdate, targetChannel);
}, this) }, this)
await Promise.all(promises); await Promise.all(promises);
} }
private async sendReminder(playdate: PlaydateModel, targetChannel: ChannelId, locale: Intl.Locale) { private async sendReminder(playdate: PlaydateModel, targetChannel: ChannelId) {
if (!playdate.group) { if (!playdate.group) {
return; return;
} }

View file

@ -2,10 +2,11 @@ import {RuntimeGroupConfiguration} from "./RuntimeGroupConfiguration";
import {GroupConfigurationRepository} from "../Repositories/GroupConfigurationRepository"; import {GroupConfigurationRepository} from "../Repositories/GroupConfigurationRepository";
import {GroupModel} from "../Models/GroupModel"; import {GroupModel} from "../Models/GroupModel";
import {GroupConfigurationResult, GroupConfigurationTransformers} from "./GroupConfigurationTransformers"; import {GroupConfigurationResult, GroupConfigurationTransformers} from "./GroupConfigurationTransformers";
// @ts-ignore // @ts-expect-error set-path is provided
import setPath from 'object-path-set'; import setPath from 'object-path-set';
import deepmerge from "deepmerge"; import deepmerge from "deepmerge";
import {Nullable} from "../types/Nullable"; import {Nullable} from "../types/Nullable";
// @ts-expect-error Any is fine
import {isPlainObject} from "is-plain-object"; import {isPlainObject} from "is-plain-object";
export class GroupConfigurationHandler { export class GroupConfigurationHandler {

View file

@ -2,41 +2,26 @@ import {GroupConfigurationTransformers, TransformerType} from "./GroupConfigurat
import {GroupConfigurationHandler} from "./GroupConfigurationHandler"; import {GroupConfigurationHandler} from "./GroupConfigurationHandler";
import { import {
ActionRowBuilder, ActionRowBuilder,
AnyComponentBuilder, AnySelectMenuInteraction, AnySelectMenuInteraction,
APISelectMenuComponent,
ButtonBuilder, ButtonBuilder,
ButtonStyle, channelMention, ButtonStyle, channelMention,
ChannelSelectMenuBuilder, ChannelSelectMenuInteraction, ChannelSelectMenuBuilder, ChannelType,
ChannelType, ChatInputCommandInteraction, EmbedBuilder, inlineCode, Interaction,
ChatInputCommandInteraction, codeBlock,
EmbedBuilder, inlineCode,
InteractionCallbackResponse,
InteractionEditReplyOptions,
InteractionReplyOptions, InteractionReplyOptions,
InteractionUpdateOptions, italic, MessageFlags, InteractionUpdateOptions, italic, MessageFlags,
SelectMenuBuilder,
StringSelectMenuBuilder, StringSelectMenuBuilder,
StringSelectMenuOptionBuilder, TextBasedChannel, StringSelectMenuOptionBuilder, UserSelectMenuBuilder
UserSelectMenuBuilder
} from "discord.js"; } from "discord.js";
import {Logger} from "log4js"; import {Logger} from "log4js";
import {Container} from "../Container/Container"; import {Container} from "../Container/Container";
import {Nullable} from "../types/Nullable"; import {Nullable} from "../types/Nullable";
import GroupConfiguration from "../Database/tables/GroupConfiguration";
import { import {
BaseSelectMenuBuilder,
MentionableSelectMenuBuilder, MentionableSelectMenuBuilder,
MessageActionRowComponentBuilder, MessageActionRowComponentBuilder,
RoleSelectMenuBuilder RoleSelectMenuBuilder
} from "@discordjs/builders"; } from "@discordjs/builders";
import {unwatchFile} from "node:fs";
import {UserError} from "../Discord/UserError";
import {RuntimeGroupConfiguration} from "./RuntimeGroupConfiguration";
import {ChannelId} from "../types/DiscordTypes"; import {ChannelId} from "../types/DiscordTypes";
import {IconCache} from "../Icons/IconCache"; import {IconCache} from "../Icons/IconCache";
import {ifError} from "node:assert";
import {DiscordClient} from "../Discord/DiscordClient";
import {channel} from "node:diagnostics_channel";
type UIElementCollection = Record<string, UIElement>; type UIElementCollection = Record<string, UIElement>;
type UIElement = { type UIElement = {
@ -104,7 +89,7 @@ export class GroupConfigurationRenderer {
let response = await interaction.reply(this.getReplyOptions()); let response = await interaction.reply(this.getReplyOptions());
let exit = false; let exit = false;
let eventResponse; let eventResponse;
const filter = i => i.user.id === interaction.user.id; const filter = (i: Interaction) => i.user.id === interaction.user.id;
do { do {
if (eventResponse) { if (eventResponse) {
@ -117,7 +102,7 @@ export class GroupConfigurationRenderer {
filter: filter, filter: filter,
time: 60_000 time: 60_000
}); });
} catch (e) { } catch (_: unknown) {
break; break;
} }
@ -139,7 +124,7 @@ export class GroupConfigurationRenderer {
} }
if (eventResponse.customId.startsWith(GroupConfigurationRenderer.SETVALUE_COMMAND)) { if (eventResponse.customId.startsWith(GroupConfigurationRenderer.SETVALUE_COMMAND)) {
this.handleSelection(eventResponse); this.handleSelection(<AnySelectMenuInteraction>eventResponse);
continue; continue;
} }
@ -150,7 +135,7 @@ export class GroupConfigurationRenderer {
await eventResponse.update( await eventResponse.update(
this.getReplyOptions() this.getReplyOptions()
); );
} catch (e) { } catch (_) {
} }
await eventResponse.deleteReply(); await eventResponse.deleteReply();
@ -170,6 +155,7 @@ export class GroupConfigurationRenderer {
private getReplyOptions(): InteractionUpdateOptions & InteractionReplyOptions & { withResponse: true } { private getReplyOptions(): InteractionUpdateOptions & InteractionReplyOptions & { withResponse: true } {
const embed = this.createEmbed(); const embed = this.createEmbed();
const icons = Container.get<IconCache>(IconCache.name);
embed.setAuthor({ embed.setAuthor({
name: "/ " + this.breadcrumbs.join(" / ") name: "/ " + this.breadcrumbs.join(" / ")
}); });
@ -177,7 +163,8 @@ export class GroupConfigurationRenderer {
const exitButton = new ButtonBuilder() const exitButton = new ButtonBuilder()
.setLabel("Exit") .setLabel("Exit")
.setStyle(ButtonStyle.Danger) .setStyle(ButtonStyle.Danger)
.setCustomId("exit"); .setCustomId("exit")
.setEmoji(icons.get("door_open_solid_white") ?? '');
const actionrow = new ActionRowBuilder<ButtonBuilder>() const actionrow = new ActionRowBuilder<ButtonBuilder>()
@ -185,7 +172,8 @@ export class GroupConfigurationRenderer {
const backButton = new ButtonBuilder() const backButton = new ButtonBuilder()
.setLabel("Back") .setLabel("Back")
.setStyle(ButtonStyle.Secondary) .setStyle(ButtonStyle.Secondary)
.setCustomId(GroupConfigurationRenderer.MOVEBACK_COMMAND); .setCustomId(GroupConfigurationRenderer.MOVEBACK_COMMAND)
.setEmoji(icons.get("angle_left_solid") ?? '');
actionrow.addComponents(backButton) actionrow.addComponents(backButton)
} }
@ -201,7 +189,7 @@ export class GroupConfigurationRenderer {
} }
private createEmbed(): EmbedBuilder { private createEmbed(): EmbedBuilder {
const {currentCollection, currentElement} = this.findCurrentUI(); const {currentElement} = this.findCurrentUI();
if (currentElement === null) { if (currentElement === null) {
return new EmbedBuilder() return new EmbedBuilder()
@ -224,7 +212,7 @@ export class GroupConfigurationRenderer {
private getCurrentValueAsUI(): string { private getCurrentValueAsUI(): string {
const path = this.breadcrumbs.join("."); const path = this.breadcrumbs.join(".");
let value = this.configurationHandler.getConfigurationByPath(path); const value = this.configurationHandler.getConfigurationByPath(path);
if (value === undefined) return italic("None"); if (value === undefined) return italic("None");
@ -263,7 +251,7 @@ export class GroupConfigurationRenderer {
if (currentElement?.isConfiguration ?? false) { if (currentElement?.isConfiguration ?? false) {
return [ return [
new ActionRowBuilder<ChannelSelectMenuBuilder | MentionableSelectMenuBuilder | RoleSelectMenuBuilder | StringSelectMenuBuilder | UserSelectMenuBuilder>() new ActionRowBuilder<ChannelSelectMenuBuilder | MentionableSelectMenuBuilder | RoleSelectMenuBuilder | StringSelectMenuBuilder | UserSelectMenuBuilder>()
.addComponents(this.getSelectForBreadcrumbs(<UIElement>currentElement)) .addComponents(this.getSelectForBreadcrumbs())
] ]
} }
@ -274,13 +262,13 @@ export class GroupConfigurationRenderer {
.setLabel(` ${elem.label}`) .setLabel(` ${elem.label}`)
.setStyle(ButtonStyle.Primary) .setStyle(ButtonStyle.Primary)
.setCustomId(GroupConfigurationRenderer.MOVETO_COMMAND + elem.key) .setCustomId(GroupConfigurationRenderer.MOVETO_COMMAND + elem.key)
.setEmoji(icons.get("folder_tree_solid") ?? '') .setEmoji(icons.get(elem.isConfiguration ? 'pen_solid' : "folder_solid") ?? '')
) )
) )
] ]
} }
private getSelectForBreadcrumbs(currentElement: UIElement): ChannelSelectMenuBuilder | MentionableSelectMenuBuilder | RoleSelectMenuBuilder | StringSelectMenuBuilder | UserSelectMenuBuilder { private getSelectForBreadcrumbs(): ChannelSelectMenuBuilder | MentionableSelectMenuBuilder | RoleSelectMenuBuilder | StringSelectMenuBuilder | UserSelectMenuBuilder {
const breadcrumbPath = this.breadcrumbs.join('.') const breadcrumbPath = this.breadcrumbs.join('.')
const transformerType = this.transformers.getTransformerType(breadcrumbPath); const transformerType = this.transformers.getTransformerType(breadcrumbPath);
if (transformerType === undefined) { if (transformerType === undefined) {
@ -300,7 +288,7 @@ export class GroupConfigurationRenderer {
.setCustomId(GroupConfigurationRenderer.SETVALUE_COMMAND + breadcrumbPath) .setCustomId(GroupConfigurationRenderer.SETVALUE_COMMAND + breadcrumbPath)
.setOptions( .setOptions(
options.map(intl => new StringSelectMenuOptionBuilder() options.map(intl => new StringSelectMenuOptionBuilder()
.setLabel(displaynames.of(intl)) .setLabel(displaynames.of(intl) ?? '')
.setValue(intl) .setValue(intl)
) )
) )

View file

@ -1,7 +1,5 @@
import {ChannelId} from "../types/DiscordTypes"; import {ChannelId} from "../types/DiscordTypes";
import {GroupConfigurationModel} from "../Models/GroupConfigurationModel"; import {GroupConfigurationModel} from "../Models/GroupConfigurationModel";
import {config} from "dotenv";
import {transform} from "esbuild";
import {Nullable} from "../types/Nullable"; import {Nullable} from "../types/Nullable";
import {ArrayUtils} from "../Utilities/ArrayUtils"; import {ArrayUtils} from "../Utilities/ArrayUtils";

View file

@ -1,5 +1,6 @@
import {formatEmoji, Routes, Snowflake} from "discord.js"; import {formatEmoji, Routes, Snowflake} from "discord.js";
import {DiscordClient} from "../Discord/DiscordClient"; import {DiscordClient} from "../Discord/DiscordClient";
import {Nullable} from "../types/Nullable";
export class IconCache { export class IconCache {
private existingIcons: Map<string, string> | undefined; private existingIcons: Map<string, string> | undefined;
@ -22,7 +23,7 @@ export class IconCache {
const id = this.get(iconName); const id = this.get(iconName);
return formatEmoji({ return formatEmoji({
id: id, id,
name: iconName name: iconName
}); });
} }
@ -47,10 +48,14 @@ export class IconCache {
return; return;
} }
const existingEmojis: DiscordIconRequest = await this.client.RESTClient.get( const existingEmojis: Nullable<DiscordIconRequest> = await this.client.RESTClient.get(
Routes.applicationEmojis(this.client.ApplicationId) Routes.applicationEmojis(this.client.ApplicationId)
) )
if (!existingEmojis) {
return;
}
this.existingIcons = new Map<string, string>( this.existingIcons = new Map<string, string>(
existingEmojis.items.map((item) => { existingEmojis.items.map((item) => {
return [ item.name, item.id ] return [ item.name, item.id ]

View file

@ -1,4 +1,3 @@
import {REST, Routes} from "discord.js";
import path from "node:path"; import path from "node:path";
import * as fs from "node:fs"; import * as fs from "node:fs";
import svg2img from "svg2img"; import svg2img from "svg2img";
@ -14,7 +13,7 @@ export class IconDeployer {
public async ensureExistance() { public async ensureExistance() {
const directory = await fs.promises.opendir(IconDeployer.ICON_PATH); const directory = await fs.promises.opendir(IconDeployer.ICON_PATH);
const addIconPromises: Promise<void>[] = []; const addIconPromises: Promise<void>[] = [];
for await (let dirname of directory) { 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) { if (this.iconCache.get(iconName) !== null) {
@ -43,7 +42,7 @@ export class IconDeployer {
} }
} }
}, },
function (err, buffer) { function (_err, buffer) {
resolve(buffer); resolve(buffer);
} }
) )

View file

@ -2,7 +2,7 @@ import {Repository} from "./Repository";
import {GroupModel} from "../Models/GroupModel"; import {GroupModel} from "../Models/GroupModel";
import Groups, {DBGroup} from "../Database/tables/Groups"; import Groups, {DBGroup} from "../Database/tables/Groups";
import {DatabaseConnection} from "../Database/DatabaseConnection"; import {DatabaseConnection} from "../Database/DatabaseConnection";
import {CacheType, CacheTypeReducer, Guild, GuildMember, GuildMemberRoleManager} from "discord.js"; import {GuildMember} from "discord.js";
import {Nullable} from "../types/Nullable"; import {Nullable} from "../types/Nullable";
import {PlaydateRepository} from "./PlaydateRepository"; import {PlaydateRepository} from "./PlaydateRepository";
import {Container} from "../Container/Container"; import {Container} from "../Container/Container";
@ -33,7 +33,7 @@ export class GroupRepository extends Repository<GroupModel, DBGroup> {
} }
public findGroupsByRoles(server: string, roleIds: string[]): GroupModel[] { public findGroupsByRoles(server: string, roleIds: string[]): GroupModel[] {
const template = roleIds.map(roleId => '?').join(','); const template = roleIds.map(_roleId => '?').join(',');
const dbResult = this.database.fetchAll<number[], DBGroup>(` const dbResult = this.database.fetchAll<number[], DBGroup>(`
SELECT * FROM groups WHERE server = ? AND role IN (${template}) SELECT * FROM groups WHERE server = ? AND role IN (${template})
@ -64,7 +64,6 @@ export class GroupRepository extends Repository<GroupModel, DBGroup> {
public deleteGroup(group: GroupModel): void { public deleteGroup(group: GroupModel): void {
this.delete(group.id); this.delete(group.id);
debugger
const repo = Container.get<PlaydateRepository>(PlaydateRepository.name); const repo = Container.get<PlaydateRepository>(PlaydateRepository.name);
const playdates = repo.findFromGroup(group, true) const playdates = repo.findFromGroup(group, true)
playdates.forEach((playdate) => { playdates.forEach((playdate) => {

View file

@ -59,7 +59,7 @@ export class PlaydateRepository extends Repository<PlaydateModel, DBPlaydate> {
} }
getNextPlaydateForGroup(group: GroupModel): PlaydateModel | null { getNextPlaydateForGroup(group: GroupModel): PlaydateModel | null {
let sql = `SELECT * FROM ${this.schema.name} WHERE groupid = ? AND time_from > ? ORDER BY time_from ASC LIMIT 1`; 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>( const find = this.database.fetch<number, DBPlaydate>(
sql, sql,
@ -78,14 +78,12 @@ export class PlaydateRepository extends Repository<PlaydateModel, DBPlaydate> {
if (!intermediateModel) { if (!intermediateModel) {
throw new Error("Unable to convert the playdate model"); throw new Error("Unable to convert the playdate model");
} }
const result: PlaydateModel = { return {
id: intermediateModel.id, id: intermediateModel.id,
group: fixedGroup ?? this.groupRepository.getById(intermediateModel.groupid), group: fixedGroup ?? this.groupRepository.getById(intermediateModel.groupid),
from_time: new Date(intermediateModel.time_from), from_time: new Date(intermediateModel.time_from),
to_time: new Date(intermediateModel.time_to), to_time: new Date(intermediateModel.time_to),
} };
return result;
} }
protected convertToCreateObject(instance: Partial<PlaydateModel>): object { protected convertToCreateObject(instance: Partial<PlaydateModel>): object {

View file

@ -2,7 +2,6 @@ import {DatabaseConnection} from "../Database/DatabaseConnection";
import {Model} from "../Models/Model"; import {Model} from "../Models/Model";
import { Nullable } from "../types/Nullable"; import { Nullable } from "../types/Nullable";
import {DatabaseDefinition} from "../Database/DatabaseDefinition"; import {DatabaseDefinition} from "../Database/DatabaseDefinition";
import {debug} from "node:util";
import {Container} from "../Container/Container"; import {Container} from "../Container/Container";
import {EventHandler} from "../Events/EventHandler"; import {EventHandler} from "../Events/EventHandler";
import {ElementCreatedEvent} from "../Events/ElementCreatedEvent"; import {ElementCreatedEvent} from "../Events/ElementCreatedEvent";
@ -62,7 +61,7 @@ export class Repository<ModelType extends Model, IntermediateModelType = unknown
SET ${Object.keys(createObject).map((key) => `${key} = ?`).join(',')} SET ${Object.keys(createObject).map((key) => `${key} = ?`).join(',')}
WHERE id = ?`; WHERE id = ?`;
const result = this.database.execute(sql, ...Object.values(createObject), instance.id); const result = this.database.execute(sql, ...Object.values(createObject), instance.id);
return result.lastInsertRowid; return result.changes > 0;
} }
public getById(id: number): Nullable<ModelType> { public getById(id: number): Nullable<ModelType> {

View file

@ -9,7 +9,7 @@ export class ArrayUtils {
if (a == null || b == null) return false; if (a == null || b == null) return false;
if (a.length !== b.length) return false; if (a.length !== b.length) return false;
for (var i = 0; i < a.length; ++i) { for (let i = 0; i < a.length; ++i) {
if (a[i] !== b[i]) return false; if (a[i] !== b[i]) return false;
} }
return true; return true;

View file

@ -1,4 +1,3 @@
import Commands from "./Discord/Commands/Commands";
import {Environment} from "./Environment"; import {Environment} from "./Environment";
import {DatabaseConnection} from "./Database/DatabaseConnection"; import {DatabaseConnection} from "./Database/DatabaseConnection";
import {DatabaseUpdater} from "./Database/DatabaseUpdater"; import {DatabaseUpdater} from "./Database/DatabaseUpdater";

View file

@ -1,7 +1,6 @@
import {DiscordClient} from "./Discord/DiscordClient"; import {DiscordClient} from "./Discord/DiscordClient";
import {Environment} from "./Environment"; import {Environment} from "./Environment";
import {Container} from "./Container/Container"; import {Container} from "./Container/Container";
import {DatabaseConnection} from "./Database/DatabaseConnection";
import {ServiceHint, Services} from "./Container/Services"; import {ServiceHint, Services} from "./Container/Services";
import {IconCache} from "./Icons/IconCache"; import {IconCache} from "./Icons/IconCache";
import {DefaultEvents} from "./Events/DefaultEvents"; import {DefaultEvents} from "./Events/DefaultEvents";