diff --git a/packages/api/configs/index.ts b/packages/api/configs/index.ts index 77b1101..58f11bb 100644 --- a/packages/api/configs/index.ts +++ b/packages/api/configs/index.ts @@ -48,6 +48,19 @@ export interface IConfigKeys { KEYCLOAK_REDIRECT_URI: string KEYCLOAK_AUTH_SERVER_URL: string KEYCLOAK_REALM: string + R2_CLIENT_ID: string + R2_CLIENT_SECRET: string + R2_BUCKET_NAME: string + R2_BUCKET_REGION: string + R2_BUCKET_ENDPOINT: string + R2_BUCKET_URL: string + R2_BUCKET_FOLDER: string + DISCORD_BOT_TOKEN: string + DISCORD_BOT_CLIENT_ID: number + DISCORD_BOT_CLIENT_SECRET: string + DISCORD_SERVER_ID: number + DISCORD_WEBHOOK_URL: string + DISCORD_SELF_ID: number } export default class ConfigStoreFactory { diff --git a/packages/api/controllers/discord.controller.ts b/packages/api/controllers/discord.controller.ts index 27fb885..389cab0 100644 --- a/packages/api/controllers/discord.controller.ts +++ b/packages/api/controllers/discord.controller.ts @@ -3,7 +3,7 @@ import { Request, Response } from 'express' import { makeResponse } from '../libs' export default class DiscordController extends DiscordService { - public getProfile = async (req: Request, res: Response) => { + public getActivity = async (req: Request, res: Response) => { try { const data = await this.activity() res.send(makeResponse(data)) @@ -22,4 +22,22 @@ export default class DiscordController extends DiscordService { res.send(makeResponse(error.message, {}, 'Failed', true)) } } + + public getProfile = async (req: Request, res: Response) => { + try { + const data = await this.profile() + res.send(makeResponse(data)) + } catch (error: any) { + res.send(makeResponse(error.message, {}, 'Failed', true)) + } + } + + public getPresence = async (req: Request, res: Response) => { + try { + const data = await this.presence() + res.send(makeResponse(data)) + } catch (error: any) { + res.send(makeResponse(error.message, {}, 'Failed', true)) + } + } } diff --git a/packages/api/controllers/upload.controller.ts b/packages/api/controllers/upload.controller.ts new file mode 100644 index 0000000..ac7ef20 --- /dev/null +++ b/packages/api/controllers/upload.controller.ts @@ -0,0 +1,27 @@ +import { NextFunction, Request, Response } from 'express' +import { ModRequest } from '../types' +import Uploader from '../services/upload.service' + +export default class UploadController extends Uploader { + public upload = async ( + req: ModRequest, + res: Response, + next: NextFunction + ) => { + try { + const { file } = req + if (!file) { + const error = new Error('Please upload a file') + next(error) + } + const data = await this.uploadS(file) + res.status(200).json({ + success: true, + message: 'File uploaded successfully', + data, + }) + } catch (error: any) { + next(error) + } + } +} diff --git a/packages/api/data/uploader.service.ts b/packages/api/data/uploader.service.ts new file mode 100644 index 0000000..2b9c1e5 --- /dev/null +++ b/packages/api/data/uploader.service.ts @@ -0,0 +1,67 @@ +import { S3Client, PutObjectCommand, S3ClientConfig } from '@aws-sdk/client-s3' +import { configKeys } from '..' + +type IUploaderServerType = 's3' | 'r2' | 'safe' + +export default class UploaderService { + private static _s3Client + private static _s3Opts + private static _clientType + + constructor(bucket, clientType: IUploaderServerType = 's3') { + const options = { + bucket, + } + UploaderService._s3Opts = options + UploaderService._clientType = clientType + + if (clientType === 's3') { + const s3ClientOpts: S3ClientConfig = { + region: configKeys.S3_BUCKET_REGION || '', + endpoint: configKeys.S3_BUCKET_ENDPOINT || '', + forcePathStyle: true, + credentials: { + accessKeyId: configKeys.S3_CLIENT_ID || '', + secretAccessKey: configKeys.S3_CLIENT_SECRET || '', + }, + } + const client = new S3Client(s3ClientOpts) + UploaderService._s3Client = client + } else if (clientType === 'r2') { + const s3ClientOpts: S3ClientConfig = { + region: configKeys.R2_BUCKET_REGION || '', + endpoint: configKeys.R2_BUCKET_ENDPOINT || '', + forcePathStyle: true, + credentials: { + accessKeyId: configKeys.R2_CLIENT_ID || '', + secretAccessKey: configKeys.R2_CLIENT_SECRET || '', + }, + } + const client = new S3Client(s3ClientOpts) + UploaderService._s3Client = client + } + } + + async uploadFile( + entity: string, + id: string, + file: Buffer, + fileType: string, + acl?: string + ) { + const key = [entity, id].join('/') + const uploadParams = { + Bucket: UploaderService._s3Opts.bucket, + ACL: acl, + ContentType: fileType, + Body: file, + Key: key, + } + await UploaderService._s3Client.send(new PutObjectCommand(uploadParams)) + return { + url: configKeys.S3_BUCKET_URL, + bucket_name: configKeys.S3_BUCKET_NAME, + folder: configKeys.S3_BUCKET_FOLDER, + } + } +} diff --git a/packages/api/helpers/discord_bot.factory.ts b/packages/api/helpers/discord_bot.factory.ts new file mode 100644 index 0000000..3f2ec4c --- /dev/null +++ b/packages/api/helpers/discord_bot.factory.ts @@ -0,0 +1,54 @@ +import { REST } from '@discordjs/rest' +import { + WebSocketManager, + WebSocketShardStatus, + WebSocketShardEvents, +} from '@discordjs/ws' +import { + GatewayDispatchEvents, + GatewayIntentBits, + Client, +} from '@discordjs/core' +import { configKeys } from '..' + +export default class DiscordBotClient { + public static _client: Client + private static _rest: REST + public static _gateway: WebSocketManager + private static _currentPresence + + public static init() { + this._rest = new REST({ version: '10' }).setToken( + configKeys.DISCORD_BOT_TOKEN + ) + this._gateway = new WebSocketManager({ + token: configKeys.DISCORD_BOT_TOKEN, + intents: GatewayIntentBits.GuildPresences, + rest: this._rest, + shardCount: 1, + shardIds: [0], + }) + this._client = new Client({ + rest: this._rest, + gateway: this._gateway, + }) + } + + public static getPresence = async () => { + return this._currentPresence + } + + // public static getShard = async () => { + // return this._gateway.on(WebSocketShardEvents.Dispatch, (data) => { + // console.log(data) + // }) + // } + + public static setPresence = async (presence: any) => { + this._currentPresence = presence + } + + public static getUser = async (id: number) => { + return this._client.rest.get(`/users/${id}`) + } +} diff --git a/packages/api/helpers/discord_bot_client.ts b/packages/api/helpers/discord_bot_client.ts new file mode 100644 index 0000000..4a88176 --- /dev/null +++ b/packages/api/helpers/discord_bot_client.ts @@ -0,0 +1,29 @@ +import { GatewayDispatchEvents } from '@discordjs/core' + +import DiscordBotClient from './discord_bot.factory' +import { configKeys } from '..' + +export default async () => { + DiscordBotClient.init() + + const DiscordBot = DiscordBotClient._client + + DiscordBot.on(GatewayDispatchEvents.PresenceUpdate, async ({ data }) => { + if (data.user.id == String(configKeys.DISCORD_SELF_ID)) { + DiscordBotClient.setPresence(data) + } + }) + + const selfInfo = await DiscordBot.api.users.getCurrent() + DiscordBot.once(GatewayDispatchEvents.Ready, async () => { + console.log( + '๐Ÿ”ฎ ' + + selfInfo.username + + '#' + + selfInfo.discriminator + + ' : Gateway Connected!' + ) + }) + + DiscordBotClient._gateway.connect() +} diff --git a/packages/api/helpers/upload.factory.ts b/packages/api/helpers/upload.factory.ts index 9bd5250..c9cb4c9 100644 --- a/packages/api/helpers/upload.factory.ts +++ b/packages/api/helpers/upload.factory.ts @@ -1,82 +1,43 @@ -import { S3Client } from '@aws-sdk/client-s3' import multer from 'multer' -import multerS3 from 'multer-s3' import path from 'path' -import napiNanoId from 'napi-nanoid' -import { configKeys } from '..' -interface UploadFactoryOptions { - region: string - bucket: string - accessKey: string - secretKey: string -} +import { nanoid } from 'napi-nanoid' interface UploaderConfig { - folder: string mimeFilters: string[] } export class UploadFactory { - private options: UploadFactoryOptions & Partial - private s3Client: S3Client - - constructor(options?: Partial) { - this.options = { - bucket: options?.bucket || configKeys.S3_BUCKET_NAME || '', - region: options?.region || configKeys.S3_BUCKET_REGION || '', - accessKey: options?.accessKey || configKeys.S3_CLIENT_ID || '', - secretKey: options?.secretKey || configKeys.S3_CLIENT_SECRET || '', - } - - this.s3Client = new S3Client({ - region: this.options.region, - credentials: { - accessKeyId: this.options.accessKey, - secretAccessKey: this.options.secretKey, + public getUploader(config?: UploaderConfig) { + const storage = multer.memoryStorage({ + filename: (req, file, cb) => { + const fileName = + file.originalname + + '-' + + nanoid() + + path.extname(file.originalname) + cb(null, fileName) }, }) - } - - public get serviceName(): string { - return 'aws:' + this.options.bucket - } - - public getUploader( - config?: Partial - ) { - const finalOptions = { - ...this.options, - ...(config || {}), - } return multer({ - fileFilter(_req, file, cb) { - const res = finalOptions.mimeFilters - ? finalOptions.mimeFilters.includes(file.mimetype) - : true - cb(null, res) - }, - storage: multerS3({ - s3: this.s3Client, - bucket: this.options.bucket, - acl: 'public-read', - contentType: multerS3.AUTO_CONTENT_TYPE, - metadata: function (_req, file, cb) { - const meta = { - fieldName: file.fieldname, - fileName: file.originalname, - uploadOn: new Date().toISOString(), + storage: storage, + fileFilter: (req, file, cb) => { + const fileName = + file.originalname.split('.')[ + file.originalname.split('.').length - 2 + ] + + '-' + + nanoid() + + path.extname(file.originalname) + file.newName = fileName + if (config?.mimeFilters?.length) { + if (config.mimeFilters.includes(file.mimetype)) { + cb(null, true) + } else { + cb(new Error('File type not allowed'), false) } - cb(null, meta) - }, - key: function (_req, file, cb) { - const key: string[] = [] - if (finalOptions.folder) key.push(finalOptions.folder) - const value = napiNanoId.nanoid() - const ext = path.extname(file.originalname) - key.push(value + ext) - - cb(null, key.join('/')) - }, - }), + } else { + cb(null, true) + } + }, }) } } diff --git a/packages/api/index.ts b/packages/api/index.ts index 14eb085..b040b23 100644 --- a/packages/api/index.ts +++ b/packages/api/index.ts @@ -10,6 +10,7 @@ import routes from './routes' import { errorHandler, notFoundHandler } from './libs' import pkg from './package.json' assert { type: 'json' } import configStore, { IConfigKeys } from './configs' +import discordBotConnect from './helpers/discord_bot_client' export const app: express.Application = express() @@ -23,6 +24,8 @@ console.log(isDev ? '๐Ÿš€ Production Mode' : '๐Ÿ‘ท Development Mode') const configs = new configStore(isDev) const configKeys: IConfigKeys = (await configs.getConfigStore()) as IConfigKeys +discordBotConnect() + app.use(cors()) app.use(helmet()) app.use(morgan('dev')) diff --git a/packages/api/package.json b/packages/api/package.json index 34bd5aa..f087ab1 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -10,6 +10,8 @@ "private": true, "dependencies": { "@aws-sdk/client-s3": "^3.226.0", + "@discordjs/core": "^0.6.0", + "@discordjs/rest": "^1.7.1", "@types/express": "^4.17.14", "axios": "^1.2.1", "cors": "^2.8.5", diff --git a/packages/api/routes/api/upload.routes.ts b/packages/api/routes/api/upload.routes.ts new file mode 100644 index 0000000..c4f65a8 --- /dev/null +++ b/packages/api/routes/api/upload.routes.ts @@ -0,0 +1,23 @@ +import { UploadFactory } from '../../helpers/upload.factory' +import { Router } from 'express' +import UploadController from '../../controllers/upload.controller' + +const router = Router() +const uploadController = new UploadController() +const uploaderFactory = new UploadFactory() + +router.post( + '/', + uploaderFactory + .getUploader({ + mimeFilters: [ + 'image/jpeg', + 'image/png', + 'image/gif', + 'application/json', + ], + }) + .single('file'), + uploadController.upload as any +) +export default router diff --git a/packages/api/routes/me/discord.routes.ts b/packages/api/routes/me/discord.routes.ts index 064681b..3500796 100644 --- a/packages/api/routes/me/discord.routes.ts +++ b/packages/api/routes/me/discord.routes.ts @@ -2,9 +2,13 @@ import { Router } from 'express' import DiscordController from '../../controllers/discord.controller' const router = Router() -const { getProfile, getBanner } = new DiscordController() +const { getProfile, getBanner, getActivity, getPresence } = + new DiscordController() -router.get('/profile', getProfile) +router.get('/profile', getActivity) router.get('/banner', getBanner) +router.get('/v2/profile', getProfile) +router.get('/v2/activity', getPresence) + export default router diff --git a/packages/api/services/discord.service.ts b/packages/api/services/discord.service.ts index 0a7cf6b..3edffac 100644 --- a/packages/api/services/discord.service.ts +++ b/packages/api/services/discord.service.ts @@ -1,4 +1,6 @@ +import { configKeys } from '..' import axios from '../helpers/axios_client' +import DiscordBotClient from '../helpers/discord_bot.factory' export default class DiscordService { public activity = async () => { @@ -14,4 +16,12 @@ export default class DiscordService { ) return data } + + public profile = async () => { + return DiscordBotClient.getUser(configKeys.DISCORD_SELF_ID) + } + + public presence = async () => { + return DiscordBotClient.getPresence() + } } diff --git a/packages/api/services/upload.service.ts b/packages/api/services/upload.service.ts new file mode 100644 index 0000000..dbbfecb --- /dev/null +++ b/packages/api/services/upload.service.ts @@ -0,0 +1,26 @@ +import UploaderService from '../data/uploader.service' +import { configKeys } from '..' + +const uploaderService = new UploaderService(configKeys.S3_BUCKET_NAME, 'r2') + +export default class Uploader { + public uploadS = async (file: any) => { + await uploaderService.uploadFile( + configKeys.S3_BUCKET_FOLDER, + file.newName, + file.buffer, + file.mimetype, + 'public-read' + ) + return { + url: + configKeys.S3_BUCKET_URL + + '/' + + configKeys.S3_BUCKET_NAME + + '/' + + configKeys.S3_BUCKET_FOLDER + + '/' + + file.newName, + } + } +} diff --git a/packages/api/types/index.d.ts b/packages/api/types/index.d.ts index 93d3aab..ff56687 100644 --- a/packages/api/types/index.d.ts +++ b/packages/api/types/index.d.ts @@ -10,4 +10,5 @@ export interface PaginationType { export interface ModRequest extends Request { user: any + file: any } diff --git a/packages/bot/assets/jail.png b/packages/bot/assets/jail.png new file mode 100644 index 0000000..d98efc0 Binary files /dev/null and b/packages/bot/assets/jail.png differ diff --git a/packages/bot/assets/rip.jpg b/packages/bot/assets/rip.jpg new file mode 100644 index 0000000..205b969 Binary files /dev/null and b/packages/bot/assets/rip.jpg differ diff --git a/packages/bot/assets/trash.png b/packages/bot/assets/trash.png new file mode 100644 index 0000000..3953fbb Binary files /dev/null and b/packages/bot/assets/trash.png differ diff --git a/packages/bot/assets/triggered.png b/packages/bot/assets/triggered.png new file mode 100644 index 0000000..6e3010e Binary files /dev/null and b/packages/bot/assets/triggered.png differ diff --git a/packages/bot/config/bot.json b/packages/bot/config/bot.json new file mode 100644 index 0000000..c32231f --- /dev/null +++ b/packages/bot/config/bot.json @@ -0,0 +1,8 @@ +{ + "defaultPrefix": "d!", + "disabledCommandCatagories": [], + "githubLink": "https://github.com/cktsun1031/DraconianBot", + "name": "Hanako", + "ownerId": "457039372009865226", + "useShards": false +} diff --git a/packages/bot/config/emojis.json b/packages/bot/config/emojis.json new file mode 100644 index 0000000..28f6fa4 --- /dev/null +++ b/packages/bot/config/emojis.json @@ -0,0 +1,5 @@ +{ + "error": "โŒ", + "success": "โœ…", + "warning": "โš ๏ธ" +} diff --git a/packages/bot/config/guild.config.ts b/packages/bot/config/guild.config.ts new file mode 100644 index 0000000..d937b01 --- /dev/null +++ b/packages/bot/config/guild.config.ts @@ -0,0 +1,47 @@ +import { GuildConfig } from '../src/sturctures/database' + +const MYGuildConfig: GuildConfig = { + prefix: 'd!', + serverId: '636830997790326794', + commands: { + global: { + disabled: [], + disabledCatagories: [], + customCooldown: [], + }, + userDisabled: [], + roleDisabled: [], + channelDisabled: [], + }, + antiSpam: { + enabled: true, + whitelistedUsers: [], + whitelistedRoles: [], + whitelistedChannels: [], + inviteLinks: { + enabled: true, + whitelistedUsers: [], + whitelistedRoles: [], + whitelistedChannels: [], + }, + mentions: { + enabled: true, + maxmiumCheck: { + enabled: true, + value: 5, + }, + publicRoleCheck: true, + whitelistedUsers: [], + whitelistedRoles: [], + whitelistedChannels: [], + }, + }, + thread: { + listen: true, + }, + snipe: { + channelDisabled: [], + }, +} + +export default MYGuildConfig diff --git a/packages/bot/package.json b/packages/bot/package.json new file mode 100644 index 0000000..5045cca --- /dev/null +++ b/packages/bot/package.json @@ -0,0 +1,48 @@ +{ + "name": "bot", + "version": "1.0.0", + "description": "Hanako's Egg", + "main": "src/index.ts", + "author": "BRAVO68WEB", + "license": "MIT", + "private": true, + "type": "module", + "devDependencies": { + "@types/crypto-js": "^4.1.1", + "@types/gifencoder": "^2.0.1", + "@types/node": "^20.1.3", + "@types/pidusage": "^2.0.2", + "@types/string-similarity": "^4.0.0", + "nodemon": "^2.0.22", + "ts-node": "^10.9.1", + "typescript": "^5.0.4" + }, + "dependencies": { + "@discordjs/core": "^0.6.0", + "@discordjs/rest": "^1.7.1", + "@esbuild-kit/esm-loader": "^2.5.5", + "@types/xml2js": "^0.4.11", + "axios": "^1.4.0", + "canvas": "^2.11.2", + "crypto-js": "^4.1.1", + "dayjs": "^1.11.7", + "discord.js": "^14.11.0", + "dotenv": "^16.0.3", + "express": "^4.18.2", + "gifencoder": "^2.0.1", + "glob": "^10.2.3", + "graphql": "^16.6.0", + "graphql-request": "^6.0.0", + "napi-nanoid": "^0.2.0", + "node-cache": "^5.1.2", + "pidusage": "^3.0.2", + "redis": "^4.6.6", + "string-similarity": "^4.0.4", + "xml2js": "^0.5.0", + "zod": "^3.21.4" + }, + "scripts": { + "dev": "nodemon --watch 'src/**/*.ts' --exec node --loader @esbuild-kit/esm-loader src/index.ts", + "start": "node --loader @esbuild-kit/esm-loader src/index.ts" + } +} diff --git a/packages/bot/src/bot.ts b/packages/bot/src/bot.ts new file mode 100644 index 0000000..87df8ff --- /dev/null +++ b/packages/bot/src/bot.ts @@ -0,0 +1,51 @@ +import { Client, Collection, GatewayIntentBits, Partials } from 'discord.js' + +// import './http/server'; + +import { loadSlashCommand, loadTextCommand } from './loaders/command' +import { loadDiscordEvent } from './loaders/event' +import type { SlashCommand, TextCommand } from './sturctures/command' +import { hgqlInit } from './utils/database' + +const client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMembers, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.DirectMessages, + GatewayIntentBits.MessageContent, + ], + allowedMentions: { parse: ['users', 'roles'], repliedUser: false }, + partials: [ + Partials.User, + Partials.Channel, + Partials.Message, + Partials.GuildMember, + ], +}) + +client.commands = new Collection() +client.commandsCatagories = new Collection() +client.aliases = new Collection() +client.slashcommands = new Collection() + +await loadDiscordEvent(client) +await loadTextCommand(client) + +await hgqlInit() + +await client.login(process.env.TOKEN) + +await loadSlashCommand(client, process.env.CLIENT_ID, process.env.TOKEN) + +export default client + +// declare types. +declare module 'discord.js' { + export interface Client { + aliases: Collection + commands: Collection + slashcommands: Collection + commandsCatagories: Collection + } +} diff --git a/packages/bot/src/commands/message/general/botinfo.ts b/packages/bot/src/commands/message/general/botinfo.ts new file mode 100644 index 0000000..3e9f040 --- /dev/null +++ b/packages/bot/src/commands/message/general/botinfo.ts @@ -0,0 +1,67 @@ +import { EmbedBuilder, version as discordJsVersion } from 'discord.js' +import pidusage from 'pidusage' + +import { version as packageVersion } from '../../../../package.json' +import type { TextCommand } from '../../../sturctures/command' +import { parseMsToVisibleText } from '../../../utils/formatters' + +export const command: TextCommand = { + data: { + name: 'botinfo', + description: 'Check Bot information.', + directMessageAllowed: true, + }, + run: async ({ message }) => { + const apiDelayMS = Math.round(message.client.ws.ping) + const osStats = await pidusage(process.pid) + + const embed = new EmbedBuilder() + .setTitle("Bot's Information") + .setDescription( + 'Hello! I am Draconian Bot, honored to see you here. Information below is my body analysis :)' + ) + .addFields([ + { + name: 'Version', + value: `\`${packageVersion}\``, + inline: true, + }, + { + name: 'Discord.js', + value: `\`${discordJsVersion}\``, + inline: true, + }, + { + name: 'Node', + value: `\`${process.version}\``, + inline: true, + }, + { + name: 'CPU', + value: `\`${Math.round(Number(osStats.cpu.toFixed(2)))}%\``, + inline: true, + }, + { + name: 'Memory', + value: `\`${Math.round( + osStats.memory / (1024 * 1024) + )}MB\``, + inline: true, + }, + { + name: 'Uptime', + value: `\`${parseMsToVisibleText(message.client.uptime)}\``, + inline: true, + }, + { + name: 'Network Delay', + value: `\`${apiDelayMS} ms\``, + inline: true, + }, + ]) + + await message.reply({ + embeds: [embed], + }) + }, +} diff --git a/packages/bot/src/commands/message/general/channelinfo.ts b/packages/bot/src/commands/message/general/channelinfo.ts new file mode 100644 index 0000000..694b5af --- /dev/null +++ b/packages/bot/src/commands/message/general/channelinfo.ts @@ -0,0 +1,194 @@ +import dayjs from 'dayjs' +import type { TextChannel, ThreadChannel, VoiceChannel } from 'discord.js' +import { EmbedBuilder } from 'discord.js' + +import type { TextCommand } from '../../../sturctures/command' + +export const command: TextCommand = { + data: { + name: 'channelinfo', + description: "Check server's channel information.", + directMessageAllowed: false, + }, + // eslint-disable-next-line + run: async ({ message, args }) => { + const { guild, channel, mentions, member } = message + + if (!guild || !member) return + + const targetNameId = args[0] + + let targetChannel = mentions.channels.first() + + if (!targetChannel && targetNameId) { + const fetchedChannel = guild.channels.cache.get(targetNameId) + if (fetchedChannel) targetChannel = fetchedChannel + else { + const name = String(targetNameId).toLowerCase() + const fetchedChannelByKW = guild.channels.cache.find((ur) => + ur.name.toLowerCase().includes(name) + ) + if (fetchedChannelByKW) targetChannel = fetchedChannelByKW + } + } + + if (!targetChannel) targetChannel = channel + + const embed = new EmbedBuilder() + + if (targetChannel.isTextBased()) { + const textChannel = targetChannel as TextChannel + + embed.setTitle(`${textChannel.name}'s information:`).addFields([ + { name: 'ID', value: textChannel.id }, + { + // eslint-disable-next-line + name: 'Created on', + // eslint-disable-next-line + value: dayjs(textChannel.createdAt.getTime()).format( + 'DD/MM/YYYY' + ), + inline: true, + }, + ]) + + if (textChannel.parent?.name) { + embed.addFields([ + { + name: 'Parent', + value: textChannel.parent.name, + inline: true, + }, + ]) + } + + embed.addFields([ + { + name: 'Position', + value: textChannel.rawPosition.toString(), + inline: true, + }, + { + name: 'NSFW', + value: textChannel.nsfw ? 'YES' : 'NO', + inline: true, + }, + { + name: 'Viewable', + value: textChannel.viewable ? 'YES' : 'NO', + inline: true, + }, + ]) + + embed.setFooter({ + iconURL: guild.iconURL() ?? '', + text: `Shard ID: ${guild.shardId}`, + }) + + await message.reply({ + embeds: [embed], + }) + + return + } + + if (targetChannel.isThread()) { + const voiceChannel = targetChannel as ThreadChannel + + embed.setTitle(`${voiceChannel.name}'s information:`).addFields([ + { name: 'ID', value: voiceChannel.id }, + { + name: 'Created on', + value: dayjs(voiceChannel.createdAt?.getTime()).format( + 'DD/MM/YYYY' + ), + inline: true, + }, + ]) + + if (voiceChannel.parent?.name) { + embed.addFields([ + { + name: 'Parent', + value: voiceChannel.parent.name, + inline: true, + }, + ]) + } + + embed.addFields([ + { + name: 'Joinable', + value: voiceChannel.joinable ? 'YES' : 'NO', + inline: true, + }, + { + name: 'Locked', + value: voiceChannel.locked ? 'YES' : 'NO', + inline: true, + }, + ]) + + embed.setFooter({ + iconURL: guild.iconURL() ?? '', + text: `Shard ID: ${guild.shardId}`, + }) + + await message.reply({ + embeds: [embed], + }) + } + + if (targetChannel.isVoiceBased()) { + const voiceChannel = targetChannel as unknown as VoiceChannel + + embed.setTitle(`${voiceChannel.name}'s information:`).addFields([ + { name: 'ID', value: voiceChannel.id }, + { + name: 'Created on', + value: dayjs(voiceChannel.createdAt.getTime()).format( + 'DD/MM/YYYY' + ), + inline: true, + }, + ]) + + if (voiceChannel.parent?.name) { + embed.addFields([ + { + name: 'Parent', + value: voiceChannel.parent.name, + inline: true, + }, + ]) + } + + embed.addFields([ + { + name: 'Position', + value: voiceChannel.rawPosition.toString(), + inline: true, + }, + { + name: 'Joinable', + value: voiceChannel.joinable ? 'YES' : 'NO', + inline: true, + }, + { + name: 'Speakable', + value: voiceChannel.speakable ? 'YES' : 'NO', + inline: true, + }, + ]) + + embed.setFooter({ + iconURL: guild.iconURL() ?? '', + text: `Shard ID: ${guild.shardId}`, + }) + + await message.reply({ + embeds: [embed], + }) + } + }, +} diff --git a/packages/bot/src/commands/message/general/help.ts b/packages/bot/src/commands/message/general/help.ts new file mode 100644 index 0000000..6de613e --- /dev/null +++ b/packages/bot/src/commands/message/general/help.ts @@ -0,0 +1,99 @@ +import type { TextChannel } from 'discord.js' +import { EmbedBuilder } from 'discord.js' + +import config from '../../../../config/bot.json' +import type { TextCommand } from '../../../sturctures/command' +import { getCommandHelpInfo } from '../../../utils/cmds' +import { callbackEmbed } from '../../../utils/messages' + +export const command: TextCommand = { + data: { + name: 'help', + description: 'Get information or help from the bot.', + directMessageAllowed: true, + }, + // eslint-disable-next-line + run: async ({ message, args }) => { + const embed = new EmbedBuilder() + + const { client, channel, guildId } = message + + if (args[0]) { + let cmd: TextCommand | undefined + + const commandMatching = client.commands.get(args[0]) + const aliasesMatching = client.aliases.get(args[0]) + + // Fetch command destination. + if (commandMatching) { + cmd = commandMatching + } else if (aliasesMatching) { + cmd = client.commands.get(aliasesMatching) + } else { + const cEmbed = callbackEmbed({ + text: 'Command requested does not exist!', + color: 'Red', + mode: 'error', + }) + await message.reply({ + embeds: [cEmbed], + }) + + return + } + + if (cmd) { + const helpInfo = getCommandHelpInfo(cmd) + await message.reply({ + embeds: [helpInfo], + }) + } + } else { + const commandsCatagories = client.commandsCatagories + + embed.setDescription( + `Hello๐Ÿ™‹โ€โ™‚๏ธ!\nOur source code: [Here](${config.githubLink})\nTurely appreciate that you are supporting us.` + ) + + for (const catagory of commandsCatagories) { + if (catagory[0].toLocaleLowerCase() === 'nsfw') { + if ((channel as TextChannel).nsfw) { + catagory[0] += ' THIS CHANNEL ONLY' + } else { + continue + } + } + const text = catagory[1] + .map((index: string) => { + let _text: string | undefined + const cmd = client.commands.get(index) + if ( + guildId || + (cmd && cmd.data.directMessageAllowed === true) + ) { + _text = `\`${index}\`` + } + + return _text + }) + .filter(Boolean) + .join(', ') + + if (text.length > 0) { + embed.addFields([{ name: catagory[0], value: text }]) + } + } + + const avatarURL = client.user.defaultAvatarURL + + embed.setTitle('Bot Assistance Centre').setFooter({ + text: `ยฉ ${new Date().getFullYear()} ${config.name}`, + iconURL: avatarURL, + }) + + await message.reply({ + embeds: [embed], + }) + } + }, +} diff --git a/packages/bot/src/commands/message/general/invite.ts b/packages/bot/src/commands/message/general/invite.ts new file mode 100644 index 0000000..21f0661 --- /dev/null +++ b/packages/bot/src/commands/message/general/invite.ts @@ -0,0 +1,27 @@ +import { EmbedBuilder } from 'discord.js' + +import type { TextCommand } from '../../../sturctures/command' +import { isDev } from '../../../utils/constants' + +export const command: TextCommand = { + data: { + name: 'invite', + description: 'Invite me to your server!', + directMessageAllowed: true, + }, + run: async ({ message }) => { + const { client } = message + + if (isDev || client.application.botPublic) { + const link = `https://discord.com/api/oauth2/authorize?client_id=${client.application.id}&permissions=1636381879799&scope=applications.commands%20bot` + + const embed = new EmbedBuilder().setDescription( + `Invite to your server: [HERE](${link})` + ) + + await message.reply({ + embeds: [embed], + }) + } + }, +} diff --git a/packages/bot/src/commands/message/general/ping.ts b/packages/bot/src/commands/message/general/ping.ts new file mode 100644 index 0000000..5c1bfed --- /dev/null +++ b/packages/bot/src/commands/message/general/ping.ts @@ -0,0 +1,23 @@ +import { EmbedBuilder } from 'discord.js' + +import type { TextCommand } from '../../../sturctures/command' + +export const command: TextCommand = { + data: { + name: 'ping', + description: "Check Bot's network delay.", + directMessageAllowed: true, + }, + run: async ({ message }) => { + const apiDelayMS = Math.round(message.client.ws.ping) + const messageDelayMS = Date.now() - message.createdTimestamp + + const embed = new EmbedBuilder().setDescription( + `Action Delay: \`${messageDelayMS}ms\`\nAPI Delay: \`${apiDelayMS}ms\`` + ) + + await message.reply({ + embeds: [embed], + }) + }, +} diff --git a/packages/bot/src/commands/message/general/serverinfo.ts b/packages/bot/src/commands/message/general/serverinfo.ts new file mode 100644 index 0000000..370169e --- /dev/null +++ b/packages/bot/src/commands/message/general/serverinfo.ts @@ -0,0 +1,96 @@ +import dayjs from 'dayjs' +import { EmbedBuilder } from 'discord.js' + +import type { TextCommand } from '../../../sturctures/command' + +export const command: TextCommand = { + data: { + name: 'serverinfo', + description: "Check server's stats and information.", + directMessageAllowed: false, + }, + run: async ({ message }) => { + const { guild } = message + + if (!guild) return + + const owner = await guild.fetchOwner() + + const embed = new EmbedBuilder() + .setThumbnail(guild.iconURL() ?? '') + .setTitle(`${guild.name}'s information:`) + .addFields([ + { name: 'Owner', value: owner.user.tag, inline: true }, + + { + name: 'Created on', + value: dayjs(guild.createdAt.getTime()).format( + 'DD/MM/YYYY' + ), + inline: true, + }, + + { + name: 'User Count', + value: guild.memberCount.toString(), + inline: true, + }, + + { + name: 'Bot Count', + value: guild.members.cache + .filter((mb) => mb.user.bot) + .size.toString(), + inline: true, + }, + { + name: 'Roles', + value: guild.members.cache + .filter((mb) => mb.user.bot) + .size.toString(), + inline: true, + }, + { + name: 'Roles', + value: guild.roles.cache.size.toString(), + inline: true, + }, + { + name: 'Emojis', + value: guild.channels.cache.size.toString(), + inline: true, + }, + { + name: 'Verification Level', + value: guild.verificationLevel.toString(), + inline: true, + }, + ]) + .setFooter({ + text: `Shard ID: ${guild.shardId}`, + }) + + if (guild.description) embed.setDescription(guild.description) + + if (guild.premiumSubscriptionCount) { + embed.addFields([ + { + name: 'Total Boosts', + value: guild.premiumSubscriptionCount.toString(), + inline: true, + }, + ]) + } + + if (guild.vanityURLCode) { + const url = `https://discord.gg/${guild.vanityURLCode}` + embed.addFields([ + { name: 'Vanity Invite URL', value: `[${url}](${url})` }, + ]) + } + + await message.reply({ + embeds: [embed], + }) + }, +} diff --git a/packages/bot/src/commands/message/general/uptime.ts b/packages/bot/src/commands/message/general/uptime.ts new file mode 100644 index 0000000..5856365 --- /dev/null +++ b/packages/bot/src/commands/message/general/uptime.ts @@ -0,0 +1,28 @@ +import { EmbedBuilder } from 'discord.js' + +import type { TextCommand } from '../../../sturctures/command' +import { parseMsToVisibleText } from '../../../utils/formatters' + +export const command: TextCommand = { + data: { + name: 'uptime', + description: 'Check Bot uptime duration.', + directMessageAllowed: true, + }, + run: async ({ message }) => { + const instanceBoot = message.client.uptime + const bootTimeMS = Math.round((Date.now() - instanceBoot) / 1000) + + const embed = new EmbedBuilder() + .setTitle("Bot's Uptime") + .setDescription( + `\`${parseMsToVisibleText( + instanceBoot + )}\`\n**Booted at** ` + ) + + await message.reply({ + embeds: [embed], + }) + }, +} diff --git a/packages/bot/src/commands/message/images/cat.ts b/packages/bot/src/commands/message/images/cat.ts new file mode 100644 index 0000000..7c679c5 --- /dev/null +++ b/packages/bot/src/commands/message/images/cat.ts @@ -0,0 +1,45 @@ +import axios from 'axios' +import { EmbedBuilder } from 'discord.js' + +import type { TextCommand } from '../../../sturctures/command' + +export const command: TextCommand = { + data: { + name: 'cat', + description: 'Fetch cat image.', + directMessageAllowed: true, + cooldownInterval: 6 * 1000, + }, + run: async ({ message }) => { + const embed = new EmbedBuilder() + + const url = 'https://api.thecatapi.com/v1/images/search?format=json' + + try { + const response = await axios.get(url) + const responseData = response.data + + const data: { + id: string + url: string + } = responseData[0] + + embed + .setTitle('Cat here') + .setImage(data.url) + .setFooter({ + text: `ID: ${data.id}`, + }) + + await message.reply({ + embeds: [embed], + }) + } catch { + embed.setDescription('Error occured when fetching meme content.') + + await message.reply({ + embeds: [embed], + }) + } + }, +} diff --git a/packages/bot/src/commands/message/images/dog.ts b/packages/bot/src/commands/message/images/dog.ts new file mode 100644 index 0000000..9261119 --- /dev/null +++ b/packages/bot/src/commands/message/images/dog.ts @@ -0,0 +1,40 @@ +import axios from 'axios' +import { EmbedBuilder } from 'discord.js' + +import type { TextCommand } from '../../../sturctures/command' + +export const command: TextCommand = { + data: { + name: 'dog', + description: 'Fetch dog image.', + directMessageAllowed: true, + cooldownInterval: 6 * 1000, + }, + run: async ({ message }) => { + const embed = new EmbedBuilder() + + const url = 'https://dog.ceo/api/breeds/image/random' + + try { + const response = await axios.get(url) + const responseData: { + status: string + message: string + } = response.data + + if (responseData.status === 'success') { + embed.setTitle('Dog here').setImage(responseData.message) + + await message.reply({ + embeds: [embed], + }) + } + } catch { + embed.setDescription('Error occured when fetching meme content.') + + await message.reply({ + embeds: [embed], + }) + } + }, +} diff --git a/packages/bot/src/commands/message/images/joke.ts b/packages/bot/src/commands/message/images/joke.ts new file mode 100644 index 0000000..b5a4a58 --- /dev/null +++ b/packages/bot/src/commands/message/images/joke.ts @@ -0,0 +1,44 @@ +import axios from 'axios' +import { EmbedBuilder } from 'discord.js' + +import type { TextCommand } from '../../../sturctures/command' + +export const command: TextCommand = { + data: { + name: 'joke', + description: 'Get some jokes.', + directMessageAllowed: true, + cooldownInterval: 6 * 1000, + }, + run: async ({ message }) => { + const embed = new EmbedBuilder() + + const url = 'https://v2.jokeapi.dev/joke/Any?type=single' + + try { + const response = await axios.get(url) + const responseData: { + id: string + joke: string + error: boolean + category: string + } = response.data + + if (responseData.error) throw 0 + + embed.setTitle(responseData.joke).setFooter({ + text: `Category: ${responseData.category} โ€ข ID: ${responseData.id}`, + }) + + await message.reply({ + embeds: [embed], + }) + } catch { + embed.setDescription('Error occured when fetching meme content.') + + await message.reply({ + embeds: [embed], + }) + } + }, +} diff --git a/packages/bot/src/commands/message/images/meme.ts b/packages/bot/src/commands/message/images/meme.ts new file mode 100644 index 0000000..0c37784 --- /dev/null +++ b/packages/bot/src/commands/message/images/meme.ts @@ -0,0 +1,45 @@ +import axios from 'axios' +import { EmbedBuilder } from 'discord.js' + +import type { TextCommand } from '../../../sturctures/command' + +export const command: TextCommand = { + data: { + name: 'meme', + description: "Fetch meme's image.", + directMessageAllowed: true, + cooldownInterval: 6 * 1000, + }, + run: async ({ message }) => { + const embed = new EmbedBuilder() + + const url = 'https://meme-api.herokuapp.com/gimme' + + try { + const response = await axios.get(url) + const responseData: { + url: string + title: string + postLink: string + subreddit: string + } = response.data + + embed + .setTitle(responseData.title) + .setImage(responseData.url) + .setFooter({ + text: `Credit: ${responseData.subreddit} โ€ข ${responseData.postLink}`, + }) + + await message.reply({ + embeds: [embed], + }) + } catch { + embed.setDescription('Error occured when fetching meme content.') + + await message.reply({ + embeds: [embed], + }) + } + }, +} diff --git a/packages/bot/src/commands/message/images/qrcode.ts b/packages/bot/src/commands/message/images/qrcode.ts new file mode 100644 index 0000000..cd0ca0d --- /dev/null +++ b/packages/bot/src/commands/message/images/qrcode.ts @@ -0,0 +1,43 @@ +import { EmbedBuilder } from 'discord.js' + +import type { TextCommand } from '../../../sturctures/command' +import { callbackEmbed } from '../../../utils/messages' + +export const command: TextCommand = { + data: { + name: 'qrcode', + description: 'Generate QR Code by one click.', + directMessageAllowed: true, + cooldownInterval: 5 * 1000, + }, + run: async ({ message, args }) => { + if (message.channel.isVoiceBased()) return + + const embed = new EmbedBuilder() + + const data = args.join(' ') + + if (!data) { + const cEmbed = callbackEmbed({ + text: 'Missing QR Code data!', + color: 'Red', + mode: 'error', + }) + await message.reply({ + embeds: [cEmbed], + }) + return + } + + const url = `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${data.replace( + /\s/g, + '%20' + )}` + + embed.setImage(url) + + await message.channel.send({ + embeds: [embed], + }) + }, +} diff --git a/packages/bot/src/commands/message/images/rip.ts b/packages/bot/src/commands/message/images/rip.ts new file mode 100644 index 0000000..57014d0 --- /dev/null +++ b/packages/bot/src/commands/message/images/rip.ts @@ -0,0 +1,78 @@ +import { createCanvas, loadImage } from 'canvas' +import { AttachmentBuilder } from 'discord.js' + +import type { TextCommand } from '../../../sturctures/command' + +export const command: TextCommand = { + data: { + name: 'rip', + description: 'R I P.', + directMessageAllowed: true, + cooldownInterval: 10 * 1000, + }, + run: async ({ message, args }) => { + const { attachments, author, guild, channel } = message + + if (channel.isVoiceBased()) return + + // Image fetching + let image = attachments.first()?.proxyURL + + for (let index = 0; index < 2; index++) { + if (image) break + + if (index === 1) { + image = author.displayAvatarURL({ + size: 256, + extension: 'png', + forceStatic: true, + }) + break + } + + if (guild && args[0]) { + if (args[0].length >= 18) { + const idMember = guild.members.cache.get(args[0]) + if (idMember) { + image = idMember.user.displayAvatarURL({ + size: 256, + extension: 'png', + forceStatic: true, + }) + } + } else { + const username = String(args[0]).toLowerCase() + const target = guild.members.cache.find((ur) => + ur.user.username.toLowerCase().includes(username) + ) + if (target) { + image = target.user.displayAvatarURL({ + size: 256, + extension: 'png', + forceStatic: true, + }) + } + } + } + } + + if (!image) return + + const targetImage = await loadImage(image) + const background = await loadImage('./assets/rip.jpg') + + const canvas = createCanvas(background.width, background.height) + const context = canvas.getContext('2d') + + context.drawImage(background, 0, 0, canvas.width, canvas.height) + context.drawImage(targetImage, 95, 200, 150, 150) + + const attachment = new AttachmentBuilder(canvas.toBuffer(), { + name: `${Date.now()}_rip.png`, + }) + + await channel.send({ + files: [attachment], + }) + }, +} diff --git a/packages/bot/src/commands/message/images/trash.ts b/packages/bot/src/commands/message/images/trash.ts new file mode 100644 index 0000000..dc6e8f9 --- /dev/null +++ b/packages/bot/src/commands/message/images/trash.ts @@ -0,0 +1,81 @@ +import { createCanvas, loadImage } from 'canvas' +import { AttachmentBuilder } from 'discord.js' + +import type { TextCommand } from '../../../sturctures/command' +import { blur } from '../../../utils/canvas' + +export const command: TextCommand = { + data: { + name: 'trash', + description: 'TRAshh.', + directMessageAllowed: true, + cooldownInterval: 10 * 1000, + }, + run: async ({ message, args }) => { + const { attachments, author, guild, channel } = message + + if (channel.isVoiceBased()) return + + // Image fetching + let image = attachments.first()?.proxyURL + + for (let index = 0; index < 2; index++) { + if (image) break + + if (index === 1) { + image = author.displayAvatarURL({ + size: 256, + extension: 'png', + forceStatic: true, + }) + break + } + + if (guild && args[0]) { + if (args[0].length >= 18) { + const idMember = guild.members.cache.get(args[0]) + if (idMember) { + image = idMember.user.displayAvatarURL({ + size: 256, + extension: 'png', + forceStatic: true, + }) + } + } else { + const username = String(args[0]).toLowerCase() + const target = guild.members.cache.find((ur) => + ur.user.username.toLowerCase().includes(username) + ) + if (target) { + image = target.user.displayAvatarURL({ + size: 256, + extension: 'png', + forceStatic: true, + }) + } + } + } + } + + if (!image) return + + const blurredImg = await blur(image) + + const targetImage = await loadImage(blurredImg) + const background = await loadImage('./assets/trash.png') + + const canvas = createCanvas(background.width, background.height) + const context = canvas.getContext('2d') + + context.drawImage(background, 0, 0) + context.drawImage(targetImage, 309, 0, 309, 309) + + const attachment = new AttachmentBuilder(canvas.toBuffer(), { + name: `${Date.now()}_trash.png`, + }) + + await channel.send({ + files: [attachment], + }) + }, +} diff --git a/packages/bot/src/commands/message/images/trigger.ts b/packages/bot/src/commands/message/images/trigger.ts new file mode 100644 index 0000000..7bb8f0f --- /dev/null +++ b/packages/bot/src/commands/message/images/trigger.ts @@ -0,0 +1,108 @@ +import { createCanvas, loadImage } from 'canvas' +import { AttachmentBuilder } from 'discord.js' +import GIFEncoder from 'gifencoder' + +import type { TextCommand } from '../../../sturctures/command' + +export const command: TextCommand = { + data: { + name: 'trigger', + description: 'TRIGGER~.', + directMessageAllowed: true, + cooldownInterval: 10 * 1000, + }, + run: async ({ message, args }) => { + const { attachments, author, guild, channel } = message + + if (channel.isVoiceBased()) return + + // Image fetching + let image = attachments.first()?.proxyURL + + for (let index = 0; index < 2; index++) { + if (image) break + + if (index === 1) { + image = author.displayAvatarURL({ + size: 256, + extension: 'png', + forceStatic: true, + }) + break + } + + if (guild && args[0]) { + if (args[0].length >= 18) { + const idMember = guild.members.cache.get(args[0]) + if (idMember) { + image = idMember.user.displayAvatarURL({ + size: 256, + extension: 'png', + forceStatic: true, + }) + } + } else { + const username = String(args[0]).toLowerCase() + const target = guild.members.cache.find((ur) => + ur.user.username.toLowerCase().includes(username) + ) + if (target) { + image = target.user.displayAvatarURL({ + size: 256, + extension: 'png', + forceStatic: true, + }) + } + } + } + } + + if (!image) return + + const targetImage = await loadImage(image) + const background = await loadImage('./assets/triggered.png') + + const gif: any = new GIFEncoder(256, 310) + + gif.start() + gif.setRepeat(0) + gif.setDelay(15) + + const canvas = createCanvas(256, 310) + const context = canvas.getContext('2d') + + const BR = 30 + const LR = 20 + + for (let index = 0; index < 9; index++) { + context.clearRect(0, 0, 256, 310) + context.drawImage( + targetImage, + Math.floor(Math.random() * BR) - BR, + Math.floor(Math.random() * BR) - BR, + 256 + BR, + 310 - 54 + BR + ) + context.fillStyle = '#FF000033' + context.fillRect(0, 0, 256, 310) + context.drawImage( + background, + Math.floor(Math.random() * LR) - LR, + 310 - 54 + Math.floor(Math.random() * LR) - LR, + 256 + LR, + 54 + LR + ) + gif.addFrame(context) + } + + gif.finish() + + const attachment = new AttachmentBuilder(gif.out.getData(), { + name: `${Date.now()}_trgigered.gif`, + }) + + await channel.send({ + files: [attachment], + }) + }, +} diff --git a/packages/bot/src/commands/message/owner/eval.ts b/packages/bot/src/commands/message/owner/eval.ts new file mode 100644 index 0000000..33aac56 --- /dev/null +++ b/packages/bot/src/commands/message/owner/eval.ts @@ -0,0 +1,57 @@ +import { inspect } from 'node:util' + +import type { TextCommand } from '../../../sturctures/command' +import { isDev } from '../../../utils/constants' + +function clean(text: string) { + if (typeof text === 'string') { + return text + .replaceAll('`', `\`${String.fromCodePoint(8203)}`) + .replaceAll(/@/g, `@${String.fromCodePoint(8203)}`) + } + return text +} + +export const command: TextCommand = { + enabled: isDev, + data: { + name: 'eval', + publicLevel: 'None', + description: 'Eval javascript code in Nodejs runtime.', + ownerOnly: true, + developmentOnly: true, + directMessageAllowed: true, + requiredArgs: [ + { + type: 'STRING', + rest: true, + }, + ], + }, + run: async ({ message, args }) => { + const { channel } = message + + if (channel.isVoiceBased()) return + + const code = args.join(' ') + + if (code) { + try { + let evaled = eval(code) + if (typeof evaled !== 'string') evaled = inspect(evaled) + if (evaled.length > 1999) return console.log(evaled) + await channel.send({ + content: `\`\`\`${clean(evaled as string)}\`\`\``, + }) + } catch (error) { + if (error instanceof Error && error.message.length < 1999) { + await channel.send({ + content: `\`ERROR\` \`\`\`xl\n${clean( + error.message + )}\n\`\`\``, + }) + } + } + } + }, +} diff --git a/packages/bot/src/commands/message/owner/exec.ts b/packages/bot/src/commands/message/owner/exec.ts new file mode 100644 index 0000000..c29f1ff --- /dev/null +++ b/packages/bot/src/commands/message/owner/exec.ts @@ -0,0 +1,59 @@ +import { exec } from 'node:child_process' + +import type { TextCommand } from '../../../sturctures/command' +import { isDev } from '../../../utils/constants' + +function clean(text: string) { + if (typeof text === 'string') { + return text + .replaceAll('`', `\`${String.fromCodePoint(8203)}`) + .replaceAll(/@/g, `@${String.fromCodePoint(8203)}`) + } + return text +} + +export const command: TextCommand = { + enabled: isDev, + data: { + name: 'exec', + publicLevel: 'None', + ownerOnly: true, + developmentOnly: true, + description: 'Execute shell command in Nodejs runtime.', + directMessageAllowed: true, + requiredArgs: [ + { + type: 'STRING', + rest: true, + }, + ], + }, + run: ({ message, args }) => { + const { channel } = message + if (channel.isVoiceBased()) return + + const code = args.join(' ') + + if (code) { + exec(code, async (error, stdout, stderr) => { + if (error) { + await channel.send({ + content: `\`ERROR\` \`\`\`xl\n${clean( + error.message + )}\n\`\`\``, + }) + return + } + + if (stderr) { + await channel.send({ + content: `\`ERROR\` \`\`\`xl\n${clean(stderr)}\n\`\`\``, + }) + return + } + + await channel.send({ content: `\`\`\`${clean(stdout)}\`\`\`` }) + }) + } + }, +} diff --git a/packages/bot/src/commands/message/utilities/aes.ts b/packages/bot/src/commands/message/utilities/aes.ts new file mode 100644 index 0000000..38a5661 --- /dev/null +++ b/packages/bot/src/commands/message/utilities/aes.ts @@ -0,0 +1,73 @@ +import AES from 'crypto-js/aes' +import { EmbedBuilder } from 'discord.js' + +import type { TextCommand } from '../../../sturctures/command' + +export const command: TextCommand = { + data: { + name: 'aes', + description: 'Encrypt and Decrypt stuff by AES256.', + directMessageAllowed: true, + requiredArgs: [ + { + name: 'mode', + type: 'STRING', + text: ['encrypt', 'decrypt'], + }, + { + name: 'key', + type: 'STRING', + }, + { + name: 'value', + type: 'STRING', + }, + ], + }, + run: async ({ message, args }) => { + if (message.channel.isVoiceBased()) return + + const [mode, key, ...value] = args + const string = value.join(' ') + + const embed = new EmbedBuilder() + + if (message.deletable) message.delete().catch(() => undefined) + + switch (mode) { + case 'encrypt': { + const encryptedValue = AES.encrypt(string, key).toString() + + embed + .setTitle('Encryped Data:') + .setDescription(`\`\`\`${encryptedValue}\`\`\``) + + await message.channel.send({ + embeds: [embed], + }) + break + } + case 'decrypt': { + const decryptedValue = AES.decrypt(string, key).toString() + + embed + .setTitle('Decryped Data:') + .setDescription(`\`\`\`${decryptedValue}\`\`\``) + + await message.channel.send({ + embeds: [embed], + }) + break + } + default: { + embed + .setTitle('Wrong Type!') + .setDescription('`encrypt / decrypt` [key] [value...]') + await message.reply({ + embeds: [embed], + }) + break + } + } + }, +} diff --git a/packages/bot/src/commands/message/utilities/avatar.ts b/packages/bot/src/commands/message/utilities/avatar.ts new file mode 100644 index 0000000..33cb4d7 --- /dev/null +++ b/packages/bot/src/commands/message/utilities/avatar.ts @@ -0,0 +1,53 @@ +import { EmbedBuilder } from 'discord.js' + +import type { TextCommand } from '../../../sturctures/command' + +export const command: TextCommand = { + data: { + name: 'avatar', + aliases: ['av'], + description: "Fetch user's avatar.", + directMessageAllowed: true, + }, + run: async ({ message, args }) => { + const embed = new EmbedBuilder() + + const { guild, mentions, author } = message + + let targetUser = mentions.users.first() + + if (!targetUser) { + if (guild && args[0]) { + if (args[0].length >= 18) { + const idMember = guild.members.cache.get(args[0]) + if (idMember) { + targetUser = idMember.user + } + } else { + const username = String(args[0]).toLowerCase() + const target = guild.members.cache.find((ur) => + ur.user.username.toLowerCase().includes(username) + ) + if (target) targetUser = target.user + } + } else { + targetUser = author + } + } + + if (!targetUser) targetUser = author + + const avatarURL = targetUser.displayAvatarURL({ + size: 4096, + }) + + embed + .setTitle(`Icon from ${targetUser.tag}`) + .setDescription(`Link: [Click Here](${avatarURL})`) + .setImage(avatarURL) + + await message.reply({ + embeds: [embed], + }) + }, +} diff --git a/packages/bot/src/commands/message/utilities/shortenurl.ts b/packages/bot/src/commands/message/utilities/shortenurl.ts new file mode 100644 index 0000000..6168da5 --- /dev/null +++ b/packages/bot/src/commands/message/utilities/shortenurl.ts @@ -0,0 +1,43 @@ +import axios from 'axios' +import { EmbedBuilder } from 'discord.js' + +import type { TextCommand } from '../../../sturctures/command' + +export const command: TextCommand = { + data: { + name: 'shortenurl', + description: "Fetch meme's image.", + directMessageAllowed: true, + cooldownInterval: 6 * 1000, + }, + run: async ({ message, args }) => { + const embed = new EmbedBuilder() + + const destination = args[0] + + try { + const url = `https://is.gd/create.php?format=simple&url=${encodeURI( + destination + )}` + + const response = await axios.get(url) + const responseData: string = response.data + + embed + .setTitle('Converted!') + .setDescription( + `URL: ${responseData}\nDestination: \`${destination}\`` + ) + + await message.reply({ + embeds: [embed], + }) + } catch { + embed.setDescription('Error occured when fetching the content.') + + await message.reply({ + embeds: [embed], + }) + } + }, +} diff --git a/packages/bot/src/commands/message/utilities/weather.ts b/packages/bot/src/commands/message/utilities/weather.ts new file mode 100644 index 0000000..65f0e14 --- /dev/null +++ b/packages/bot/src/commands/message/utilities/weather.ts @@ -0,0 +1,143 @@ +import axios from 'axios' +import { EmbedBuilder } from 'discord.js' +import { parseStringPromise } from 'xml2js' + +import type { TextCommand } from '../../../sturctures/command' + +export const command: TextCommand = { + data: { + name: 'weather', + description: 'Get weather.', + directMessageAllowed: true, + cooldownInterval: 10 * 1000, + }, + run: async ({ message, args }) => { + const embed = new EmbedBuilder() + + const targetLocation = args.join(' ') + + if (!targetLocation) { + return + } + + try { + const url = 'https://weather.service.msn.com/find.aspx' + + const response = await axios.get(url, { + params: { + src: 'outlook', + weadegreetype: 'C', + culture: 'en-US', + weasearchstr: targetLocation, + }, + timeout: 10_000, + }) + + const responseData = response.data as string + + if (!responseData.includes('<')) { + if (responseData.search(/not found/i) !== -1) { + throw new Error('Location not found!') + } + throw new Error('Unknown error!') + } + + const data = await parseStringPromise(responseData) + + interface CurrentWeather { + $: { + temperature: string + skytext: string + date: string + observationtime: string + observationpoint: string + feelslike: string + humidity: string + winddisplay: string + day: string + shortday: string + windspeed: string + } + } + + interface WeatherData { + $: { + weatherlocationname: string + timezone: string + url: string + imagerelativeurl: string + degreetype: string + entityid: string + } + current: CurrentWeather[] + } + + const weatherData: WeatherData = data.weatherdata.weather[0] + + embed + .setTitle( + `${weatherData.$.weatherlocationname}'s Current Weather:` + ) + .setDescription( + `More Information: [HERE](${weatherData.$.url})` + ) + .setFooter({ + text: `ID: ${weatherData.$.entityid}`, + }) + .addFields([ + { + name: 'Date', + value: `${weatherData.current[0].$.date} (${weatherData.current[0].$.day})`, + inline: false, + }, + { + name: 'Time Zone', + value: `UTC${ + weatherData.$.timezone.startsWith('-') + ? weatherData.$.timezone + : '+' + weatherData.$.timezone + }`, + inline: true, + }, + + { + name: 'Status', + value: weatherData.current[0].$.skytext, + inline: true, + }, + { + name: 'Temperature', + value: weatherData.current[0].$.temperature + 'ยฐC', + inline: true, + }, + { + name: 'Feels like', + value: weatherData.current[0].$.feelslike + 'ยฐC', + inline: true, + }, + { + name: 'Humidity', + value: weatherData.current[0].$.humidity, + inline: true, + }, + { + name: 'Windspeed', + value: weatherData.current[0].$.winddisplay, + inline: true, + }, + ]) + + await message.reply({ + embeds: [embed], + }) + } catch (error) { + if (error instanceof Error) { + embed.setTitle(error.message) + + await message.reply({ + embeds: [embed], + }) + } + } + }, +} diff --git a/packages/bot/src/commands/slash/help.ts b/packages/bot/src/commands/slash/help.ts new file mode 100644 index 0000000..7a62899 --- /dev/null +++ b/packages/bot/src/commands/slash/help.ts @@ -0,0 +1,90 @@ +import { SlashCommandBuilder } from '@discordjs/builders' +import type { TextChannel } from 'discord.js' +import { EmbedBuilder } from 'discord.js' + +import { githubLink, name as botname } from '../../../config/bot.json' +import type { SlashCommand, TextCommand } from '../../sturctures/command' +import { getCommandHelpInfo } from '../../utils/cmds' +import { callbackEmbed } from '../../utils/messages' +import { command as helpTextCommand } from '../message/general/help' + +export const command: SlashCommand = { + slashData: new SlashCommandBuilder() + .setName('help') + .setDescription(helpTextCommand.data.description) + .addStringOption((option) => + option + .setName('command') + .setDescription('Get specified Text Command') + .setRequired(false) + ), + run: async ({ interaction }) => { + const embed = new EmbedBuilder() + + const { client, channel, options } = interaction + + const commandInput = options.get('command')?.value + + if (!commandInput || typeof commandInput !== 'string') { + const commandsCatagories = client.commandsCatagories + + embed.setDescription( + `Hello๐Ÿ™‹โ€โ™‚๏ธ!\nOur source code: [Here](${githubLink})\nTurely appreciate that you are supporting us.` + ) + + for (const catagory of commandsCatagories) { + if (catagory[0].toLocaleLowerCase() === 'nsfw') { + if ((channel as TextChannel).nsfw) { + catagory[0] += ' THIS CHANNEL ONLY' + } else { + continue + } + } + const text = catagory[1] + .map((index: string) => `\`${index}\``) + .join(', ') + embed.addFields([{ name: catagory[0], value: text }]) + } + + const avatarURL = client.user.defaultAvatarURL + + embed.setTitle('Bot Assistance Centre').setFooter({ + text: `ยฉ ${new Date().getFullYear()} ${botname}`, + iconURL: avatarURL, + }) + + await interaction.reply({ + embeds: [embed], + }) + return + } + + let cmd: TextCommand | undefined + + const commandMatching = client.commands.get(commandInput) + const aliasesMatching = client.aliases.get(commandInput) + // Fetch command destination. + if (commandMatching) { + cmd = commandMatching + } else if (aliasesMatching) { + cmd = client.commands.get(aliasesMatching) + } else { + const cEmbed = callbackEmbed({ + text: 'Command requested does not exist!', + color: 'Red', + mode: 'error', + }) + await interaction.reply({ + embeds: [cEmbed], + }) + return + } + + if (cmd) { + const helpInfo = getCommandHelpInfo(cmd) + await interaction.reply({ + embeds: [helpInfo], + }) + } + }, +} diff --git a/packages/bot/src/commands/slash/ping.ts b/packages/bot/src/commands/slash/ping.ts new file mode 100644 index 0000000..8868a31 --- /dev/null +++ b/packages/bot/src/commands/slash/ping.ts @@ -0,0 +1,23 @@ +import { SlashCommandBuilder } from '@discordjs/builders' +import { EmbedBuilder } from 'discord.js' + +import type { SlashCommand } from '../../sturctures/command' +import { command as TextCommand } from '../message/general/ping' + +export const command: SlashCommand = { + slashData: new SlashCommandBuilder() + .setName('ping') + .setDescription(TextCommand.data.description), + run: async ({ interaction }) => { + const apiDelayMS = Math.round(interaction.client.ws.ping) + const messageDelayMS = Date.now() - interaction.createdTimestamp + + const embed = new EmbedBuilder().setDescription( + `Action Delay: \`${messageDelayMS}ms\`\nAPI Delay: \`${apiDelayMS}ms\`` + ) + + await interaction.reply({ + embeds: [embed], + }) + }, +} diff --git a/packages/bot/src/events/interaction-create.ts b/packages/bot/src/events/interaction-create.ts new file mode 100644 index 0000000..b64d971 --- /dev/null +++ b/packages/bot/src/events/interaction-create.ts @@ -0,0 +1,79 @@ +import type { CommandInteraction } from 'discord.js' + +import { ownerId } from '../../config/bot.json' +import type { DiscordEvent } from '../sturctures/event' +import { cooldownCache } from '../utils/cache' +import { isDev } from '../utils/constants' +import { parseMsToVisibleText } from '../utils/formatters' + +export const event: DiscordEvent = { + name: 'interactionCreate', + // eslint-disable-next-line + run: async (interaction: CommandInteraction) => { + if (interaction.guild && !interaction.member) { + await interaction.guild.members.fetch(interaction.user.id) + } + + const returnOfInter = async (content: string, ephemeral = true) => { + await interaction.reply({ content, ephemeral }) + } + + if (interaction.isChatInputCommand()) { + const { commandName, user, client } = interaction + + const slashCollection = client.slashcommands + + const slash = slashCollection.get(commandName) + + if (!slashCollection.has(commandName) || !slash) { + return returnOfInter('Error Occured!') + } + + // Eligibility Validations + if (slash.enabled === false) { + return returnOfInter('This command is not enabled to execute.') + } + + if (slash.data?.ownerOnly === true && user.id !== ownerId) { + return returnOfInter('This command is not enabled to execute.') + } + + if (slash.data?.developmentOnly === true && !isDev) { + return returnOfInter( + 'This command is not enabled to execute in current state.' + ) + } + + // Cooldown Validation + const now = Date.now() + const keyName = `CMD_${user.id}_${slash.slashData.name}` + const cooldownInterval = slash.data?.cooldownInterval ?? 3000 + + // Reject if user exists in cooldown. + if (cooldownCache.has(keyName)) { + const expectedEnd = cooldownCache.get(keyName) + if (expectedEnd && now < Number(expectedEnd)) { + const timeleft = parseMsToVisibleText( + Number(expectedEnd) - now + ) + return returnOfInter( + `Before using this command, please wait for **${timeleft}**.` + ) + } + } + + // Set cooldown + cooldownCache.set( + keyName, + now + cooldownInterval, + cooldownInterval / 1000 + ) + + try { + return slash.run({ interaction }) + } catch (error) { + if (error instanceof Error) console.error(error) + } + } + }, +} diff --git a/packages/bot/src/events/message-create.ts b/packages/bot/src/events/message-create.ts new file mode 100644 index 0000000..8560b4a --- /dev/null +++ b/packages/bot/src/events/message-create.ts @@ -0,0 +1,456 @@ +import type { Message, PermissionResolvable, TextChannel } from 'discord.js' + +import { ownerId } from '../../config/bot.json' +import type { TextCommand } from '../sturctures/command' +import type { GuildConfig } from '../sturctures/database' +import type { DiscordEvent } from '../sturctures/event' +import { cooldownCache } from '../utils/cache' +import { getCommandHelpInfo, resembleCommandCheck } from '../utils/cmds' +import { isDev } from '../utils/constants' +import { parseMsToVisibleText } from '../utils/formatters' +import { callbackEmbed } from '../utils/messages' +import MYGuildConfig from '../../config/guild.config' + +async function reject(message: Message, usage: string, missing: string) { + const postMessage = await message.reply({ + content: `Usage: \`${usage}\`\nMissing: \`${missing}\``, + allowedMentions: { repliedUser: true }, + }) + + setTimeout(() => { + if (postMessage.deletable) postMessage.delete().catch(() => undefined) + }, 6000) +} + +export const event: DiscordEvent = { + name: 'messageCreate', + // eslint-disable-next-line + run: async (message: Message) => { + const { content, channel, author, webhookId, member, guild, client } = + message + + if ( + author.bot || + channel.isVoiceBased() || + webhookId || + author.id === client.user.id + ) + return + + const prefix = 'd!' + + if (guild) { + if (!member) await guild.members.fetch(author.id) + } + + const guildDatabase: GuildConfig = MYGuildConfig + + const mentionReg = new RegExp(`^(<@!?${client.user.id}>)`) + const mentionTest = mentionReg.test(content) + if (mentionTest) { + await channel.send(`Hey! My prefix is \`${prefix}\``) + return + } + + const prefixReg = new RegExp(`^${prefix}`) + const prefixTest = content.match(prefixReg) + if (prefixTest) { + const [prefix] = prefixTest + const parsedContent = content.slice(prefix.length) + if (!parsedContent) return + + const command = parsedContent.split(' ')[0] + // Command Content Parsing. + let cmd: TextCommand | undefined + + let cmdName = command + + // Fetch command destination. + for (let index = 0; index < 2; index++) { + const commandMatching = client.commands.get(cmdName) + const aliasesMatching = client.aliases.get(cmdName) + + if (commandMatching) { + cmd = commandMatching + cmdName = cmd.data.name + break + } else if (aliasesMatching) { + cmd = client.commands.get(aliasesMatching) + if (cmd) { + cmdName = cmd.data.name + } + break + } else { + if (index === 0) { + const expectedCommandName = await resembleCommandCheck( + message, + cmdName + ) + if (expectedCommandName) { + cmdName = expectedCommandName.name + message.createdTimestamp += + expectedCommandName.timeTaken + continue + } + } + return + } + } + // Reject if no. + if (!cmd) return + + const cmdData = cmd.data + + /** + * Command's eligibility vaildation. + */ + + if (cmd.enabled === false) return + // Reject if not in development mode + if (cmdData.developmentOnly === true && !isDev) { + return + } + + if (cmdData.ownerOnly === true && author.id !== ownerId) return + + // Reject if dm mode while configurated to guild. + if (!guild && !cmdData.directMessageAllowed) return + + // Reject if command can executed NSFW channel when it's not in. + if ( + cmdData.nsfwChannelRequired && + (!guild || !channel.isTextBased()) && + !(channel as TextChannel).nsfw + ) { + const cEmbed = callbackEmbed({ + text: 'You must be in **NSFW** channel before executing this commmand.', + color: 'Red', + mode: 'error', + }) + author + .send({ + embeds: [cEmbed], + }) + .catch(() => undefined) + return + } + + // Reject if dm mode while configurated to guild. + if (!guild && !cmdData.directMessageAllowed) return + + // Reject when Target disabled or didn't pass. + if (guild) { + const commandDatasbase = guildDatabase?.commands + // GUILD specifies disabled command. + if ( + !commandDatasbase || + commandDatasbase.global.disabled.includes(cmdName) + ) { + return + } + + // Specified CATAGORIES + if ( + cmd.data.catagory && + commandDatasbase.global.disabledCatagories.includes( + cmd.data.catagory + ) + ) { + return + } + + // Specified CHANNEL + if ( + commandDatasbase.channelDisabled + .find((x) => x.id === channel.id) + ?.cmds.includes(cmdName) + ) { + author + .send({ + content: `Command cannot be executed in this channel (#${channel.id})!`, + allowedMentions: { repliedUser: true }, + }) + .catch(() => undefined) + return + } + + // Specified ROLE + if (member?.roles.cache) { + // eslint-disable-next-line no-unsafe-optional-chaining + for (const role of member.roles.cache) { + const hasRole = commandDatasbase.roleDisabled + .find((x) => x.id === role[1].id) + ?.cmds.includes(cmdName) + if (hasRole) return + } + } + + // Specified USER + if ( + commandDatasbase.userDisabled + .find((x) => x.id === author.id) + ?.cmds.includes(cmdName) + ) { + author + .send({ + content: + 'You are disabled from executing this command!', + allowedMentions: { repliedUser: true }, + }) + .catch(() => undefined) + return + } + + if ( + cmd.data.inVoiceChannelRequired === true && + !member?.voice.channel + ) { + const cEmbed = callbackEmbed({ + text: 'You must be in voice channel before executing this commmand.', + color: 'Red', + mode: 'error', + }) + await message.reply({ + embeds: [cEmbed], + }) + return + } + } + + /** + * END of Command's eligibility vaildation. + */ + + // Cooldown Validation + const now = Date.now() + const keyName = `CMD_${author.id}_${cmdName}` + + const cooldownInterval = cmd.data.cooldownInterval ?? 3000 + + // Reject if user exists in cooldown. + if (cooldownCache.has(keyName)) { + const expectedEnd = cooldownCache.get(keyName) + if (expectedEnd && now < Number(expectedEnd)) { + const timeleft = parseMsToVisibleText( + Number(expectedEnd) - now + ) + const postMessage = await message.reply({ + content: `Before using this command, please wait for **${timeleft}**.`, + allowedMentions: { repliedUser: true }, + }) + setTimeout(() => { + if (postMessage.deletable) + postMessage.delete().catch(() => undefined) + }, 6000) + return + } + } + + // Set cooldown. + cooldownCache.set( + keyName, + now + cooldownInterval, + cooldownInterval / 1000 + ) + + // Reject if excess usage. + if (cmd.data.intervalLimit) { + const key1 = 'INTERVAL' + keyName + + let doRejection = { is: false, which: '' } + const customTTL = { + minute: 60 * 1000, + hour: 60 * 60 * 1000, + day: 24 * 60 * 60 * 1000, + } + const intervalList = cmd.data.intervalLimit + for (const [key, ms] of Object.entries(intervalList)) { + if (!ms) continue + const keyTyped = key as keyof typeof intervalList + if (!intervalList[keyTyped]) continue + + const userFeq = cooldownCache.get(keyTyped + key1) ?? '0' + + // Do Rejection if number reached specified amount of allowed cooldown. + if (Number(userFeq) === intervalList[keyTyped]) { + doRejection = { is: true, which: keyTyped } + break + } + + // Set to Database with TTL. + cooldownCache.set( + keyTyped + key1, + (Number(userFeq) + 1).toString(), + customTTL[keyTyped] + ) + } + + if (doRejection.is) { + const postMessage = await message.reply({ + content: `You have reached the maxmium usage in 1 **${doRejection.which}**!`, + allowedMentions: { repliedUser: true }, + }) + + setTimeout(() => { + if (postMessage.deletable) + postMessage.delete().catch(() => undefined) + }, 6000) + + return + } + } + + // Permission Check (BOT) + const requestPermsClient = cmd.data.clientRequiredPermissions + if (guild && requestPermsClient) { + const permMissing: PermissionResolvable[] = [] + for (const perm of requestPermsClient) { + const botId = client.user.id + if (botId) { + const isOwned = guild.members.cache + .get(botId) + ?.permissions.has(perm) + if (!isOwned) permMissing.push(perm) + } + } + + // Reject if BOT doesn't own permission(s). + if (permMissing.length > 0) { + const perms = permMissing + .map((index) => `\`${Number(index)}\``) + .join(', ') + + await message.reply({ + content: `I don't have **PERMISSIONS**: ${perms}`, + }) + + return + } + } + + // Permission Check (AUTHOR) + const requestPermsAuthor = cmd.data.authorRequiredPermissions + if (guild && requestPermsAuthor) { + const permMissing: PermissionResolvable[] = [] + for (const perm of requestPermsAuthor) { + const isOwned = member?.permissions.has(perm) + if (!isOwned) permMissing.push(perm) + } + + // Reject if AUTHOR doesn't own permission(s). + if (permMissing.length > 0) { + const perms = permMissing + .map((index) => `\`${Number(index)}\``) + .join(', ') + + await message.reply({ + content: `You do not have required **PERMISSIONS**: ${perms}`, + }) + + return + } + } + + // Pass + console.log( + `[CMD] ${author.tag} executed ${cmdName} in ${ + guild?.name ?? 'DM' + }.` + ) + const arguments_ = parsedContent.split(' ').slice(1) + + if (arguments_[0] === 'help') { + if ( + cmd.data.nsfwChannelRequired === true && + (channel as TextChannel).nsfw + ) { + const helpInfo = getCommandHelpInfo(cmd) + await message.reply({ + embeds: [helpInfo], + }) + } + return + } + + // Arguments Checking + const requiredArugments = cmd.data.requiredArgs + if (requiredArugments && requiredArugments.length > 0) { + let usage = `${prefix}${cmd.data.name}` + for (const _argument_ of requiredArugments) { + let namedArguments: string = _argument_.type + if (_argument_.type === 'STRING' && _argument_.text) { + namedArguments = _argument_.text.join(' | ') + } else if (_argument_.name) { + namedArguments += `(${_argument_.name})` + } + usage += ` [${namedArguments}]` + } + + for ( + let index = 0, l = requiredArugments.length; + index < l; + index++ + ) { + const _argument = requiredArugments[index] + const userArgument = arguments_[index] + + switch (_argument.type) { + case 'STRING': { + if (_argument.required) { + if ( + !userArgument || + userArgument.length === 0 + ) { + return reject( + message, + usage, + index.toString() + ) + } + if ( + _argument.text && + !_argument.text.includes(userArgument) + ) { + const text = `${index.toString()} (NOT_MATCH)` + return reject(message, usage, text) + } + if ( + _argument.customLength && + (content.length > + _argument.customLength.max! || + content.length < + _argument.customLength.min!) + ) { + const text = `${index.toString()} (ERR_Length)` + return reject(message, usage, text) + } + } + break + } + case 'NUMBER': { + if ( + _argument.required && + Number.isNaN(Number(userArgument)) + ) { + return reject(message, usage, index.toString()) + } + break + } + default: { + break + } + } + + if (_argument.rest) break + } + } + + // Run the actual command. + try { + return cmd.run({ message, args: arguments_ }) + } catch (error) { + if (error instanceof Error) console.error(error) + } + } + }, +} diff --git a/packages/bot/src/events/ready.ts b/packages/bot/src/events/ready.ts new file mode 100644 index 0000000..c52a15e --- /dev/null +++ b/packages/bot/src/events/ready.ts @@ -0,0 +1,26 @@ +import type { Client } from 'discord.js' +import { ActivityType } from 'discord.js' + +import { defaultPrefix } from '../../config/bot.json' +import type { DiscordEvent } from '../sturctures/event' + +export const event: DiscordEvent = { + once: true, + name: 'ready', + run: (client: Client) => { + // Dynamic Status + let index = 0 + + const status = [`${client.guilds.cache.size} Servers`] + + setInterval(() => { + const _status = status[index++ % status.length] + const text = `${defaultPrefix}help | ${_status}` + client.user?.setActivity(text, { + type: ActivityType.Watching, + }) + }, 15 * 1000) + + console.log('Bot is in ready status!') + }, +} diff --git a/packages/bot/src/index.ts b/packages/bot/src/index.ts new file mode 100644 index 0000000..cb3474b --- /dev/null +++ b/packages/bot/src/index.ts @@ -0,0 +1,34 @@ +import 'dotenv/config' + +import chalk from 'chalk' + +import './validate-env' + +import { isDev } from './utils/constants' + +// Check NODE Version +const nodeVersions = process.versions.node.split('.') +if (Number(nodeVersions[0]) <= 16 && Number(nodeVersions[1]) < 9) { + throw new Error( + 'Node.js version must be 16.9.0 higher. Please update your Node.js version.' + ) +} + +process.setMaxListeners(15) + +// If instacne is not production mode. +if (isDev) { + const log = console.log + + log(chalk.bold.red('DEVELOPMENT / MAINTAINANCE MODE')) + log( + chalk.red.bold( + 'Some production features will be disrupted or terminated.' + ) + ) +} else { + process.on('uncaughtException', console.error) + process.on('unhandledRejection', console.error) +} + +import('./bot') diff --git a/packages/bot/src/loaders/command.ts b/packages/bot/src/loaders/command.ts new file mode 100644 index 0000000..5f14268 --- /dev/null +++ b/packages/bot/src/loaders/command.ts @@ -0,0 +1,167 @@ +import { basename, dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { REST } from '@discordjs/rest' +import chalk from 'chalk' +import type { Client } from 'discord.js' +import type { + RESTGetAPIApplicationCommandsResult, + RESTPostAPIApplicationCommandsJSONBody, +} from 'discord-api-types/v9' +import { Routes } from 'discord-api-types/v9' +import { glob } from 'glob' + +import { disabledCommandCatagories } from '../../config/bot.json' +import type { SlashCommand, TextCommand } from '../sturctures/command' +import { isDev } from '../utils/constants' + +type TextCommandCatagories = Record + +const __dirname = dirname(fileURLToPath(import.meta.url)) + +/** Text Command Loaders */ +export async function loadTextCommand(client: Client) { + let folderPath = join(__dirname, '../commands/message/**/*.ts') + + // Parse path in windows + if (process.platform === 'win32') { + folderPath = folderPath.replaceAll('\\', '/') + } + + const allFiles = await glob(folderPath) + + if (allFiles.length === 0) { + return + } + + const catagories: TextCommandCatagories = {} + + for (const filePath of allFiles) { + const commandFile = await import(filePath) + const command: TextCommand = commandFile.command + + // Neglect if disabled. + if (command.enabled === false) continue + + // Store command to memory. + const cmdName = command.data.name + if (client.commands.has(cmdName)) { + throw new Error('Duplicated command is found!') + } + + const catagory = basename(dirname(filePath)) + + const disabledCatagories: string[] = disabledCommandCatagories + + if (!disabledCatagories.includes(catagory)) { + if (catagory) { + command.data.catagory = catagory + if (command.data.publicLevel !== 'None') { + if (!catagories[String(catagory)]) { + catagories[String(catagory)] = [] + } + catagories[String(catagory)].push(cmdName) + } + } + + if (command.data.intervalLimit) { + const list = command.data.intervalLimit + if (list.minute! > list.hour! || list.hour! > list.day!) { + throw 'Impolitic Custom Interval style!' + } + } + + client.commands.set(cmdName, command) + + if (command.data.aliases) { + for (const alias of command.data.aliases) { + if (client.aliases.has(alias)) { + throw new Error('Duplicated alias is found!') + } + // Store aliase(s) to memory if exists. + client.aliases.set(alias, command.data.name) + } + } + } + } + + for (const value of Object.entries(catagories)) { + client.commandsCatagories.set(value[0], value[1]) + } + + // Print number of loaded commands. + console.log( + chalk.greenBright.bold(`Loaded ${client.commands.size} text commands.`) + ) +} + +/** Load Slash commands to API & Collection */ +export async function loadSlashCommand( + client: Client, + clientId: string, + token: string +) { + let folderPath = join(__dirname, '../commands/slash/**/*.ts') + + // Parse path in windows + if (process.platform === 'win32') { + folderPath = folderPath.replaceAll('\\', '/') + } + + const allFiles = await glob(folderPath) + + const slashCommandData: RESTPostAPIApplicationCommandsJSONBody[] = [] + + for (const filePath of allFiles) { + const commandFile = await import(filePath) + const slashCommand: SlashCommand = commandFile.command + + const slashCommandCollection = client.slashcommands + const name = slashCommand.slashData.name + + if (slashCommandCollection.has(name)) { + throw new Error('Duplicated slash command is found!') + } + + client.slashcommands.set(name, slashCommand) + + slashCommandData.push(slashCommand.slashData.toJSON()) + } + + const rest = new REST({ version: '9' }).setToken(token) + + if (isDev) { + // Guild & Development Commands. + const guildId = process.env.DEV_GUILD_ID + + if (guildId) { + const guildCommands = await rest.get( + Routes.applicationGuildCommands(clientId, guildId) + ) + + for (const command of guildCommands as RESTGetAPIApplicationCommandsResult) { + const deleteUrl = `${Routes.applicationGuildCommands( + clientId, + guildId + )}/${command.id}` + await rest.delete(`/${deleteUrl}`) + } + + await rest.put(Routes.applicationGuildCommands(clientId, guildId), { + body: slashCommandData, + }) + } + } else { + // Global Commands + await rest.put(Routes.applicationCommands(clientId), { + body: slashCommandData, + }) + } + + // Print number of loaded commands. + console.log( + chalk.greenBright.bold( + `Loaded ${client.slashcommands.size} slash commands.` + ) + ) +} diff --git a/packages/bot/src/loaders/event.ts b/packages/bot/src/loaders/event.ts new file mode 100644 index 0000000..29cbecf --- /dev/null +++ b/packages/bot/src/loaders/event.ts @@ -0,0 +1,45 @@ +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +import chalk from 'chalk' +import type { Client } from 'discord.js' +import { glob } from 'glob' + +import type { DiscordEvent } from '../sturctures/event' + +const __dirname = dirname(fileURLToPath(import.meta.url)) + +export async function loadDiscordEvent(client: Client) { + let folderPath = join(__dirname, '../events/**/*.ts') + + // Parse path in windows + if (process.platform === 'win32') { + folderPath = folderPath.replaceAll('\\', '/') + } + + const allFiles = await glob(folderPath) + + if (allFiles.length === 0) { + return + } + + for (const filePath of allFiles) { + // Get event content. + const eventFile = await import(filePath) + const event: DiscordEvent = eventFile.event + + // Check triggering mode. + if (event.once === true) { + // eslint-disable-next-line + client.once(event.name, event.run.bind(undefined)) + } else { + // eslint-disable-next-line + client.on(event.name, event.run.bind(undefined)) + } + } + + // Print number of loaded events. + console.log( + chalk.greenBright.bold(`Loaded ${allFiles.length} Discord events.`) + ) +} diff --git a/packages/bot/src/sturctures/command.ts b/packages/bot/src/sturctures/command.ts new file mode 100644 index 0000000..1fcecd5 --- /dev/null +++ b/packages/bot/src/sturctures/command.ts @@ -0,0 +1,93 @@ +import type { SlashCommandBuilder } from '@discordjs/builders' +import type { + CommandInteraction, + Message, + PermissionResolvable, +} from 'discord.js' + +interface TextCommandExecution { + message: Message + args: string[] +} + +interface SlashCommandExecution { + interaction: CommandInteraction +} + +interface TextCommandRequiredArgumentsDefault { + name?: string + rest?: boolean + text?: string[] + type: 'NUMBER' | 'STRING' + customLength?: { + min?: number + max?: number + } + required?: boolean +} + +type TextCommandRequiredArguments = TextCommandRequiredArgumentsDefault + +/** + * As default, command can only be accessed in guild. + * + * Everyone can access wihtout any permission limitations. + * + * Cooldown Interval is 3 seconds (3000 milliseconds) + */ +export interface TextCommand { + enabled?: boolean + + // Command Data + readonly data: { + // Permissions + clientRequiredPermissions?: PermissionResolvable[] + authorRequiredPermissions?: PermissionResolvable[] + + // Access, Environment & Scenes + ownerOnly?: boolean + developmentOnly?: boolean + nsfwChannelRequired?: boolean + inVoiceChannelRequired?: boolean + threadChannelAllowed?: boolean + directMessageAllowed?: boolean + publicLevel?: 'All' | 'Permission' | 'None' + requiredArgs?: TextCommandRequiredArguments[] + + // Info + name: string + description: string + catagory?: string + usage?: string + aliases?: string[] + + // Specified Configurations + cooldownInterval?: number + intervalLimit?: { + minute?: number + hour?: number + day?: number + } + } + // eslint-disable-next-line no-unused-vars + run: ({ message, args }: TextCommandExecution) => Promise | void +} + +export interface SlashCommand { + enabled?: boolean + + // Slash Data + slashData: Omit + + readonly data?: { + clientRequiredPermissions?: PermissionResolvable[] + + ownerOnly?: boolean + developmentOnly?: boolean + + cooldownInterval?: number + } + + // eslint-disable-next-line no-unused-vars + run: ({ interaction }: SlashCommandExecution) => Promise +} diff --git a/packages/bot/src/sturctures/database.ts b/packages/bot/src/sturctures/database.ts new file mode 100644 index 0000000..e1496c7 --- /dev/null +++ b/packages/bot/src/sturctures/database.ts @@ -0,0 +1,67 @@ +export interface GuildConfig { + prefix: string + serverId: string + commands: { + global: { + // Access + disabled: string[] + disabledCatagories: string[] + // Cooldown + customCooldown: { + name: string + value: number + intervals: { + minute: number + hour: number + day: number + } + }[] + } + // Access + userDisabled: { id: string; cmds: string[] }[] + roleDisabled: { id: string; cmds: string[] }[] + channelDisabled: { id: string; cmds: string[] }[] + } + antiSpam: { + enabled: boolean + whitelistedUsers: string[] + whitelistedRoles: string[] + whitelistedChannels: string[] + inviteLinks: { + enabled: boolean + whitelistedUsers: string[] + whitelistedRoles: string[] + whitelistedChannels: string[] + } + mentions: { + enabled: boolean + maxmiumCheck: { + enabled: boolean + value: number + } + publicRoleCheck: boolean + whitelistedUsers: string[] + whitelistedRoles: string[] + whitelistedChannels: string[] + } + } + thread: { + listen?: boolean + } + snipe: { + channelDisabled: string[] + } +} + +export interface SnipeConfig { + channelId: string + author: { + id: string + name: string + } + content: { + text: string + date: number + imageURL: string | undefined + } +} diff --git a/packages/bot/src/sturctures/event.ts b/packages/bot/src/sturctures/event.ts new file mode 100644 index 0000000..cd73d54 --- /dev/null +++ b/packages/bot/src/sturctures/event.ts @@ -0,0 +1,10 @@ +import type { ClientEvents } from 'discord.js' + +/** Discord Client events */ +export interface DiscordEvent { + // Event Data + name: keyof ClientEvents + once?: boolean + // eslint-disable-next-line no-unused-vars + run: (...arguments_: any[]) => Promise | void +} diff --git a/packages/bot/src/utils/cache.ts b/packages/bot/src/utils/cache.ts new file mode 100644 index 0000000..99b752f --- /dev/null +++ b/packages/bot/src/utils/cache.ts @@ -0,0 +1,5 @@ +import NodeCache from 'node-cache' + +const cooldownCache = new NodeCache() + +export { cooldownCache } diff --git a/packages/bot/src/utils/canvas.ts b/packages/bot/src/utils/canvas.ts new file mode 100644 index 0000000..e50fd6d --- /dev/null +++ b/packages/bot/src/utils/canvas.ts @@ -0,0 +1,28 @@ +import { createCanvas, loadImage } from 'canvas' + +async function blur(image: string | Buffer): Promise { + if (!image) throw new Error('Image was not provided!') + const img = await loadImage(image) + const canvas = createCanvas(img.width, img.height) + const context = canvas.getContext('2d') + + context.fillStyle = '#ffffff' + context.fillRect(0, 0, canvas.width, canvas.height) + context.drawImage(img, 0, 0, canvas.width / 4, canvas.height / 4) + context.imageSmoothingEnabled = true + context.drawImage( + canvas, + 0, + 0, + canvas.width / 4, + canvas.height / 4, + 0, + 0, + canvas.width + 5, + canvas.height + 5 + ) + + return canvas.toBuffer() +} + +export { blur } diff --git a/packages/bot/src/utils/cmds.ts b/packages/bot/src/utils/cmds.ts new file mode 100644 index 0000000..3e52833 --- /dev/null +++ b/packages/bot/src/utils/cmds.ts @@ -0,0 +1,127 @@ +import type { + CollectorFilter, + Message, + MessageComponentInteraction, +} from 'discord.js' +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + EmbedBuilder, +} from 'discord.js' +import { findBestMatch } from 'string-similarity' + +import type { TextCommand } from '../sturctures/command' + +/** Send command information message embed. */ +export function getCommandHelpInfo(cmd: TextCommand): EmbedBuilder { + const embed = new EmbedBuilder() + .setTitle(`Command: ${cmd.data.name}`) + .addFields([{ name: 'Description', value: cmd.data.description }]) + + if (cmd.data.usage) { + embed.addFields([{ name: 'Usage', value: cmd.data.usage }]) + } + + embed.addFields([ + { name: 'Catagory', value: cmd.data.catagory ?? '', inline: true }, + { + name: 'Cooldown', + value: `${ + (cmd.data.cooldownInterval ?? 3000) / 1000 || '3' + } seconds`, + inline: true, + }, + ]) + + if (cmd.data.intervalLimit) { + embed.addFields([ + { + name: 'Allowed Intervals', + value: Object.entries(cmd.data.intervalLimit) + .map((value) => { + return `${value[0]} - \`${value[1]}\`` + }) + .join('\n'), + inline: false, + }, + ]) + } + + return embed +} + +interface ExpectedWord { + name: string + timeTaken: number +} + +/** Similarity check of excuting commands. */ +export async function resembleCommandCheck( + message: Message, + word: string +): Promise { + const timeStarted = Date.now() + + const cmdList = [...message.client.commands.keys()] + + const { bestMatch } = findBestMatch(word, cmdList) + + if (bestMatch.rating < 0.65) return undefined + + const acceptButtonId = 'accCMD' + const declineButtonId = 'denCMD' + + const acceptButton = new ButtonBuilder() + .setStyle(ButtonStyle.Success) + .setEmoji('๐Ÿ‘') + .setCustomId(acceptButtonId) + const declineButton = new ButtonBuilder() + .setStyle(ButtonStyle.Danger) + .setEmoji('๐Ÿ‘Ž') + .setCustomId(declineButtonId) + + const row: ActionRowBuilder = new ActionRowBuilder().addComponents([ + acceptButton, + declineButton, + ]) + + const matchRate = Number(bestMatch.rating.toFixed(2)) + + const _message = await message.reply({ + content: `Do you prefer excuting \`${ + bestMatch.target + }\`? (Similarity: \`${matchRate * 100}%\`)`, + components: [row], + }) + + const filter: CollectorFilter<[MessageComponentInteraction]> = (inter) => { + return ( + [acceptButtonId, declineButtonId].includes(inter.customId) && + inter.user.id === message.author.id + ) + } + + return new Promise((resolve) => { + return _message + .awaitMessageComponent({ + filter, + time: 30_000, + }) + .then(async (_inter) => { + // Delete message first + if (_message.deletable) { + await _message.delete().catch(() => undefined) + } + if (_inter.customId === acceptButtonId) { + const timeTaken = timeStarted - Date.now() + return resolve({ name: bestMatch.target, timeTaken }) + } + throw new Error('Declined') + }) + .catch(() => { + // eslint-disable-next-line + return resolve(undefined) + }) + }) +} diff --git a/packages/bot/src/utils/constants.ts b/packages/bot/src/utils/constants.ts new file mode 100644 index 0000000..0a346c5 --- /dev/null +++ b/packages/bot/src/utils/constants.ts @@ -0,0 +1 @@ +export const isDev = process.env.NODE_ENV === 'development' diff --git a/packages/bot/src/utils/database.ts b/packages/bot/src/utils/database.ts new file mode 100644 index 0000000..f3800ef --- /dev/null +++ b/packages/bot/src/utils/database.ts @@ -0,0 +1,17 @@ +import { GraphQLClient } from 'graphql-request' + +export let client = new GraphQLClient('') + +export const hgqlInit = () => { + console.log('\n๐Ÿš€ GraphQL Client Initialized') + + let HASURA_URL: string = process.env.HASURA_GRAPHQL_ENDPOINT || '' + HASURA_URL += HASURA_URL.endsWith('/') ? 'v1/graphql' : '/v1/graphql' + const HASURA_ADMIN: string = process.env.HASURA_GRAPHQL_ADMIN_SECRET || '' + + client = new GraphQLClient(HASURA_URL, { + headers: { + 'x-hasura-admin-secret': HASURA_ADMIN, + }, + }) +} diff --git a/packages/bot/src/utils/formatters.ts b/packages/bot/src/utils/formatters.ts new file mode 100644 index 0000000..9a1c6e0 --- /dev/null +++ b/packages/bot/src/utils/formatters.ts @@ -0,0 +1,16 @@ +/** + * Transfer milliseconds to visible duration + * + * 1000 -> 0h 0m 1s + * */ +export function parseMsToVisibleText(ms: number): string { + if (ms < 1000) return 'less than 1s' + let seconds: number | string = Math.trunc((ms / 1000) % 60) + let minutes: number | string = Math.trunc((ms / (1000 * 60)) % 60) + let hours: number | string = Math.trunc((ms / (1000 * 60 * 60)) % 24) + seconds = seconds > 0 ? `${seconds}s` : '' + minutes = minutes > 0 ? `${minutes}m ` : '' + hours = hours > 0 ? `${hours}h ` : '' + + return `${hours}${minutes}${seconds}` +} diff --git a/packages/bot/src/utils/messages.ts b/packages/bot/src/utils/messages.ts new file mode 100644 index 0000000..41560cd --- /dev/null +++ b/packages/bot/src/utils/messages.ts @@ -0,0 +1,93 @@ +import type { + CollectorFilter, + ColorResolvable, + EmbedField, + Message, + MessageComponentInteraction, +} from 'discord.js' +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + EmbedBuilder, +} from 'discord.js' + +import emoji from '../../config/emojis.json' + +interface ConfirmInformationButtons { + title: string + message: Message + fields: EmbedField[] +} + +export async function confirmInformationButtons({ + title, + fields, + message, +}: ConfirmInformationButtons): Promise { + if (message.channel.isVoiceBased()) return false + + const now = Date.now() + const embed = new EmbedBuilder().setTitle(title).addFields(fields) + + const confirmId = `CONFIRM_${now}` + const cancelId = `CANCEL_${now}` + + const buttonSuccess = new ButtonBuilder() + .setStyle(ButtonStyle.Success) + .setLabel('Confirm') + .setCustomId(confirmId) + const buttonCancel = new ButtonBuilder() + .setStyle(ButtonStyle.Danger) + .setLabel('Cancel') + .setCustomId(cancelId) + + const row: ActionRowBuilder = new ActionRowBuilder().addComponents([ + buttonSuccess, + buttonCancel, + ]) + + const respondAwaiting = await message.channel.send({ + embeds: [embed], + components: [row], + }) + + const filter: CollectorFilter<[MessageComponentInteraction]> = (inter) => { + return ( + [confirmId, cancelId].includes(inter.customId) && + inter.user.id === message.author.id + ) + } + + const interaction = await respondAwaiting.awaitMessageComponent({ + filter, + time: 30_000, + }) + + if (respondAwaiting.deletable) + await respondAwaiting.delete().catch(() => undefined) + + return interaction.customId === confirmId +} + +interface CallbackEmbed { + text: string + color?: ColorResolvable + mode?: 'error' | 'success' | 'warning' +} + +export function callbackEmbed({ + text, + color = 'Grey', + mode, +}: CallbackEmbed): EmbedBuilder { + let emojiText = '' + + if (mode && typeof mode === 'string') { + emojiText = emoji[mode] + } + + return new EmbedBuilder() + .setDescription(`${emojiText} ${text}`) + .setColor(color) +} diff --git a/packages/bot/src/validate-env.ts b/packages/bot/src/validate-env.ts new file mode 100644 index 0000000..9be29aa --- /dev/null +++ b/packages/bot/src/validate-env.ts @@ -0,0 +1,22 @@ +import { z } from 'zod' + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace NodeJS { + // eslint-disable-next-line + interface ProcessEnv extends z.infer {} + } +} + +const ZodEnvironmentVariables = z.object({ + TOKEN: z.string(), + CLIENT_ID: z.string(), + DEV_GUILD_ID: z.string().optional(), + PORT: z.string().optional(), + HASURA_GRAPHQL_ADMIN_SECRET: z.string(), + HASURA_GRAPHQL_ENDPOINT: z.string(), +}) + +ZodEnvironmentVariables.parse(process.env) + +console.log('โœ… Environment variables verified!') diff --git a/packages/bot/tsconfig.json b/packages/bot/tsconfig.json new file mode 100644 index 0000000..a434882 --- /dev/null +++ b/packages/bot/tsconfig.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "allowJs": true, + "allowSyntheticDefaultImports": true, + "allowUnreachableCode": false, + "allowUnusedLabels": false, + "alwaysStrict": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + + "isolatedModules": true, + "module": "esnext", + "moduleResolution": "node", + + "noEmit": true, + "noFallthroughCasesInSwitch": true, + "noImplicitAny": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noImplicitThis": true, + "noUncheckedIndexedAccess": false, + + "noUnusedLocals": true, + "noUnusedParameters": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "strictFunctionTypes": true, + "strictNullChecks": true, + "strictPropertyInitialization": true, + "target": "esnext" + }, + "display": "Default tsconfig.json", + "exclude": ["node_modules"] +}