Compare commits

...

8 Commits

7 changed files with 470 additions and 113 deletions

2
.gitignore vendored
View File

@ -1,7 +1,7 @@
### Node template
# Configurations
config.json
.vscode
# Logs
logs
*.log

View File

@ -1,13 +1,392 @@
import DiscordInteractionCommand from "../../util/DiscordInteractionCommand";
import { ChatInputCommandInteraction } from "discord.js";
import { MemberModel } from "../../database/Member";
import Partner, {
PartnerModel,
PartnerCommissionType,
PartnerRoleType,
PartnerDepartment,
PartnerTitle,
} from "../../database/Partner";
export default class Ping extends DiscordInteractionCommand {
import DiscordInteractionCommand from "../../util/DiscordInteractionCommand";
import {
ChatInputCommandInteraction,
InteractionContextType,
PermissionFlagsBits,
} from "discord.js";
//TODO: ad email validation
//TODO: cover all partner model properties in cooresponding sub commands
const partnerTitles: PartnerTitle[] = [
"Director of Engineering",
"Director of Operations",
"Deputy Director of Engineering",
"Deputy Director of Operations",
"Services Manager",
"Project Manager",
"Engineering Core Partner",
"Operations Core Partner",
"Community Moderator",
"Technician",
];
export default class PartnerCommand extends DiscordInteractionCommand {
constructor() {
super("partner", "Manipulates partner information.");
this.builder
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator)
.setContexts(InteractionContextType.Guild);
this.builder.addSubcommand((c) =>
c
.setName("add")
.setDescription("test")
.addUserOption((option) =>
option.setName("partner").setDescription("the partner you want to add.").setRequired(true)
)
.addStringOption((option) =>
option.setName("email").setDescription("their email address.").setRequired(true)
)
.addStringOption((option) =>
option
.setName("role-type")
.setDescription("their roleType.")
.setChoices(this.formatOptionsForDiscordFromEnum(PartnerRoleType))
.setRequired(true)
)
.addStringOption((option) =>
option
.setName("commission-type")
.setDescription("their commissionType.")
.setChoices(this.formatOptionsForDiscordFromEnum(PartnerCommissionType))
.setRequired(true)
)
.addStringOption((option) =>
option
.setName("title")
.setDescription("their title.")
.setChoices(this.formatPartnerTitlesArrayForDiscord())
.setRequired(true)
)
.addStringOption((option) =>
option
.setName("department")
.setDescription("their department.")
.setChoices(this.formatOptionsForDiscordFromEnum(PartnerDepartment))
.setRequired(true)
)
.addUserOption((option) =>
option.setName("direct-report").setDescription("their direct report.")
)
);
this.builder.addSubcommand((c) =>
c
.setName("get")
.setDescription("get")
.addUserOption((option) =>
option
.setName("partner")
.setDescription("the partner you want to get info abouts.")
.setRequired(true)
)
);
this.builder.addSubcommand((c) =>
c
.setName("delete")
.setDescription("delete")
.addUserOption((option) =>
option
.setName("partner")
.setDescription("the partner you want to delete.")
.setRequired(true)
)
);
this.builder.addSubcommand((c) =>
c
.setName("update")
.setDescription("update")
.addUserOption((option) =>
option
.setName("partner")
.setDescription("the partner you want to update.")
.setRequired(true)
)
.addUserOption((option) =>
option.setName("direct-report").setDescription("their direct report.")
)
.addStringOption((option) => option.setName("email").setDescription("their email address."))
.addStringOption((option) =>
option
.setName("role-type")
.setDescription("their roleType.")
.setChoices(this.formatOptionsForDiscordFromEnum(PartnerRoleType))
)
.addStringOption((option) =>
option
.setName("commission-type")
.setDescription("their commissionType.")
.setChoices(this.formatOptionsForDiscordFromEnum(PartnerCommissionType))
)
.addStringOption((option) =>
option
.setName("title")
.setDescription("their title.")
.setChoices(this.formatPartnerTitlesArrayForDiscord())
)
.addStringOption((option) =>
option
.setName("department")
.setDescription("their department.")
.setChoices(this.formatOptionsForDiscordFromEnum(PartnerDepartment))
)
);
}
public async execute(interaction: ChatInputCommandInteraction): Promise<void> {
if (interaction.options?.getSubcommand(true) === "add") {
const subcommandName = interaction.options.getSubcommand(true);
switch (subcommandName) {
case "get":
await this.handleGetSubcommand(interaction);
break;
case "add":
await this.handleAddSubcommand(interaction);
break;
case "delete":
await this.handleDeleteSubcommand(interaction);
break;
case "update":
await this.handleUpdateSubcommand(interaction);
break;
default:
//discord does not allow parent/main commands to be excutable, this range is limited to options entered via commandbuiler
break;
}
}
async handleAddSubcommand(interaction: ChatInputCommandInteraction) {
const partnerOption = interaction.options.getUser("partner", true);
const directReport = interaction.options.getUser("direct-report", false);
const partnerOptionEmailAddress = interaction.options.getString("email", true);
const partnerOptionRoleType = interaction.options.getString("role-type", true);
const partnerOptionCommisioComissionType = interaction.options.getString(
"commission-type",
true
);
const partnerOptionDepartment = interaction.options.getString("department", true);
const partnerOptionTitle = interaction.options.getString("title", true);
const partner = await PartnerModel.findOne({ discordID: partnerOption.id }).exec();
if (partner)
return interaction.reply({
content: "The specified user already has a partner entry.",
ephemeral: false,
});
/*
const member = await MemberModel.findOne({ discordID: partnerOption.id }).exec();
if (!member)
return interaction.reply({
content: "The specified partner does not have a base member entry.",
ephemeral: false,
});
*/
let directReportPartnerDocumentFromDb;
if (directReport) {
directReportPartnerDocumentFromDb = await PartnerModel.findOne({
discordID: directReport.id,
}).exec();
if (!directReportPartnerDocumentFromDb)
return interaction.reply({
content: `the specified directReport ${directReport.username} does not have an entry in partner database, please add them first them before assigning subordinates`,
ephemeral: false,
});
}
let newPartner = new PartnerModel({
discordID: partnerOption.id,
emailAddress: partnerOptionEmailAddress,
roleType: partnerOptionRoleType,
commissionType: partnerOptionCommisioComissionType,
department: partnerOptionDepartment,
title: partnerOptionTitle,
directReport:
directReport && directReportPartnerDocumentFromDb
? directReportPartnerDocumentFromDb._id
: null,
});
await newPartner.save();
return interaction.reply({
content: `\`\`\`\n${JSON.stringify(newPartner, null, 2)}\n\`\`\``,
ephemeral: false,
});
}
async handleGetSubcommand(interaction: ChatInputCommandInteraction) {
const partnerOption = interaction.options.getUser("partner", true);
let partner = await PartnerModel.findOne({ discordID: partnerOption.id }).exec();
if (!partner)
return interaction.reply({
content: "The specified partner does not an entry in the database.",
ephemeral: false,
});
if (partner.directReport) await partner.populate("directReport");
if (partner.directReport && partner.directReport instanceof Partner) {
console.log(partner.directReport);
return interaction.reply({
content: `Raw entry \`\`\`\n${JSON.stringify(partner, null, 2)}\n\`\`\`\n\nDirect report: \`\`\`\n${JSON.stringify(partner.directReport, null, 2)}\n\`\`\``,
ephemeral: false,
});
}
return interaction.reply({
content: `Raw entry \`\`\`\n${JSON.stringify(partner, null, 2)}\n\`\`\``,
ephemeral: false,
});
}
async handleDeleteSubcommand(interaction: ChatInputCommandInteraction) {
const partnerOption = interaction.options.getUser("partner", true);
const partner = await PartnerModel.findOne({ discordID: partnerOption.id })
.populate("directReport")
.exec();
if (!partner)
return interaction.reply({
content: "The specified user does not have an entry.",
ephemeral: false,
});
if (
partner.directReport &&
partner.directReport instanceof Partner &&
interaction.user.id !== partner.directReport.discordID
)
return interaction.reply({
content:
"You're not authorized to delete this partner's information, only their direct report can.",
ephemeral: false,
});
await PartnerModel.findByIdAndDelete(partner.id);
return interaction.reply({
content: `removed partner entry from the database.`,
ephemeral: false,
});
}
async handleUpdateSubcommand(interaction: ChatInputCommandInteraction) {
const partnerOption = interaction.options.getUser("partner", true);
const directReport = interaction.options.getUser("direct-report");
const partnerOptionEmailAddress = interaction.options.getString("email");
const partnerOptionRoleType = interaction.options.getString("role-type");
const partnerOptionCommisioComissionType = interaction.options.getString("commission-type");
const partnerOptionDepartment = interaction.options.getString("department");
const partnerOptionTitle = interaction.options.getString("title");
if (
!directReport &&
!partnerOptionEmailAddress &&
!partnerOptionEmailAddress &&
!partnerOptionRoleType &&
!partnerOptionCommisioComissionType &&
!partnerOptionDepartment &&
!partnerOptionTitle
) {
return interaction.reply({
content: "You need to select atleast one option to update",
ephemeral: false,
});
}
let partner = await PartnerModel.findOne({ discordID: partnerOption.id }).exec();
if (!partner)
return interaction.reply({
content: "The specified partner does not have an entry.",
ephemeral: false,
});
if (partner.directReport) partner = await partner.populate("directReport");
console.log(partner.directReport);
if (
partner.directReport instanceof PartnerModel &&
interaction.user.id !== partner.directReport.discordID
)
return interaction.reply({
content:
"You're not authorized to update this partner's information, only their direct report can.",
ephemeral: false,
});
let directReportPartnerDocumentFromDb;
if (directReport) {
directReportPartnerDocumentFromDb = await PartnerModel.findOne({
discordID: directReport.id,
}).exec();
if (!directReportPartnerDocumentFromDb)
return interaction.reply({
content: `the specified directReport ${directReport.username} does not have an entry in partner database, please add them first them before assigning subordinates`,
ephemeral: false,
});
}
let updateObj = {
discordID: partnerOption.id,
emailAddress: partnerOptionEmailAddress,
roleType: partnerOptionRoleType,
commissionType: partnerOptionCommisioComissionType,
department: partnerOptionDepartment,
title: partnerOptionTitle,
directReport:
directReport && directReportPartnerDocumentFromDb
? directReportPartnerDocumentFromDb.id
: null,
};
try {
let updatedPartner = await this.updatePartnerInfo(partner.id, updateObj);
return interaction.reply({
content: `updated partner!\n\n\`\`\`\n${JSON.stringify(updatedPartner, null, 2)}\n\`\`\``,
ephemeral: false,
});
} catch (error) {
return interaction.reply({
content: `an error occured !\n\n\`\`\`\n${JSON.stringify(error, null, 2)}\n\`\`\``,
ephemeral: false,
});
}
}
private formatPartnerTitlesArrayForDiscord(): { name: PartnerTitle; value: string }[] {
return partnerTitles.map((title) => ({
name: title,
value: title,
}));
}
private formatOptionsForDiscordFromEnum(args: any): { name: string; value: string }[] {
return Object.entries(args)
.filter(([key, value]) => typeof value === "number") // Filter out reverse mappings
.map(([key, value]) => ({
name: key,
value: (value as number).toString(),
}));
}
private async updatePartnerInfo<T>(id: string, updateObj: Partial<T>) {
// Remove keys with falsy values (undefined, null, etc.)
const filteredUpdate = Object.fromEntries(
Object.entries(updateObj).filter(([key, value]) => key !== "discordID" && value)
);
if (Object.keys(filteredUpdate).length === 0) {
throw new Error(
"Error in Partner update command, no options specified on update, you can safely ignore this error."
);
}
// Find and update the document by ID with only the valid fields
return await PartnerModel.findByIdAndUpdate(id, filteredUpdate, { new: true });
}
}

View File

@ -1,34 +0,0 @@
import DiscordInteractionCommand, {
DiscordInteractionCommandSkeleton,
} from "../../util/DiscordInteractionCommand";
import { guildID } from "../../config.json";
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import { MemberModel } from "../../database/Member";
import { PartnerModel } from "../../database/Partner";
export default class PartnerAdd implements DiscordInteractionCommandSkeleton {
public GUILD_ID: string;
public name: string;
public description: string;
public builder: SlashCommandBuilder;
constructor() {
this.name = "partner";
this.description = "Creates a new partner entry.";
this.builder = new SlashCommandBuilder();
this.GUILD_ID = guildID;
}
public async execute(interaction: ChatInputCommandInteraction) {
const member = MemberModel.findOne({ discordID: interaction.user.id });
if (!member)
return interaction.reply({
content: "The specified partner does not have a base member entry.",
ephemeral: true,
});
if (!(await PartnerModel.findOne({ discordID: interaction.user.id })))
return interaction.reply({
content: "The specified partner already has a partner entry.",
ephemeral: true,
});
}
}

135
index.ts
View File

@ -1,77 +1,88 @@
import { Client, GatewayIntentBits, Partials, REST, Routes } from "discord.js";
import { discordBotToken, discordClientID } from "./config.json"
import Collection from "./util/Collection";
import DiscordInteractionCommand from "./util/DiscordInteractionCommand";
import DiscordEvent from "./util/DiscordEvent";
import * as DiscordInteractionCommandsIndex from "./discord/commands";
import * as DiscordEventsIndex from "./discord/events";
import mongoose from "mongoose";
import { Client, GatewayIntentBits, Partials, REST, Routes } from 'discord.js';
import { discordBotToken, discordClientID, MongoDbUrl } from './config.json';
import Collection from './util/Collection';
import DiscordInteractionCommand from './util/DiscordInteractionCommand';
import DiscordEvent from './util/DiscordEvent';
import * as DiscordInteractionCommandsIndex from './discord/commands';
import * as DiscordEventsIndex from './discord/events';
import mongoose from 'mongoose';
export const DiscordInteractionCommands: Collection<DiscordInteractionCommand> = new Collection();
export const DiscordInteractionCommands: Collection<DiscordInteractionCommand> =
new Collection();
export const DiscordEvents: Collection<DiscordEvent> = new Collection();
// Instantiates a new Discord client
const discordClient = new Client({
intents: [
GatewayIntentBits.DirectMessages,
GatewayIntentBits.GuildIntegrations,
GatewayIntentBits.GuildPresences,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildInvites,
GatewayIntentBits.GuildModeration,
],
partials: [ Partials.GuildMember, Partials.Message, Partials.User, Partials.Channel, ],
intents: [
GatewayIntentBits.DirectMessages,
GatewayIntentBits.GuildIntegrations,
GatewayIntentBits.GuildPresences,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildInvites,
GatewayIntentBits.GuildModeration,
],
partials: [
Partials.GuildMember,
Partials.Message,
Partials.User,
Partials.Channel,
],
});
const discordREST = new REST().setToken(discordBotToken);
// const stripeClient = new Stripe(stripeToken, { typescript: true });
export async function main() {
// Connect to the databases
try {
mongoose.connection.once("open", () => {
console.info("[Info - Database] Connected to MongoDB");
})
// TODO: Fetch the MongoDB URI from the config file
await mongoose.connect("mongodb://localhost:27017/crra-main", {});
} catch (error) {
console.error(`[Error - Database] Failed to connect to MongoDB: ${error}`);
process.exit(1);
}
// Load Discord interaction commands
for (const Command of Object.values(DiscordInteractionCommandsIndex)) {
const instance = new Command();
DiscordInteractionCommands.add(instance.name, instance);
console.info(`[Info - Discord] Loaded interaction command: ${instance.name}`);
}
// Load Discord events
for (const Event of Object.values(DiscordEventsIndex)) {
const instance = new Event(discordClient);
DiscordEvents.add(instance.name, instance);
discordClient.on(instance.name, instance.execute);
console.info(`[Info - Discord] Loaded event: ${instance.name}`);
}
await discordClient.login(discordBotToken);
// Connect to the databases
try {
//@ts-ignore
mongoose.connection.once('open', () => {
console.info('[Info - Database] Connected to MongoDB');
});
// TODO: Fetch the MongoDB URI from the config file
await mongoose.connect(MongoDbUrl, {});
} catch (error) {
console.error(`[Error - Database] Failed to connect to MongoDB: ${error}`);
process.exit(1);
}
// Load Discord interaction commands
for (const Command of Object.values(DiscordInteractionCommandsIndex)) {
const instance = new Command();
DiscordInteractionCommands.add(instance.name, instance);
console.info(
`[Info - Discord] Loaded interaction command: ${instance.name}`
);
}
// Load Discord events
for (const Event of Object.values(DiscordEventsIndex)) {
const instance = new Event(discordClient);
DiscordEvents.add(instance.name, instance);
discordClient.on(instance.name, instance.execute);
console.info(`[Info - Discord] Loaded event: ${instance.name}`);
}
await discordClient.login(discordBotToken);
try {
console.log(`Started refreshing ${DiscordInteractionCommands.size} application (/) commands.`);
const interactionCommandsData = [];
for (const command of DiscordInteractionCommands.values()) {
interactionCommandsData.push(command.builder.toJSON());
}
// The put method is used to fully refresh all commands in the guild with the current set
const data = await discordREST.put(
Routes.applicationCommands(discordClientID),
{ body: interactionCommandsData },
);
// @ts-ignore
console.log(`Successfully reloaded ${data?.length} application (/) commands.`);
} catch (error) {
// And of course, make sure you catch and log any errors!
console.error(error);
try {
console.log(
`Started refreshing ${DiscordInteractionCommands.size} application (/) commands.`
);
const interactionCommandsData = [];
for (const command of DiscordInteractionCommands.values()) {
interactionCommandsData.push(command.builder.toJSON());
}
// The put method is used to fully refresh all commands in the guild with the current set
const data = await discordREST.put(
Routes.applicationCommands(discordClientID),
{ body: interactionCommandsData }
);
console.log(
`Successfully reloaded ${interactionCommandsData?.length} application (/) commands.`
);
} catch (error) {
// And of course, make sure you catch and log any errors!
console.error(error);
}
}
main();

3
package-lock.json generated
View File

@ -1,9 +1,10 @@
{
"name": "CRRA",
"name": "crra",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"license": "AGPL-3.0-or-later",
"dependencies": {
"@typegoose/typegoose": "^12.2.0",
"discord.js": "^14.14.1",

View File

@ -11,11 +11,11 @@
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
"target": "es2016" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */
"experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
"emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
"experimentalDecorators": true /* Enable experimental support for legacy experimental decorators. */,
"emitDecoratorMetadata": true /* Emit design-type metadata for decorated declarations in source files. */,
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
@ -25,7 +25,7 @@
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
/* Modules */
"module": "commonjs", /* Specify what module code is generated. */
"module": "commonjs" /* Specify what module code is generated. */,
// "rootDir": "./", /* Specify the root folder within your source files. */
// "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
@ -39,7 +39,7 @@
// "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
// "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
// "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
"resolveJsonModule": true, /* Enable importing .json files. */
"resolveJsonModule": true /* Enable importing .json files. */,
// "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
@ -55,7 +55,7 @@
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
// "outDir": "./", /* Specify an output folder for all emitted files. */
"outDir": "./dist" /* Specify an output folder for all emitted files. */,
// "removeComments": true, /* Disable emitting comments. */
// "noEmit": true, /* Disable emitting files from a compilation. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
@ -77,12 +77,12 @@
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
// "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
"esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */,
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
"forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,
/* Type Checking */
"strict": true, /* Enable all strict type-checking options. */
"strict": true /* Enable all strict type-checking options. */,
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
@ -104,6 +104,6 @@
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
}
}