diff --git a/package.json b/package.json index 4dbbc216..c8b142b2 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,8 @@ "dependencies": { "@discordjs/opus": "^0.10.0", "@discordjs/voice": "^0.18.0", + "@mikro-orm/core": "^6.4.13", + "@mikro-orm/mongodb": "^6.4.13", "@napi-rs/canvas": "^0.1.68", "chalk": "^5.4.1", "cron": "^4.1.0", @@ -21,6 +23,7 @@ "i18next": "^24.2.2", "i18next-fs-backend": "^2.6.0", "md5": "^2.3.0", + "mongodb": "^6.16.0", "mongoose": "^8.12.1", "ms": "^2.1.3", "node-fetch": "^3.3.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7c3cf91b..0e017fb3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,12 @@ importers: '@discordjs/voice': specifier: ^0.18.0 version: 0.18.0(@discordjs/opus@0.10.0) + '@mikro-orm/core': + specifier: ^6.4.13 + version: 6.4.13 + '@mikro-orm/mongodb': + specifier: ^6.4.13 + version: 6.4.13(@mikro-orm/core@6.4.13) '@napi-rs/canvas': specifier: ^0.1.68 version: 0.1.69 @@ -35,6 +41,9 @@ importers: md5: specifier: ^2.3.0 version: 2.3.0 + mongodb: + specifier: ^6.16.0 + version: 6.16.0 mongoose: specifier: ^8.12.1 version: 8.14.0 @@ -366,6 +375,16 @@ packages: resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@mikro-orm/core@6.4.13': + resolution: {integrity: sha512-DG6QGr3J9bBy64DBBxoA6WwvnKr35QqOGsQ8OE2B8ccUaK4jrpMolCkKYbZpJmCHGG4qKaECRu46at6nYZzogA==} + engines: {node: '>= 18.12.0'} + + '@mikro-orm/mongodb@6.4.13': + resolution: {integrity: sha512-nBALSAcO7GaPf1v02D3BBBfW6tViC74GI+KSJqtvBZ4Xc1pSmyR5Bm7MSWOzs1ggTJBzii3ZS1SzYtPagExOmw==} + engines: {node: '>= 18.12.0'} + peerDependencies: + '@mikro-orm/core': ^6.0.0 + '@mongodb-js/saslprep@1.2.2': resolution: {integrity: sha512-EB0O3SCSNRUFk66iRCpI+cXzIjdswfCs7F6nOC3RAGJ7xr5YhaicvsRwJ9eyzYvYRlCSDUO/c7g4yNulxKC1WA==} @@ -725,6 +744,9 @@ packages: resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} engines: {node: '>= 12'} + dataloader@2.2.3: + resolution: {integrity: sha512-y2krtASINtPFS1rSDjacrFgn1dcUuoREVabwlOGOe4SdxenREqwjwjElAdwvbGM7kgZz9a3KVicWR7vcz8rnzA==} + debug@4.4.0: resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==} engines: {node: '>=6.0'} @@ -765,6 +787,10 @@ packages: resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} engines: {node: '>=6.0.0'} + dotenv@16.5.0: + resolution: {integrity: sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==} + engines: {node: '>=12'} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -821,6 +847,11 @@ packages: resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + esquery@1.6.0: resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} engines: {node: '>=0.10'} @@ -888,6 +919,10 @@ packages: resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} engines: {node: '>=12.20.0'} + fs-extra@11.3.0: + resolution: {integrity: sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==} + engines: {node: '>=14.14'} + fs-minipass@2.1.0: resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==} engines: {node: '>= 8'} @@ -936,6 +971,9 @@ packages: resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} engines: {node: '>=10'} + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} @@ -1031,6 +1069,9 @@ packages: json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + jsonfile@6.1.0: + resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} + kareem@2.6.3: resolution: {integrity: sha512-C3iHfuGUXK2u8/ipq9LfjFfXFxAZMQJJq7vLS45r3D9Y2xQ/m4S8zaR4zMLFWh9AsNPXmcFfUDhTEO8UIC/V6Q==} engines: {node: '>=12.0.0'} @@ -1087,6 +1128,10 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + mikro-orm@6.4.13: + resolution: {integrity: sha512-sQZezhVLjH/gHUjVqsn+k60xC4TsL7h+Q/0UPe+OF4LoywTZNKwid1mN3WPxNd4c5eI6Aavl11KIsu2t9OKqzQ==} + engines: {node: '>= 18.12.0'} + minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -1118,6 +1163,33 @@ packages: mongodb-connection-string-url@3.0.2: resolution: {integrity: sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==} + mongodb@6.15.0: + resolution: {integrity: sha512-ifBhQ0rRzHDzqp9jAQP6OwHSH7dbYIQjD3SbJs9YYk9AikKEettW/9s/tbSFDTpXcRbF+u1aLrhHxDFaYtZpFQ==} + engines: {node: '>=16.20.1'} + peerDependencies: + '@aws-sdk/credential-providers': ^3.188.0 + '@mongodb-js/zstd': ^1.1.0 || ^2.0.0 + gcp-metadata: ^5.2.0 + kerberos: ^2.0.1 + mongodb-client-encryption: '>=6.0.0 <7' + snappy: ^7.2.2 + socks: ^2.7.1 + peerDependenciesMeta: + '@aws-sdk/credential-providers': + optional: true + '@mongodb-js/zstd': + optional: true + gcp-metadata: + optional: true + kerberos: + optional: true + mongodb-client-encryption: + optional: true + snappy: + optional: true + socks: + optional: true + mongodb@6.16.0: resolution: {integrity: sha512-D1PNcdT0y4Grhou5Zi/qgipZOYeWrhLEpk33n3nm6LGtz61jvO88WlrWCK/bigMjpnOdAUKKQwsGIl0NtWMyYw==} engines: {node: '>=16.20.1'} @@ -1320,6 +1392,9 @@ packages: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} + reflect-metadata@0.2.2: + resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + regenerator-runtime@0.14.1: resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==} @@ -1474,6 +1549,10 @@ packages: resolution: {integrity: sha512-q/1rj5D0/zayJB2FraXdaWxbhWiNKDvu8naDT2dl1yTlvJp4BLtOcp2a5BvgGNQpYYJzau7tf1WgKv3b+7mqpQ==} engines: {node: '>=18.17'} + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -1796,6 +1875,30 @@ snapshots: dependencies: '@sinclair/typebox': 0.27.8 + '@mikro-orm/core@6.4.13': + dependencies: + dataloader: 2.2.3 + dotenv: 16.5.0 + esprima: 4.0.1 + fs-extra: 11.3.0 + globby: 11.1.0 + mikro-orm: 6.4.13 + reflect-metadata: 0.2.2 + + '@mikro-orm/mongodb@6.4.13(@mikro-orm/core@6.4.13)': + dependencies: + '@mikro-orm/core': 6.4.13 + bson: 6.10.3 + mongodb: 6.15.0 + transitivePeerDependencies: + - '@aws-sdk/credential-providers' + - '@mongodb-js/zstd' + - gcp-metadata + - kerberos + - mongodb-client-encryption + - snappy + - socks + '@mongodb-js/saslprep@1.2.2': dependencies: sparse-bitfield: 3.0.3 @@ -2155,6 +2258,8 @@ snapshots: data-uri-to-buffer@4.0.1: {} + dataloader@2.2.3: {} + debug@4.4.0: dependencies: ms: 2.1.3 @@ -2198,6 +2303,8 @@ snapshots: dependencies: esutils: 2.0.3 + dotenv@16.5.0: {} + emoji-regex@8.0.0: {} esbuild@0.25.3: @@ -2341,6 +2448,8 @@ snapshots: acorn-jsx: 5.3.2(acorn@8.14.1) eslint-visitor-keys: 3.4.3 + esprima@4.0.1: {} + esquery@1.6.0: dependencies: estraverse: 5.3.0 @@ -2410,6 +2519,12 @@ snapshots: dependencies: fetch-blob: 3.2.0 + fs-extra@11.3.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.1.0 + universalify: 2.0.1 + fs-minipass@2.1.0: dependencies: minipass: 3.3.6 @@ -2469,6 +2584,22 @@ snapshots: merge2: 1.4.1 slash: 3.0.0 + got@13.0.0: + dependencies: + '@sindresorhus/is': 5.6.0 + '@szmarczak/http-timer': 5.0.1 + cacheable-lookup: 7.0.0 + cacheable-request: 10.2.14 + decompress-response: 6.0.0 + form-data-encoder: 2.1.4 + get-stream: 6.0.1 + http2-wrapper: 2.2.1 + lowercase-keys: 3.0.0 + p-cancelable: 3.0.0 + responselike: 3.0.0 + + graceful-fs@4.2.11: {} + graphemer@1.4.0: {} has-ansi@2.0.0: @@ -2542,6 +2673,12 @@ snapshots: json-stable-stringify-without-jsonify@1.0.1: {} + jsonfile@6.1.0: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + kareem@2.6.3: {} keyv@4.5.4: @@ -2593,6 +2730,8 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 + mikro-orm@6.4.13: {} + minimatch@3.1.2: dependencies: brace-expansion: 1.1.11 @@ -2623,6 +2762,12 @@ snapshots: '@types/whatwg-url': 11.0.5 whatwg-url: 14.2.0 + mongodb@6.15.0: + dependencies: + '@mongodb-js/saslprep': 1.2.2 + bson: 6.10.3 + mongodb-connection-string-url: 3.0.2 + mongodb@6.16.0: dependencies: '@mongodb-js/saslprep': 1.2.2 @@ -2783,6 +2928,8 @@ snapshots: dependencies: picomatch: 2.3.1 + reflect-metadata@0.2.2: {} + regenerator-runtime@0.14.1: {} require-relative@0.8.7: {} @@ -2913,6 +3060,8 @@ snapshots: undici@6.21.1: {} + universalify@2.0.1: {} + uri-js@4.4.1: dependencies: punycode: 2.3.1 diff --git a/src/adapters/database/MikroOrmAdapter.ts b/src/adapters/database/MikroOrmAdapter.ts new file mode 100644 index 00000000..6284bcb2 --- /dev/null +++ b/src/adapters/database/MikroOrmAdapter.ts @@ -0,0 +1,103 @@ +import { + EntityName, + EntityManager, + MikroORM, + RequiredEntityData, + FilterQuery, +} from "@mikro-orm/mongodb"; +import IDatabaseAdapter from "./IDatabaseAdapter.js"; +import Cache from "../cache/MapCache.js"; +import { MongoDriver } from "@mikro-orm/mongodb"; +import mirkoOrmConfig from "./mirko-orm.config.js"; +import logger from "@/helpers/logger.js"; +import { BaseEntity } from "@/structures/BaseEntity.js"; + +export default class MikroOrmAdapter extends IDatabaseAdapter< + EntityName, + Record, + FilterQuery, + unknown, + any +> { + cache = new Cache(); + em!: EntityManager; + orm!: MikroORM; + + uri!: string; + + constructor(uri: string) { + super(); + this.uri = uri; + } + + async connect() { + this.orm = await MikroORM.init({ + ...mirkoOrmConfig, + clientUrl: this.uri, + }); + this.em = this.orm.em.fork(); + this.em.clear(); + + BaseEntity.useEntityManager(this.em); + + logger.log("Database connected."); + } + + async disconnect() { + await this.orm.close(true); + logger.warn("Database disconnected."); + this.cache.clear(); + } + + #generateCacheKey(modelName: string, query: {}, options: {}) { + return `${modelName}:${JSON.stringify(query)}:${JSON.stringify(options)}`; + } + + async find(model: EntityName, query: FilterQuery = {}) { + const cacheKey = this.#generateCacheKey(model.toString(), query, {}); + if (this.cache.get(cacheKey)) { + return this.cache.get(cacheKey) as T[]; + } + + const result = await this.em.find(model, query); + this.cache.set(cacheKey, result); + return result; + } + + async findOne(model: EntityName, query: FilterQuery = {}) { + const cacheKey = this.#generateCacheKey(model.toString(), query, {}); + if (this.cache.get(cacheKey)) { + return this.cache.get(cacheKey) as T[]; + } + + const result = await this.em.findOne(model, query); + this.cache.set(cacheKey, result); + + return result; + } + + async updateOne(model: EntityName, filter: FilterQuery, update: any) { + const result = await this.em.nativeUpdate(model, filter, update); + this.cache.clear(); + return result; + } + + async deleteOne(model: EntityName, filter: FilterQuery) { + const result = await this.em.nativeDelete(model, filter); + this.cache.clear(); + return result; + } + + async findOneOrCreate(model: EntityName, filter: FilterQuery) { + this.cache.clear(); + const result = await this.em.findOne(model, filter).then(async entity => { + if (entity) { + return entity; + } + const res = this.em.create(model, filter as unknown as RequiredEntityData); + await this.em.persistAndFlush(res); + return res; + }); + return result; + } +} diff --git a/src/adapters/database/mirko-orm.config.ts b/src/adapters/database/mirko-orm.config.ts new file mode 100644 index 00000000..3bc23559 --- /dev/null +++ b/src/adapters/database/mirko-orm.config.ts @@ -0,0 +1,10 @@ +import { defineConfig, MongoDriver } from "@mikro-orm/mongodb"; + +export default defineConfig({ + entities: ["dist/models/**/*.js"], + entitiesTs: ["src/models/**/*.ts"], + debug: process.env.NODE_ENV !== "production", + forceEntityConstructor: true, + dbName: "discordbot", + driver: MongoDriver, +}); diff --git a/src/commands/Administration/config.ts b/src/commands/Administration/config.ts index e979ffd0..92949144 100644 --- a/src/commands/Administration/config.ts +++ b/src/commands/Administration/config.ts @@ -1,5 +1,5 @@ import { getLocalizedDesc, translateContext } from "@/helpers/functions.js"; -import GuildModel from "@/models/GuildModel.js"; +import { Guild } from "@/models/GuildModel.js"; import { CommandData, SlashCommandProps } from "@/types.js"; import { generateFields } from "@/utils/config-fields.js"; import { createEmbed } from "@/utils/create-embed.js"; @@ -155,7 +155,7 @@ export const run = async ({ interaction }: SlashCommandProps) => { return interaction.editReply({ embeds: [embed] }); } - const parameter = interaction.options.getString("parameter", true); + const parameter = interaction.options.getString("parameter", true) as keyof Guild["plugins"]; const state = interaction.options.getBoolean("state", true); const channel = interaction.options.getChannel("channel") as Channel; @@ -163,19 +163,18 @@ export const run = async ({ interaction }: SlashCommandProps) => { }; async function saveSettings( - guildData: InstanceType, - parameter: string, + guildData: InstanceType, + parameter: keyof Guild["plugins"], value: unknown, ) { - guildData.plugins[parameter] = value; - guildData.markModified(`plugins.${parameter}`); + guildData.plugins[parameter] = value as any; await guildData.save(); } async function generateReply( interaction: ChatInputCommandInteraction, - guildData: InstanceType, + guildData: InstanceType, parameter: string, state: boolean, channel?: Channel | null, @@ -190,14 +189,16 @@ async function generateReply( if (channel) return `${translatedParam}: **${enabledText}** (${channel.toString()})`; return `${translatedParam}: ${ - state ? `**${enabledText}** (<#${guildData.plugins[parameter]}>)` : `**${disabledText}**` + state + ? `**${enabledText}** (<#${guildData.plugins[parameter as keyof Guild["plugins"]]}>)` + : `**${disabledText}**` }`; } async function changeSetting( interaction: ChatInputCommandInteraction, guildData: any, - parameter: string, + parameter: keyof Guild["plugins"], state: boolean, channel?: Channel | null, ) { diff --git a/src/helpers/tasks/birthdays.ts b/src/helpers/tasks/birthdays.ts index 018bfd0b..2032e033 100644 --- a/src/helpers/tasks/birthdays.ts +++ b/src/helpers/tasks/birthdays.ts @@ -1,5 +1,5 @@ import useClient from "@/utils/use-client.js"; -import UserModel from "@/models/UserModel.js"; +import { User } from "@/models/UserModel.js"; import { createEmbed } from "@/utils/create-embed.js"; import logger from "../logger.js"; import { getNoun } from "../functions.js"; @@ -9,7 +9,7 @@ export const data = { task: async () => { const client = useClient(); const guilds = client.guilds.cache.values(); - const users = await client.adapter.find(UserModel, { + const users = await client.adapter.find(User, { birthdate: { $ne: null }, }); diff --git a/src/helpers/tasks/checkReminds.ts b/src/helpers/tasks/checkReminds.ts index c801aadc..153a3ebd 100644 --- a/src/helpers/tasks/checkReminds.ts +++ b/src/helpers/tasks/checkReminds.ts @@ -1,5 +1,5 @@ import { createEmbed } from "@/utils/create-embed.js"; -import UserModel from "../../models/UserModel.js"; +import { User } from "../../models/UserModel.js"; import useClient from "../../utils/use-client.js"; import { CronTaskData } from "@/types.js"; @@ -8,7 +8,11 @@ export const data: CronTaskData = { task: async () => { const client = useClient(); - const users = await client.adapter.find(UserModel, { reminds: { $gt: [] } }); + const users = await client.adapter.find(User, { + reminds: { + $gt: [] as any, + }, + }); for (const user of users) { if (!client.users.cache.has(user.id)) { @@ -58,7 +62,7 @@ export const data: CronTaskData = { }); await client.adapter.updateOne( - UserModel, + User, { id }, { $pull: { diff --git a/src/models/GuildModel.ts b/src/models/GuildModel.ts index be4a3b1f..caeec93a 100644 --- a/src/models/GuildModel.ts +++ b/src/models/GuildModel.ts @@ -1,51 +1,111 @@ -import { model, Schema, Types } from "mongoose"; +import { Entity, EntityManager, OneToMany, Property } from "@mikro-orm/core"; +import { Member } from "./MemberModel.js"; import useClient from "@/utils/use-client.js"; +import { BaseEntity } from "../structures/BaseEntity.js"; const client = useClient(); -export interface IGuildSchema { - id: string; - membersData: Record; - members: Types.ObjectId[]; - language: string; - plugins: any; -} +export type Plugins = { + welcome: { + enabled: boolean; + message: string | null; + channel: string | null; + withImage: string | null; + }; + goodbye: { + enabled: boolean; + message: string | null; + channel: string | null; + withImage: string | null; + }; + autorole: { + enabled: boolean; + role: string | null; + }; + automod: { + enabled: boolean; + ignored: string[]; + }; + warnsSanctions: { + kick: string | null; + ban: string | null; + }; + monitoring: { + messageUpdate: string | null; + messageDelete: string | null; + }; + tickets: { + count: number; + ticketLogs: string | null; + transcriptionLogs: string | null; + ticketsCategory: string | null; + }; + suggestions: string | null; + reports: string | null; + birthdays: string | null; + modlogs: string | null; +}; + +@Entity({ collection: "guilds" }) +export class Guild extends BaseEntity { + @Property({ type: "ObjectId", primary: true }) + _id!: string; + + @Property({ type: "string", unique: true, index: true }) + id!: string; + + @Property({ type: "json" }) + membersData: Record = {}; -const GuildSchema = new Schema({ - id: { type: String, required: true }, - membersData: { type: Object, default: {} }, - members: [{ type: Schema.Types.ObjectId, ref: "Member" }], - language: { - type: String, - default: client.configService.get("defaultLang"), - }, - plugins: { - type: Object, - default: { - welcome: { - enabled: false, - message: null, - channel: null, - withImage: null, - }, - goodbye: { - enabled: false, - message: null, - channel: null, - withImage: null, - }, - tickets: { - count: 0, - ticketLogs: null, - transcriptionLogs: null, - ticketsCategory: null, - }, - suggestions: null, - reports: null, - birthdays: null, - modlogs: null, + @OneToMany(() => Member, (member: Member) => member.guildID) + members = new Array(); + + @Property({ type: "string" }) + language: string = client.configService.get("defaultLang"); + + @Property({ type: "json" }) + plugins: Plugins = { + welcome: { + enabled: false, + message: null, + channel: null, + withImage: null, + }, + goodbye: { + enabled: false, + message: null, + channel: null, + withImage: null, }, - }, -}); + autorole: { + enabled: false, + role: null, + }, + automod: { + enabled: false, + ignored: [], + }, + warnsSanctions: { + kick: null, + ban: null, + }, + monitoring: { + messageUpdate: null, + messageDelete: null, + }, + tickets: { + count: 0, + ticketLogs: null, + transcriptionLogs: null, + ticketsCategory: null, + }, + suggestions: null, + reports: null, + birthdays: null, + modlogs: null, + }; -export default model("Guild", GuildSchema); + async hasMember(memberId: string, em: EntityManager): Promise { + return (await em.count(Member, { id: memberId, guildID: this.id })) > 0; + } +} diff --git a/src/models/MemberModel.ts b/src/models/MemberModel.ts index 548b0b4f..a5fa672e 100644 --- a/src/models/MemberModel.ts +++ b/src/models/MemberModel.ts @@ -1,5 +1,9 @@ -import { Schema, model } from "mongoose"; +import type { ObjectId } from "mongodb"; +import { Entity, ManyToOne, Property } from "@mikro-orm/core"; +import { Guild } from "./GuildModel.js"; +import { BaseEntity } from "../structures/BaseEntity.js"; +// Вложенные интерфейсы для типизации interface Transaction { user: string; amount: number; @@ -7,54 +11,55 @@ interface Transaction { type: string; } -export interface IMemberSchema { - id: string; - guildID: string; - money: number; - workStreak: number; - bankSold: number; - exp: number; - level: number; - transactions: Array; - registeredAt: number; - cooldowns: { - work: number; - rob: number; - }; - sanctions: any[][]; - mute: { - muted: boolean; - case: string | null; - endDate: number | null; - }; +interface Cooldowns { + work: number; + rob: number; } -const memberSchema = new Schema({ - id: { type: String, required: true }, - guildID: { type: String, required: true, ref: "Guild" }, - money: { type: Number, default: 0 }, - workStreak: { type: Number, default: 0 }, - bankSold: { type: Number, default: 0 }, - exp: { type: Number, default: 0 }, - level: { type: Number, default: 0 }, - transactions: [], - registeredAt: { type: Number, default: () => Date.now() }, - cooldowns: { - type: Object, - default: { - work: 0, - rob: 0, - }, - }, - sanctions: [], - mute: { - type: Object, - default: { - muted: false, - case: null, - endDate: null, - }, - }, -}); - -export default model("Member", memberSchema); +interface Mute { + muted: boolean; + case: string | null; + endDate: number | null; +} + +@Entity({ collection: "members" }) +export class Member extends BaseEntity { + @Property({ type: "ObjectId", primary: true }) + _id!: ObjectId; + + @Property({ type: "string", unique: true }) + id!: string; + + @ManyToOne(() => Guild, { index: true }) + guildID!: string; + + @Property({ type: "int" }) + money = 0; + + @Property({ type: "int" }) + workStreak = 0; + + @Property({ type: "int" }) + bankSold = 0; + + @Property({ type: "int" }) + exp = 0; + + @Property({ type: "int" }) + level = 0; + + @Property({ type: "array" }) + transactions: Transaction[] = []; + + @Property({ type: "int", onCreate: () => Date.now() }) + registeredAt!: number; + + @Property({ type: "json" }) + cooldowns: Cooldowns = { work: 0, rob: 0 }; + + @Property({ type: "array" }) + sanctions: any[][] = []; + + @Property({ type: "json" }) // Объект + mute: Mute = { muted: false, case: null, endDate: null }; +} diff --git a/src/models/UserModel.ts b/src/models/UserModel.ts index cb4cee37..a4c0e114 100644 --- a/src/models/UserModel.ts +++ b/src/models/UserModel.ts @@ -1,6 +1,8 @@ /* eslint-disable max-len */ -import { Schema, model } from "mongoose"; +import { Entity, Property } from "@mikro-orm/mongodb"; import { createCanvas, loadImage } from "@napi-rs/canvas"; +import { ObjectId } from "mongodb"; +import { BaseEntity } from "../structures/BaseEntity.js"; export type UserReminds = { message: string; @@ -8,30 +10,61 @@ export type UserReminds = { sendAt: number; }; -interface IUserSchema { - id: string; - rep: number; - bio: string; - birthdate: number | null; - lover: string; - registeredAt: number; - achievements: { - [key: string]: { - achieved: boolean; - progress: { - now: number; - total: number; - }; +type UserAchievements = { + married: { + achieved: boolean; + progress: { + now: number; + total: number; }; }; - cooldowns: { - rep: number; + work: { + achieved: boolean; + progress: { + now: number; + total: number; + }; }; - afk: string; - reminds: UserReminds[]; - logged: boolean; - apiToken: string; -} + firstCommand: { + achieved: boolean; + progress: { + now: number; + total: number; + }; + }; + slots: { + achieved: boolean; + progress: { + now: number; + total: number; + }; + }; + tip: { + achieved: boolean; + progress: { + now: number; + total: number; + }; + }; + rep: { + achieved: boolean; + progress: { + now: number; + total: number; + }; + }; + invite: { + achieved: boolean; + progress: { + now: number; + total: number; + }; + }; +}; + +type UserCooldowns = { + rep: number; +}; const genToken = () => { let token = ""; @@ -43,19 +76,32 @@ const genToken = () => { return token; }; -const userSchema = new Schema({ - id: { type: String, required: true }, +@Entity({ collection: "users" }) +export class User extends BaseEntity { + @Property({ type: "ObjectId", primary: true }) + _id!: ObjectId; - rep: { type: Number, default: 0 }, - bio: { type: String }, - birthdate: { type: Number }, - lover: { type: String }, + @Property({ type: "string", unique: true, index: true }) + id!: string; - registeredAt: { type: Number, default: Date.now() }, + @Property({ type: "number" }) + rep: number = 0; - achievements: { - type: Object, - default: { + @Property({ type: "string", nullable: true }) + bio?: string; + + @Property({ type: "number", nullable: true }) + birthdate!: number | null; + + @Property({ type: "string" }) + lover: string = ""; + + @Property({ type: "number", onCreate: () => Date.now() }) + registeredAt!: number; + + @Property({ + type: "json", + onCreate: () => ({ married: { achieved: false, progress: { @@ -105,66 +151,63 @@ const userSchema = new Schema({ total: 1, }, }, - }, - }, - - cooldowns: { - type: Object, - default: { - rep: 0, - }, - }, - - afk: { type: String, default: null }, - reminds: [ - { - type: Object, - default: { - message: null, - createdAt: null, - sendAt: null, - }, - }, - ], - logged: { type: Boolean, default: false }, - apiToken: { type: String, default: genToken() }, -}); - -userSchema.method("getAchievementsImage", async function () { - const canvas = createCanvas(1800, 250), - ctx = canvas.getContext("2d"); - - const images = [ - await loadImage( - `./assets/img/achievements/achievement${this.achievements.work.achieved ? "_colored" : ""}1.png`, - ), - await loadImage( - `./assets/img/achievements/achievement${this.achievements.firstCommand.achieved ? "_colored" : ""}2.png`, - ), - await loadImage( - `./assets/img/achievements/achievement${this.achievements.married.achieved ? "_colored" : ""}3.png`, - ), - await loadImage( - `./assets/img/achievements/achievement${this.achievements.slots.achieved ? "_colored" : ""}4.png`, - ), - await loadImage( - `./assets/img/achievements/achievement${this.achievements.tip.achieved ? "_colored" : ""}5.png`, - ), - await loadImage( - `./assets/img/achievements/achievement${this.achievements.rep.achieved ? "_colored" : ""}6.png`, - ), - await loadImage( - `./assets/img/achievements/achievement${this.achievements.invite.achieved ? "_colored" : ""}7.png`, - ), - ]; - let dim = 0; - - for (let i = 0; i < images.length; i++) { - ctx.drawImage(images[i], dim, 10, 350, 200); - dim += 200; - } + }), + }) + achievements!: UserAchievements; + + @Property({ type: "json" }) + cooldowns: UserCooldowns = { + rep: 0, + }; + + @Property({ type: "string" }) + afk: string | null = null; - return await canvas.encode("png"); -}); + @Property({ type: "array" }) + reminds: UserReminds[] = []; -export default model("User", userSchema); + @Property({ type: "boolean" }) + logged: boolean = false; + + @Property({ type: "string", onCreate: () => genToken() }) + token!: string; + + async getAchievements() { + const canvas = createCanvas(1800, 250), + ctx = canvas.getContext("2d"); + + const images = await Promise.all([ + await loadImage( + `./assets/img/achievements/achievement${this.achievements.work.achieved ? "_colored" : ""}1.png`, + ), + await loadImage( + `./assets/img/achievements/achievement${this.achievements.firstCommand.achieved ? "_colored" : ""}2.png`, + ), + await loadImage( + `./assets/img/achievements/achievement${this.achievements.married.achieved ? "_colored" : ""}3.png`, + ), + await loadImage( + `./assets/img/achievements/achievement${this.achievements.slots.achieved ? "_colored" : ""}4.png`, + ), + await loadImage( + `./assets/img/achievements/achievement${this.achievements.tip.achieved ? "_colored" : ""}5.png`, + ), + await loadImage( + `./assets/img/achievements/achievement${this.achievements.rep.achieved ? "_colored" : ""}6.png`, + ), + await loadImage( + `./assets/img/achievements/achievement${this.achievements.invite.achieved ? "_colored" : ""}7.png`, + ), + ]); + let dim = 0; + + for (let i = 0; i < images.length; i++) { + ctx.drawImage(images[i], dim, 10, 350, 200); + dim += 200; + } + + const encodedPhoto = await canvas.encode("png"); + + return encodedPhoto; + } +} diff --git a/src/structures/BaseEntity.ts b/src/structures/BaseEntity.ts new file mode 100644 index 00000000..fb93c2b8 --- /dev/null +++ b/src/structures/BaseEntity.ts @@ -0,0 +1,20 @@ +import { EntityManager } from "@mikro-orm/mongodb"; + +export abstract class BaseEntity { + static em: EntityManager; + + static useEntityManager(em: EntityManager) { + this.em = em; + } + + async save(): Promise { + const ctor = this.constructor as typeof BaseEntity; + ctor.em.persist(this); + await ctor.em.flush(); + return this; + } + async remove(): Promise { + const ctor = this.constructor as typeof BaseEntity; + await ctor.em.removeAndFlush(this); + } +} diff --git a/src/structures/client.ts b/src/structures/client.ts index 3bfb0034..055d8361 100644 --- a/src/structures/client.ts +++ b/src/structures/client.ts @@ -1,16 +1,16 @@ import { Client, ClientOptions } from "discord.js"; import { Handlers } from "@/handlers/index.js"; -import MongooseAdapter from "@/adapters/database/MongooseAdapter.js"; import logger from "@/helpers/logger.js"; import ConfigService from "@/services/config/index.js"; import InternationalizationService from "@/services/languages/index.js"; import { SUPER_CONTEXT } from "@/constants/index.js"; import { cacheRemindsData } from "@/types.js"; +import MikroOrmAdapter from "@/adapters/database/MikroOrmAdapter.js"; import { Rainlink, Library } from "rainlink"; export class ExtendedClient extends Client { configService = new ConfigService(); - adapter = new MongooseAdapter(this.configService.get("mongoDB")); + adapter = new MikroOrmAdapter(this.configService.get("mongoDB")); cacheReminds = new Map(); i18n = new InternationalizationService(this); rainlink = new Rainlink({ @@ -53,37 +53,38 @@ export class ExtendedClient extends Client { } async getGuildData(guildId: string) { - const { default: GuildModel } = await import("@/models/GuildModel.js"); - const guildData = await this.adapter.findOneOrCreate(GuildModel, { id: guildId }); + const { Guild } = await import("@/models/GuildModel.js"); + const guildData = await this.adapter.findOneOrCreate(Guild, { id: guildId }); return guildData; } async getUserData(userID: string) { - const { default: UserModel } = await import("@/models/UserModel.js"); - const userData = await this.adapter.findOneOrCreate(UserModel, { id: userID }); + const { User } = await import("@/models/UserModel.js"); + const userData = await this.adapter.findOneOrCreate(User, { id: userID }); return userData; } async getUsersData() { - const { default: UserModel } = await import("@/models/UserModel.js"); - const usersData = await this.adapter.find(UserModel); + const { User } = await import("@/models/UserModel.js"); + const usersData = await this.adapter.find(User); return usersData; } async getMemberData(memberId: string, guildID: string) { - const { default: MemberModel } = await import("@/models/MemberModel.js"); - const memberData = await this.adapter.findOneOrCreate(MemberModel, { + const { Member } = await import("@/models/MemberModel.js"); + const memberData = await this.adapter.findOneOrCreate(Member, { id: memberId, guildID, }); const guildData = await this.getGuildData(guildID); + const isMemberInGuild = await guildData.hasMember(memberData.id, this.adapter.em); - if (!guildData.members.includes(memberData._id)) { - guildData.members.push(memberData._id); + if (!isMemberInGuild) { + guildData.members.push(memberData); await guildData.save(); } @@ -91,8 +92,8 @@ export class ExtendedClient extends Client { } async getMembersData(guildID: string) { - const { default: MemberModel } = await import("@/models/MemberModel.js"); - const membersData = await this.adapter.find(MemberModel, { + const { Member } = await import("@/models/MemberModel.js"); + const membersData = await this.adapter.find(Member, { guildID, }); diff --git a/src/utils/config-fields.ts b/src/utils/config-fields.ts index d2b071c8..1fe98d3e 100644 --- a/src/utils/config-fields.ts +++ b/src/utils/config-fields.ts @@ -1,5 +1,5 @@ import { translateContext } from "@/helpers/functions.js"; -import GuildModel from "@/models/GuildModel.js"; +import { Guild } from "@/models/GuildModel.js"; import { APIEmbedField, ChatInputCommandInteraction } from "discord.js"; enum ConfigFieldsType { @@ -74,7 +74,7 @@ const processGroupField = async ( export const generateFields = async ( interaction: ChatInputCommandInteraction, - guildData: InstanceType, + guildData: InstanceType, ) => { const fieldsConfig: ConfigField[] = [ { @@ -82,14 +82,14 @@ export const generateFields = async ( nameKey: "administration/config:WELCOME_TITLE", valueKey: "administration/config:WELCOME_CONTENT", enabled: guildData.plugins.welcome.enabled, - data: { channel: guildData.plugins.welcome.channel }, + data: { channel: guildData.plugins.welcome.channel! }, }, { type: ConfigFieldsType.plugin, nameKey: "administration/config:GOODBYE_TITLE", valueKey: "administration/config:GOODBYE_CONTENT", enabled: guildData.plugins.goodbye.enabled, - data: { channel: guildData.plugins.goodbye.channel }, + data: { channel: guildData.plugins.goodbye.channel! }, }, { type: ConfigFieldsType.group, @@ -97,12 +97,12 @@ export const generateFields = async ( items: [ { key: "administration/config:MESSAGEUPDATE", - path: guildData.plugins.monitoring.messageUpdate, + path: guildData.plugins.monitoring.messageUpdate!, format: "channel", }, { key: "administration/config:MESSAGEDELETE", - path: guildData.plugins.monitoring.messageDelete, + path: guildData.plugins.monitoring.messageDelete!, format: "channel", }, ], @@ -113,37 +113,37 @@ export const generateFields = async ( items: [ { key: "administration/config:BIRTHDAYS", - path: guildData.plugins.birthdays, + path: guildData.plugins.birthdays!, format: "channel", }, { key: "administration/config:MODLOGS", - path: guildData.plugins.modlogs, + path: guildData.plugins.modlogs!, format: "channel", }, { key: "administration/config:REPORTS", - path: guildData.plugins.reports, + path: guildData.plugins.reports!, format: "channel", }, { key: "administration/config:SUGGESTIONS", - path: guildData.plugins.suggestions, + path: guildData.plugins.suggestions!, format: "channel", }, { key: "administration/config:TICKETSCATEGORY", - path: guildData.plugins.tickets.ticketsCategory, + path: guildData.plugins.tickets.ticketsCategory!, format: "channel", }, { key: "administration/config:TICKETLOGS", - path: guildData.plugins.tickets.ticketLogs, + path: guildData.plugins.tickets.ticketLogs!, format: "channel", }, { key: "administration/config:TRANSCRIPTIONLOGS", - path: guildData.plugins.tickets.transcriptionLogs, + path: guildData.plugins.tickets.transcriptionLogs!, format: "channel", }, ],