Compare commits

..

9 commits

Author SHA1 Message Date
40a9764192 Add notice channel sending functionality
All checks were successful
Test / build (push) Successful in 12s
2024-12-22 18:16:30 +00:00
afafe246a8 WIP: Add to the timer the kick functionality
All checks were successful
Test / build (push) Successful in 13s
2024-12-21 14:24:38 +00:00
94a2965e8e WIP: Start of creating autokick timer
All checks were successful
Test / build (push) Successful in 15s
2024-12-16 18:59:52 +00:00
9e05d884cf Add ability to run background tasks on a timer 2024-12-16 18:45:35 +00:00
8d1befcf25 Create autokick command
All checks were successful
Test / build (push) Successful in 7s
2024-12-06 20:54:47 +00:00
2b4ae91584 Create helper class 2024-12-06 20:27:17 +00:00
bc3c16bdcb WIP: Start creating autokick command
All checks were successful
Test / build (push) Successful in 7s
2024-11-30 13:53:24 +00:00
8e16a4fda6 Add AutoKickConfig to database 2024-11-30 13:53:13 +00:00
bfbf386eeb Fix link only mode not checking the message id
All checks were successful
Test / build (push) Successful in 6s
2024-10-25 17:38:22 +01:00
19 changed files with 471 additions and 11 deletions

View file

@ -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
DB_LOGGING=true
DB_DATA_LOCATION=./.temp/database
DB_ROOT_HOST=0.0.0.0

View file

@ -22,7 +22,7 @@ jobs:
- run: yarn test
- name: "Copy files over to location"
run: rsync -rvzP . ${{ secrets.PROD_REPO_PATH }}
run: cp -r . ${{ secrets.PROD_REPO_PATH }}
deploy:
environment: prod

View file

@ -22,7 +22,7 @@ jobs:
- run: yarn test
- name: "Copy files over to location"
run: rsync -rvzP . ${{ secrets.STAGE_REPO_PATH }}
run: cp -r . ${{ secrets.STAGE_REPO_PATH }}
deploy:
environment: prod

View file

@ -0,0 +1 @@
DROP TABLE auto_kick_config;

View file

@ -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
);

View file

@ -0,0 +1,2 @@
ALTER TABLE auto_kick_config
ADD PRIMARY KEY (Id);

View file

@ -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",

View file

@ -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, true);
this._timerHelper.StartAllTimers();
})
.catch((err) => console.error("Error Initialising Data Source", err));
super.on("interactionCreate", this._events.onInteractionCreate);

113
src/commands/autokick.ts Normal file
View file

@ -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");
}
}

View file

@ -1,3 +1,5 @@
export default class EmbedColours {
public static readonly Ok = 0x3050ba;
public static readonly Warning = 0xffbf00;
public static readonly Danger = 0xd2042d;
}

View file

@ -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<AutoKickConfig | null> {
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<AutoKickConfig[]> {
const repository = AppDataSource.getRepository(AutoKickConfig);
const query = repository
.createQueryBuilder("config")
.where("config.serverId = :serverId", { serverId })
.getMany();
return query;
}
}

View file

@ -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<void> {
MigrationHelper.Up("1732973911304-createAutoKickConfig", "3.2.4", [
"01-AutoKickConfig-Table",
"02-AutoKickConfig-Key",
], queryRunner)
}
public async down(queryRunner: QueryRunner): Promise<void> {
MigrationHelper.Down("1732973911304-createAutoKickConfig", "3.2.4", [
"01-AutoKickConfig",
], queryRunner)
}
}

View file

@ -6,11 +6,7 @@ export default async function LinkOnlyMode(message: Message) {
const gifOnlyMode = await SettingsHelper.GetSetting("channel.linkonly", message.guild.id);
if (!gifOnlyMode) return;
const channel = message.guild.channels.cache.find(x => x.id == gifOnlyMode) || message.guild.channels.fetch(gifOnlyMode);
if (!channel) return;
if (gifOnlyMode != message.channel.id) return;
if (message.content.startsWith("https://") || message.content.startsWith("http://")) return;

View file

@ -0,0 +1,37 @@
import AutoKickConfig from "../database/entities/AutoKickConfig";
export default class AutoKickHelper {
public static async GetSetting(serverId: string): Promise<AutoKickConfig | null> {
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);
}
}
}

View file

@ -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<string, primitive>;
onTick: ((context: Map<string, primitive>) => void) | ((context: Map<string, primitive>) => Promise<void>);
runOnStart: boolean;
}
export default class TimerHelper {
private _timers: Timer[];
constructor() {
this._timers = [];
}
public AddTimer(
cronTime: string,
timeZone: string,
onTick: ((context: Map<string, primitive>) => void) | ((context: Map<string, primitive>) => Promise<void>),
runOnStart: boolean = false): string {
const context = new Map<string, primitive>();
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);
}
}
}

View file

@ -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());

98
src/timers/AutoKick.ts Normal file
View file

@ -0,0 +1,98 @@
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");
console.log("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.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.getMilliseconds() - 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.avatarURL())
.addFields([
{
name: "User",
value: `<@${member.user.id}> \`${member.user.username}\``,
inline: true,
},
{
name: "When To Kick",
value: `<t:${whenToKick.getMilliseconds()}:R>`,
inline: true,
},
]);
await channel.send({
embeds: [ embed ],
});
}
}
}
}
}

1
src/type/primitive.ts Normal file
View file

@ -0,0 +1 @@
export type primitive = string | number | boolean;

View file

@ -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"