diff --git a/.env.example b/.env.example index 44b071b..e2591d0 100644 --- a/.env.example +++ b/.env.example @@ -7,7 +7,7 @@ # any secret values. BOT_TOKEN= -BOT_VER=3.2.3 +BOT_VER=3.2.4 BOT_AUTHOR=Vylpes BOT_OWNERID=147392775707426816 BOT_CLIENTID=682942374040961060 @@ -23,4 +23,6 @@ DB_NAME=vylbot DB_AUTH_USER=dev DB_AUTH_PASS=dev DB_SYNC=true -DB_LOGGING=true \ No newline at end of file +DB_LOGGING=true +DB_DATA_LOCATION=./.temp/database +DB_ROOT_HOST=0.0.0.0 \ No newline at end of file diff --git a/database/3.2.4/1732973911304-createAutoKickConfig/Down/01-AutoKickConfig.sql b/database/3.2.4/1732973911304-createAutoKickConfig/Down/01-AutoKickConfig.sql new file mode 100644 index 0000000..8412a02 --- /dev/null +++ b/database/3.2.4/1732973911304-createAutoKickConfig/Down/01-AutoKickConfig.sql @@ -0,0 +1 @@ +DROP TABLE auto_kick_config; diff --git a/database/3.2.4/1732973911304-createAutoKickConfig/Up/01-AutoKickConfig-Table.sql b/database/3.2.4/1732973911304-createAutoKickConfig/Up/01-AutoKickConfig-Table.sql new file mode 100644 index 0000000..e7d377a --- /dev/null +++ b/database/3.2.4/1732973911304-createAutoKickConfig/Up/01-AutoKickConfig-Table.sql @@ -0,0 +1,10 @@ +CREATE TABLE auto_kick_config ( + Id varchar(255) NOT NULL, + WhenCreated datetime NOT NULL, + WhenUpdated datetime NOT NULL, + ServerId varchar(255) NOT NULL, + RoleId varchar(255) NOT NULL, + KickTime int NOT NULL, + NoticeTime int NULL, + NoticeChannelId varchar(255) NULL +); diff --git a/database/3.2.4/1732973911304-createAutoKickConfig/Up/02-AutoKickConfig-Key.sql b/database/3.2.4/1732973911304-createAutoKickConfig/Up/02-AutoKickConfig-Key.sql new file mode 100644 index 0000000..e3fec43 --- /dev/null +++ b/database/3.2.4/1732973911304-createAutoKickConfig/Up/02-AutoKickConfig-Key.sql @@ -0,0 +1,2 @@ +ALTER TABLE auto_kick_config + ADD PRIMARY KEY (Id); diff --git a/package.json b/package.json index c5af404..27b06a3 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@discordjs/rest": "^2.0.0", "@types/jest": "^29.0.0", "@types/uuid": "^9.0.0", + "cron": "^3.3.1", "discord.js": "^14.3.0", "dotenv": "^16.0.0", "emoji-regex": "^10.0.0", diff --git a/src/client/client.ts b/src/client/client.ts index c1ce5da..86b34bd 100644 --- a/src/client/client.ts +++ b/src/client/client.ts @@ -1,6 +1,5 @@ import { Client, Partials } from "discord.js"; import * as dotenv from "dotenv"; -import { createConnection } from "typeorm"; import { EventType } from "../constants/EventType"; import ICommandItem from "../contracts/ICommandItem"; import IEventItem from "../contracts/IEventItem"; @@ -12,14 +11,18 @@ import AppDataSource from "../database/dataSources/appDataSource"; import ButtonEventItem from "../contracts/ButtonEventItem"; import { ButtonEvent } from "../type/buttonEvent"; import CacheHelper from "../helpers/CacheHelper"; +import TimerHelper from "../helpers/TimerHelper"; +import AutoKick from "../timers/AutoKick"; export class CoreClient extends Client { private static _commandItems: ICommandItem[]; private static _eventItems: IEventItem[]; private static _buttonEvents: ButtonEventItem[]; + private static _baseClient: Client; private _events: Events; private _util: Util; + private _timerHelper: TimerHelper; public static get commandItems(): ICommandItem[] { return this._commandItems; @@ -33,6 +36,10 @@ export class CoreClient extends Client { return this._buttonEvents; } + public static get baseClient(): Client { + return this._baseClient; + } + constructor(intents: number[], partials: Partials[]) { super({ intents: intents, partials: partials }); dotenv.config(); @@ -43,6 +50,7 @@ export class CoreClient extends Client { this._events = new Events(); this._util = new Util(); + this._timerHelper = new TimerHelper(); } public async start() { @@ -51,8 +59,16 @@ export class CoreClient extends Client { return; } + CoreClient._baseClient = this; + await AppDataSource.initialize() - .then(() => console.log("Data Source Initialized")) + .then(() => { + console.log("Data Source Initialized"); + + this._timerHelper.AddTimer("0 * * * *", "Europe/London", AutoKick, false); + + this._timerHelper.StartAllTimers(); + }) .catch((err) => console.error("Error Initialising Data Source", err)); super.on("interactionCreate", this._events.onInteractionCreate); diff --git a/src/commands/autokick.ts b/src/commands/autokick.ts new file mode 100644 index 0000000..760441e --- /dev/null +++ b/src/commands/autokick.ts @@ -0,0 +1,113 @@ +import {ChatInputCommandInteraction, CommandInteraction, EmbedBuilder, PermissionFlagsBits, SlashCommandBuilder} from "discord.js"; +import {Command} from "../type/command"; +import TimeLengthInput from "../helpers/TimeLengthInput"; +import AutoKickHelper from "../helpers/AutoKickHelper"; + +export default class Autokick extends Command { + constructor() { + super(); + + this.CommandBuilder = new SlashCommandBuilder() + .setName("autokick") + .setDescription("Configure the auto kick functionality") + .setDefaultMemberPermissions(PermissionFlagsBits.KickMembers) + .addSubcommand(x => x + .setName("set") + .setDescription("Set the configuration") + .addRoleOption(y => y + .setName("role") + .setDescription("The role the user needs to be auto kicked") + .setRequired(true)) + .addStringOption(y => y + .setName("kicktime") + .setDescription("The time with the role before being kicked (Ex: 2h 30m)") + .setRequired(true)) + .addStringOption(y => y + .setName("noticetime") + .setDescription("The time before being kicked when a notification is sent (Ex: 2h 30m)")) + .addChannelOption(y => y + .setName("noticechannel") + .setDescription("The channel to send the notification to"))) + .addSubcommand(x => x + .setName("unset") + .setDescription("Unset the current configuration")); + } + + public override async execute(interaction: CommandInteraction) { + if (!interaction.isChatInputCommand()) return; + + const subcommand = interaction.options.getSubcommand(); + + switch (subcommand) { + case "set": + await this.set(interaction); + break; + case "unset": + await this.unset(interaction); + break; + } + } + + private async set(interaction: ChatInputCommandInteraction) { + if (!interaction.guildId) return; + + const roleOption = interaction.options.getRole("role", true); + const kickTimeOption = interaction.options.getString("kicktime", true); + const noticeTimeOption = interaction.options.getString("noticetime"); + const noticeChannelOption = interaction.options.getChannel("noticechannel"); + + const roleId = roleOption.id; + const kickTimeInput = new TimeLengthInput(kickTimeOption); + const noticeTimeInput = noticeTimeOption ? new TimeLengthInput(noticeTimeOption) : undefined; + const noticeChannelId = noticeChannelOption?.id; + + if ((noticeTimeInput && !noticeTimeOption) || (!noticeTimeInput && noticeChannelOption)) { + await interaction.reply("Both `noticetime` and `noticechannel` must be set if you want a notification embed"); + return; + } + + await AutoKickHelper.SetSetting(interaction.guildId, roleId, kickTimeInput.GetMilliseconds(), noticeTimeInput?.GetMilliseconds(), noticeChannelId); + + const embed = new EmbedBuilder() + .setTitle("Auto Kick") + .setDescription("Configured auto kick for this server") + .addFields([ + { + name: "Role", + value: roleOption.name, + inline: true, + }, + { + name: "Kick Time", + value: kickTimeInput.GetLengthShort(), + inline: true, + }, + ]); + + if (noticeTimeInput) { + embed.addFields([ + { + name: "Notice Time", + value: noticeTimeInput.GetLengthShort(), + }, + { + name: "Notice Channel", + value: noticeChannelOption!.name!, + inline: true, + }, + ]); + } + + await interaction.reply({ + embeds: [ embed ], + }); + } + + private async unset(interaction: ChatInputCommandInteraction) { + if (!interaction.guildId) return; + + await AutoKickHelper.UnsetSetting(interaction.guildId); + + await interaction.reply("Unset the auto kick configuration for this server"); + } +} diff --git a/src/constants/EmbedColours.ts b/src/constants/EmbedColours.ts index 023c77a..dd84b94 100644 --- a/src/constants/EmbedColours.ts +++ b/src/constants/EmbedColours.ts @@ -1,3 +1,5 @@ export default class EmbedColours { public static readonly Ok = 0x3050ba; + public static readonly Warning = 0xffbf00; + public static readonly Danger = 0xd2042d; } \ No newline at end of file diff --git a/src/database/entities/AutoKickConfig.ts b/src/database/entities/AutoKickConfig.ts new file mode 100644 index 0000000..6a82ba5 --- /dev/null +++ b/src/database/entities/AutoKickConfig.ts @@ -0,0 +1,61 @@ +import {Column, Entity} from "typeorm"; +import AppDataSource from "../dataSources/appDataSource"; +import BaseEntity from "../../contracts/BaseEntity"; + +@Entity() +export default class AutoKickConfig extends BaseEntity { + constructor(serverId: string, roleId: string, kickTime: number, noticeTime?: number, noticeChannelId?: string) { + super(); + + this.ServerId = serverId; + this.RoleId = roleId; + this.KickTime = kickTime; + this.NoticeTime = noticeTime; + this.NoticeChannelId = noticeChannelId; + } + + @Column() + ServerId: string; + + @Column() + RoleId: string; + + @Column({ type: "int" }) + KickTime: number; + + @Column({ type: "int", nullable: true }) + NoticeTime?: number; + + @Column({ nullable: true }) + NoticeChannelId?: string; + + public UpdateBasicDetails(roleId: string, kickTime: number, noticeTime?: number, noticeChannelId?: string) { + this.RoleId = roleId; + this.KickTime = kickTime; + this.NoticeTime = noticeTime; + this.NoticeChannelId = noticeChannelId; + } + + public static async FetchOneByServerIdAndRoleId(serverId: string, roleId: string): Promise { + const repository = AppDataSource.getRepository(AutoKickConfig); + + const query = repository + .createQueryBuilder("config") + .where("config.serverId = :serverId", { serverId }) + .andWhere("config.roleId = :roleId", { roleId }) + .getOne(); + + return query; + } + + public static async FetchAllByServerId(serverId: string): Promise { + const repository = AppDataSource.getRepository(AutoKickConfig); + + const query = repository + .createQueryBuilder("config") + .where("config.serverId = :serverId", { serverId }) + .getMany(); + + return query; + } +} diff --git a/src/database/migrations/3.2.4/1732973911304-createAutoKickConfig.ts b/src/database/migrations/3.2.4/1732973911304-createAutoKickConfig.ts new file mode 100644 index 0000000..c4e9a74 --- /dev/null +++ b/src/database/migrations/3.2.4/1732973911304-createAutoKickConfig.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +import MigrationHelper from "../../../helpers/MigrationHelper"; + +export class CreateAutoKickConfig1732973911304 implements MigrationInterface { + + public async up(queryRunner: QueryRunner): Promise { + MigrationHelper.Up("1732973911304-createAutoKickConfig", "3.2.4", [ + "01-AutoKickConfig-Table", + "02-AutoKickConfig-Key", + ], queryRunner) + } + + public async down(queryRunner: QueryRunner): Promise { + MigrationHelper.Down("1732973911304-createAutoKickConfig", "3.2.4", [ + "01-AutoKickConfig", + ], queryRunner) + } + +} diff --git a/src/helpers/AutoKickHelper.ts b/src/helpers/AutoKickHelper.ts new file mode 100644 index 0000000..a02a934 --- /dev/null +++ b/src/helpers/AutoKickHelper.ts @@ -0,0 +1,37 @@ +import AutoKickConfig from "../database/entities/AutoKickConfig"; + +export default class AutoKickHelper { + public static async GetSetting(serverId: string): Promise { + const configs = await AutoKickConfig.FetchAllByServerId(serverId); + + if (configs.length != 1) { + return null; + } + + return configs[0]; + } + + public static async SetSetting(serverId: string, roleId: string, kickTime: number, noticeTime?: number, noticeChannelId?: string) { + const configs = await AutoKickConfig.FetchAllByServerId(serverId); + + if (configs.length == 0) { + const config = new AutoKickConfig(serverId, roleId, kickTime, noticeTime, noticeChannelId); + await config.Save(AutoKickConfig, config); + + return; + } + + const config = configs[0]; + + config.UpdateBasicDetails(roleId, kickTime, noticeTime, noticeChannelId); + await config.Save(AutoKickConfig, config); + } + + public static async UnsetSetting(serverId: string) { + const configs = await AutoKickConfig.FetchAllByServerId(serverId); + + for (let config of configs) { + await AutoKickConfig.Remove(AutoKickConfig, config); + } + } +} diff --git a/src/helpers/TimerHelper.ts b/src/helpers/TimerHelper.ts new file mode 100644 index 0000000..3cc4246 --- /dev/null +++ b/src/helpers/TimerHelper.ts @@ -0,0 +1,81 @@ +import {CronJob} from "cron"; +import {primitive} from "../type/primitive"; +import {v4} from "uuid"; + +interface Timer { + id: string; + job: CronJob; + context: Map; + onTick: ((context: Map) => void) | ((context: Map) => Promise); + runOnStart: boolean; +} + +export default class TimerHelper { + private _timers: Timer[]; + + constructor() { + this._timers = []; + } + + public AddTimer( + cronTime: string, + timeZone: string, + onTick: ((context: Map) => void) | ((context: Map) => Promise), + runOnStart: boolean = false): string { + const context = new Map(); + + const job = new CronJob( + cronTime, + () => { + onTick(context); + }, + null, + false, + timeZone, + ); + + const id = v4(); + + this._timers.push({ + id, + job, + context, + onTick, + runOnStart, + }); + + return id; + } + + public StartAllTimers() { + this._timers.forEach(timer => this.StartJob(timer)); + } + + public StopAllTimers() { + this._timers.forEach(timer => timer.job.stop()); + } + + public StartTimer(id: string) { + const timer = this._timers.find(x => x.id == id); + + if (!timer) return; + + this.StartJob(timer); + } + + public StopTimer(id: string) { + const timer = this._timers.find(x => x.id == id); + + if (!timer) return; + + timer.job.stop(); + } + + private StartJob(timer: Timer) { + timer.job.start(); + + if (timer.runOnStart) { + timer.onTick(timer.context); + } + } +} diff --git a/src/registry.ts b/src/registry.ts index a2d7391..36dd021 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -4,6 +4,7 @@ import { EventType } from "./constants/EventType"; // Command Imports import About from "./commands/about"; import Audits from "./commands/audits"; +import Autokick from "./commands/autokick"; import Ban from "./commands/ban"; import Bunny from "./commands/bunny"; import Clear from "./commands/clear"; @@ -45,6 +46,7 @@ export default class Registry { public static RegisterCommands() { CoreClient.RegisterCommand("about", new About()); CoreClient.RegisterCommand("audits", new Audits()); + CoreClient.RegisterCommand("autokick", new Autokick()); CoreClient.RegisterCommand("ban", new Ban()); CoreClient.RegisterCommand("bunny", new Bunny()); CoreClient.RegisterCommand("clear", new Clear()); diff --git a/src/timers/AutoKick.ts b/src/timers/AutoKick.ts new file mode 100644 index 0000000..61f743a --- /dev/null +++ b/src/timers/AutoKick.ts @@ -0,0 +1,97 @@ +import { EmbedBuilder } from "discord.js"; +import {CoreClient} from "../client/client"; +import AutoKickConfig from "../database/entities/AutoKickConfig"; +import EmbedColours from "../constants/EmbedColours"; + +export default async function AutoKick() { + const client = CoreClient.baseClient; + const autoKickConfigs = await AutoKickConfig.FetchAll(AutoKickConfig); + + for (let config of autoKickConfigs) { + const guild = client.guilds.cache.find(x => x.id == config.ServerId) || await client.guilds.fetch(config.ServerId); + + if (!guild) { + continue; + } + + await guild.members.fetch(); + + const role = guild.roles.cache.find(x => x.id == config.RoleId); + + if (!role) { + continue; + } + + for (let memberEntity of role.members) { + const member = memberEntity[1]; + + if (!member.kickable) { + continue; + } + + const whenToKick = new Date(member.joinedTimestamp! + config.KickTime); + const now = new Date(); + + if (whenToKick < now) { + await member.kick("Auto Kicked"); + + if (config.NoticeChannelId) { + const channel = guild.channels.cache.find(x => x.id == config.NoticeChannelId) || await guild.channels.fetch(config.NoticeChannelId); + + if (!channel?.isSendable()) { + continue; + } + + const embed = new EmbedBuilder() + .setTitle("Auto Kicked User") + .setColor(EmbedColours.Danger) + .setThumbnail(member.user.avatarURL()) + .addFields([ + { + name: "User", + value: `<@${member.user.id}> \`${member.user.username}\``, + inline: true, + }, + ]); + + await channel.send({ + embeds: [ embed ], + }); + } + } else if (config.NoticeChannelId && config.NoticeTime) { + const whenToNotice = new Date(whenToKick.getTime() - config.NoticeTime); + + const channel = guild.channels.cache.find(x => x.id == config.NoticeChannelId) || await guild.channels.fetch(config.NoticeChannelId); + + if (!channel?.isSendable()) { + continue; + } + + if (now.getMonth() == whenToNotice.getMonth() + && now.getDate() == whenToNotice.getDate() + && now.getHours() == whenToNotice.getHours()) { + const embed = new EmbedBuilder() + .setTitle("Auto Kick Notice") + .setColor(EmbedColours.Warning) + .setThumbnail(member.user.avatarURL()) + .addFields([ + { + name: "User", + value: `<@${member.user.id}> \`${member.user.username}\``, + inline: true, + }, + { + name: "When To Kick", + value: ``, + inline: true, + }, + ]); + + await channel.send({ + embeds: [ embed ], + }); + } + } + } + } +} diff --git a/src/type/primitive.ts b/src/type/primitive.ts new file mode 100644 index 0000000..9093737 --- /dev/null +++ b/src/type/primitive.ts @@ -0,0 +1 @@ +export type primitive = string | number | boolean; diff --git a/yarn.lock b/yarn.lock index ced241e..24feeb5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -788,6 +788,11 @@ expect "^29.0.0" pretty-format "^29.0.0" +"@types/luxon@~3.4.0": + version "3.4.2" + resolved "https://registry.yarnpkg.com/@types/luxon/-/luxon-3.4.2.tgz#e4fc7214a420173cea47739c33cdf10874694db7" + integrity sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA== + "@types/node@*": version "22.7.5" resolved "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz" @@ -1458,6 +1463,14 @@ create-jest@^29.7.0: jest-util "^29.7.0" prompts "^2.0.1" +cron@^3.3.1: + version "3.3.1" + resolved "https://registry.yarnpkg.com/cron/-/cron-3.3.1.tgz#03c56b4a3ad52606160adfba1fab932c53838807" + integrity sha512-KpvuzJEbeTMTfLsXhUuDfsFYr8s5roUlLKb4fa68GszWrA4783C7q6m9yj4vyc6neyD/V9e0YiADSX2c+yRDXg== + dependencies: + "@types/luxon" "~3.4.0" + luxon "~3.5.0" + cross-spawn@^7.0.0, cross-spawn@^7.0.3: version "7.0.3" resolved "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz" @@ -3076,6 +3089,11 @@ lru-cache@^5.1.1: dependencies: yallist "^3.0.2" +luxon@~3.5.0: + version "3.5.0" + resolved "https://registry.yarnpkg.com/luxon/-/luxon-3.5.0.tgz#6b6f65c5cd1d61d1fd19dbf07ee87a50bf4b8e20" + integrity sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ== + magic-bytes.js@^1.10.0: version "1.10.0" resolved "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.10.0.tgz"