Adds group configuration

This commit is contained in:
Michel Fedde 2025-04-12 19:41:16 +02:00
parent 0d9cf6a370
commit 154002f6f3
16 changed files with 633 additions and 20 deletions

26
package-lock.json generated
View file

@ -10,13 +10,16 @@
"license": "ISC",
"dependencies": {
"@types/better-sqlite3": "^7.6.12",
"@types/deepmerge": "^2.1.0",
"@types/log4js": "^0.0.33",
"@types/node": "^22.13.9",
"better-sqlite3": "^11.8.1",
"deepmerge": "^4.3.1",
"discord.js": "^14.18.0",
"dotenv": "^16.4.7",
"esbuild": "^0.25.0",
"log4js": "^6.9.1"
"log4js": "^6.9.1",
"object-path-set": "^1.0.2"
}
},
"node_modules/@discordjs/builders": {
@ -607,6 +610,12 @@
"@types/node": "*"
}
},
"node_modules/@types/deepmerge": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/deepmerge/-/deepmerge-2.1.0.tgz",
"integrity": "sha512-/0Ct/q5g+SgaACZ+A0ylY3071nEBN7QDnTWiCtaB3fx24UpoAQXf25yNVloOYVUis7jytM1F1WC78+EOwXkQJQ==",
"license": "MIT"
},
"node_modules/@types/express": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.1.tgz",
@ -843,6 +852,15 @@
"node": ">=4.0.0"
}
},
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/detect-libc": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz",
@ -1130,6 +1148,12 @@
"node": ">=10"
}
},
"node_modules/object-path-set": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/object-path-set/-/object-path-set-1.0.2.tgz",
"integrity": "sha512-kvjSaWTxrT4h66JIf4zR4LPxLCEBny0WIP/JIbQ6nqdI8qwfDcNV9vafjWqWBWL+tNlpLB9XyWCkxydCf/wuMw==",
"license": "MIT"
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",

View file

@ -15,12 +15,15 @@
},
"dependencies": {
"@types/better-sqlite3": "^7.6.12",
"@types/deepmerge": "^2.1.0",
"@types/log4js": "^0.0.33",
"@types/node": "^22.13.9",
"better-sqlite3": "^11.8.1",
"deepmerge": "^4.3.1",
"discord.js": "^14.18.0",
"dotenv": "^16.4.7",
"esbuild": "^0.25.0",
"log4js": "^6.9.1"
"log4js": "^6.9.1",
"object-path-set": "^1.0.2"
}
}

View file

@ -5,12 +5,13 @@ export class Container {
public set<T extends {constructor: {name: string}}>(instance: T, name: string|null = null): void
{
this.instances.set(name ?? instance.constructor.name, instance);
const settingName = name ?? instance.constructor.name;
this.instances.set(settingName.toLowerCase(), instance);
}
public get<T>(name: string): T
{
return <T>this.instances.get(name);
return <T>this.instances.get(name.toLowerCase());
}
static getInstance(): Container {

View file

@ -6,6 +6,7 @@ import path from "node:path";
import {GroupRepository} from "../Repositories/GroupRepository";
import {PlaydateRepository} from "../Repositories/PlaydateRepository";
import {GuildEmojiRoleManager} from "discord.js";
import {GroupConfigurationRepository} from "../Repositories/GroupConfigurationRepository";
export enum ServiceHint {
App,
@ -56,5 +57,6 @@ export class Services {
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,10 +1,12 @@
import Groups from "./tables/Groups";
import {DatabaseDefinition} from "./DatabaseDefinition";
import Playdate from "./tables/Playdate";
import GroupConfiguration from "./tables/GroupConfiguration";
const definitions = new Set<DatabaseDefinition>([
Groups,
Playdate
Playdate,
GroupConfiguration
]);
export default definitions;

View file

@ -1,6 +1,6 @@
import {DatabaseDefinition} from "../DatabaseDefinition";
export type DBGroup = {
export type DBGroupConfiguration = {
id: number;
groupid: number;
key: string;
@ -8,7 +8,7 @@ export type DBGroup = {
}
const dbDefinition: DatabaseDefinition = {
name: "groups",
name: "groupConfiguration",
columns: [
{
name: "id",

View file

@ -13,6 +13,10 @@ import {Container} from "../../Container/Container";
import {GroupSelection} from "../CommandPartials/GroupSelection";
import {UserError} from "../UserError";
import {ArrayUtils} from "../../Utilities/ArrayUtils";
import {GroupConfigurationRenderer} from "../../Groups/GroupConfigurationRenderer";
import {GroupConfigurationHandler} from "../../Groups/GroupConfigurationHandler";
import {GroupConfigurationTransformers} from "../../Groups/GroupConfigurationTransformers";
import {GroupConfigurationRepository} from "../../Repositories/GroupConfigurationRepository";
export class GroupCommand implements Command, ChatInteractionCommand, AutocompleteCommand {
private static GOODBYE_MESSAGES: string[] = [
@ -57,7 +61,8 @@ export class GroupCommand implements Command, ChatInteractionCommand, Autocomple
.addIntegerOption(GroupSelection.createOptionSetup())
);
}
execute(interaction: ChatInputCommandInteraction): Promise<void> {
async execute(interaction: ChatInputCommandInteraction): Promise<void> {
switch (interaction.options.getSubcommand()) {
case "create":
this.create(interaction);
@ -66,10 +71,11 @@ export class GroupCommand implements Command, ChatInteractionCommand, Autocomple
this.list(interaction);
break;
case "remove":
this.remove(interaction);
await this.remove(interaction);
break;
case "config":
this.runConfigurator(interaction);
await this.runConfigurator(interaction);
break;
default:
throw new Error("Unsupported command");
}
@ -159,9 +165,17 @@ export class GroupCommand implements Command, ChatInteractionCommand, Autocomple
}
}
private runConfigurator(interaction: ChatInputCommandInteraction) {
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);
}
}

View file

@ -38,13 +38,6 @@ export class DiscordClient {
})
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}'`);
@ -77,7 +70,7 @@ export class DiscordClient {
try {
await command.execute(interaction)
}
catch (e: Error) {
catch (e: any) {
Container.get<Logger>("logger").error(e)
let userMessage = ":x: There was an error while executing this command!";

View file

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

View file

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

View file

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

View file

@ -0,0 +1,9 @@
export type RuntimeGroupConfiguration = {
channels: Nullable<ChannelRuntimeGroupConfiguration>,
locale: Intl.Locale,
};
export type ChannelRuntimeGroupConfiguration = {
newPlaydates: ChannelId,
playdateReminders: ChannelId
}

View file

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

View file

@ -33,6 +33,30 @@ export class Repository<ModelType extends Model, IntermediateModelType = unknown
return result.lastInsertRowid;
}
public update(instance: Partial<ModelType>&{id: number}): boolean {
const columnNames = this.schema.columns.filter((column) => {
return !column.primaryKey
}).map((column) => {
return column.name;
});
const createObject = this.convertToCreateObject(instance);
const keys = Object.keys(createObject);
const missingColumns = columnNames.filter((columnName) => {
return !keys.includes(columnName);
})
if (missingColumns.length > 0) {
throw new Error("Can't create instance, due to missing column values: " + missingColumns);
}
const sql = `UPDATE ${this.schema.name}
SET ${Object.keys(createObject).map((key) => `${key} = ?`).join(',')}
WHERE id = ?`;
const result = this.database.execute(sql, ...Object.values(createObject), instance.id);
return result.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));

View file

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

View file

@ -6,4 +6,6 @@ export type GuildMember = {
export type Role = {
server: string;
roleid: string;
};
};
export type ChannelId = string;