diff --git a/package-lock.json b/package-lock.json index aefe194b..8127d170 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "ghom-eval": "^1.1.3", "ghom-prettify": "^3.0.0", "knex": "^3.0.1", + "node-cron": "^3.0.3", "openai": "^4.68.4", "pg": "^8.13.0", "prettier": "^3.2.5", @@ -47,7 +48,7 @@ "@eslint/compat": "^1.2.0", "@eslint/eslintrc": "^3.1.0", "@eslint/js": "^9.12.0", - "@ghom/bot.ts-cli": "^8.2.0", + "@ghom/bot.ts-cli": "^8.3.1", "@types/boxen": "^3.0.1", "@types/cron": "^1.7.3", "@types/dotenv": "^8.2.0", @@ -56,6 +57,7 @@ "@types/gulp-filter": "^3.0.34", "@types/gulp-rename": "^2.0.1", "@types/node": "^22.7.6", + "@types/node-cron": "^3.0.11", "@types/prettier": "^2.6.3", "@types/vinyl-paths": "^0.0.31", "@types/ws": "^8.5.3", @@ -810,9 +812,9 @@ "optional": true }, "node_modules/@ghom/bot.ts-cli": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/@ghom/bot.ts-cli/-/bot.ts-cli-8.2.0.tgz", - "integrity": "sha512-d6bhTn2jqI2Ou8G794g3qziFGa2G1srQdWUfvQmL80TwewM1VLacLeHM5SehArzVU77rVd+8/K3TYB/FZTPmJA==", + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/@ghom/bot.ts-cli/-/bot.ts-cli-8.3.1.tgz", + "integrity": "sha512-l8c/iZsOjcViLO2SiRQ9CZ7tJRopsGdSt9dM0rqU4S+226Cp8bSb2snQfwrAZCSHBhmH8Xzq+FOqlCf5u0Tmrw==", "dev": true, "license": "ISC", "dependencies": { @@ -1502,6 +1504,13 @@ "undici-types": "~6.19.2" } }, + "node_modules/@types/node-cron": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/node-cron/-/node-cron-3.0.11.tgz", + "integrity": "sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node-fetch": { "version": "2.6.8", "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.8.tgz", @@ -7130,6 +7139,18 @@ "license": "MIT", "optional": true }, + "node_modules/node-cron": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz", + "integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==", + "license": "ISC", + "dependencies": { + "uuid": "8.3.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/node-domexception": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", @@ -9258,6 +9279,15 @@ "devOptional": true, "license": "MIT" }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/v8flags": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/v8flags/-/v8flags-4.0.1.tgz", diff --git a/package.json b/package.json index a645916f..d8cd7f11 100644 --- a/package.json +++ b/package.json @@ -64,14 +64,15 @@ "tims": "^2.1.0", "types-package-json": "^2.0.39", "yargs-parser": "^21.0.1", - "zod": "^3.23.8" + "zod": "^3.23.8", + "node-cron": "^3.0.3" }, "devDependencies": { "@esbuild/linux-x64": "^0.24.0", "@eslint/compat": "^1.2.0", "@eslint/eslintrc": "^3.1.0", "@eslint/js": "^9.12.0", - "@ghom/bot.ts-cli": "^8.2.0", + "@ghom/bot.ts-cli": "^8.3.1", "@types/boxen": "^3.0.1", "@types/cron": "^1.7.3", "@types/dotenv": "^8.2.0", @@ -103,7 +104,8 @@ "make-bot.ts": "^6.0.5", "nodemon": "^2.0.19", "typescript": "^5.4.0-beta", - "vinyl-paths": "^4.0.0" + "vinyl-paths": "^4.0.0", + "@types/node-cron": "^3.0.11" }, "engines": { "node": ">=22.x.x", @@ -126,4 +128,4 @@ "@types/pg": "^8.11.10", "through2": "^4.0.2" } -} +} \ No newline at end of file diff --git a/src/app.native.ts b/src/app.native.ts index 58620c44..195d64af 100644 --- a/src/app.native.ts +++ b/src/app.native.ts @@ -8,6 +8,7 @@ export * from "./app/config.ts" export * from "./app/logger.ts" export * from "./app/slash.ts" export * from "./app/util.ts" +export * from "./app/cron.ts" export * from "./config.ts" export * from "./types.ts" @@ -18,6 +19,7 @@ export * as command from "./app/command.ts" export * as button from "./app/button.ts" export * as slash from "./app/slash.ts" export * as util from "./app/util.ts" +export * as cron from "./app/cron.ts" export { default as env } from "#env" export { default as client } from "#client" diff --git a/src/app/button.ts b/src/app/button.ts index 571d437c..89a56e5c 100644 --- a/src/app/button.ts +++ b/src/app/button.ts @@ -15,7 +15,8 @@ export const buttonHandler = new handler.Handler( pattern: /\.js$/, loader: async (filepath) => { const file = await import(url.pathToFileURL(filepath).href) - return file.default as IButton + if (file.default instanceof Button) return file.default + throw new Error(`${filepath}: default export must be a Button instance`) }, onLoad: async (filepath, button) => { button.native = filepath.endsWith(".native.js") @@ -31,22 +32,22 @@ export const buttons = new (class ButtonCollection extends discord.Collection< > { add(button: IButton): this { this.validate(button) - return super.set(button.options.key, button) + return this.set(button.options.name, button) } validate(button: IButton): void | never { - if (this.has(button.options.key)) { - throw new Error(`Button key "${button.options.key}" is not unique.`) + if (this.has(button.options.name)) { + throw new Error(`Button key "${button.options.name}" is not unique.`) } util.validateCooldown( button.options.cooldown, button.options.run, - button.options.key, + button.options.name, ) logger.log( - `loaded button ${util.styleText("blueBright", button.options.key)}${ + `loaded button ${util.styleText("blueBright", button.options.name)}${ button.native ? ` ${util.styleText("green", "native")}` : "" } ${util.styleText("grey", button.options.description)}`, ) @@ -55,7 +56,7 @@ export const buttons = new (class ButtonCollection extends discord.Collection< export interface IButton { options: { - key: string + name: string description: string guildOnly?: boolean adminOnly?: boolean @@ -79,7 +80,7 @@ export interface IButton { export type ButtonParams = Record | null export interface ButtonOptions { - key: string + name: string description: string guildOnly?: boolean adminOnly?: boolean @@ -133,7 +134,7 @@ export function createButton( params: Params, ): discord.ButtonBuilder { const button = new discord.ButtonBuilder() - .setCustomId(encodeButtonCustomId(handler.options.key, params)) + .setCustomId(encodeButtonCustomId(handler.options.name, params)) .setStyle(discord.ButtonStyle.Primary) handler.options.builder?.(button, params) @@ -147,7 +148,7 @@ export async function prepareButton( ): Promise { const error = await util.checkCooldown( button.options.cooldown, - `${button.options.key} button`, + `${button.options.name} button`, { authorId: interaction.user.id, channelId: interaction.channelId, diff --git a/src/app/command.ts b/src/app/command.ts index cbcbfdae..e03d47f7 100644 --- a/src/app/command.ts +++ b/src/app/command.ts @@ -18,13 +18,14 @@ import config from "#config" import { filename } from "dirname-filename-esm" const __filename = filename(import.meta) -export const commandHandler = new handler.Handler( +export const commandHandler = new handler.Handler( path.join(process.cwd(), "dist", "commands"), { pattern: /\.js$/, loader: async (filepath) => { const file = await import(url.pathToFileURL(filepath).href) - return file.default as ICommand + if (file.default instanceof Command) return file.default + throw new Error(`${filepath}: default export must be a Command instance`) }, onLoad: async (filepath, command) => { command.native = filepath.endsWith(".native.js") diff --git a/src/app/cron.ts b/src/app/cron.ts new file mode 100644 index 00000000..32f49b10 --- /dev/null +++ b/src/app/cron.ts @@ -0,0 +1,213 @@ +import * as handler from "@ghom/handler" + +import cron from "node-cron" +import path from "path" +import url from "url" +import discord from "discord.js" + +import * as util from "./util.ts" + +import env from "#env" +import logger from "#logger" + +export class CRON_Error extends Error { + constructor(message: string) { + super(message) + this.name = "CRON_Error" + } +} + +export const cronHandler = new handler.Handler( + path.join(process.cwd(), "dist", "cron"), + { + pattern: /\.js$/, + loader: async (filepath) => { + const file = await import(url.pathToFileURL(filepath).href) + if (file.default instanceof Cron) return file.default + throw new CRON_Error( + `${filepath}: default export must be a Cron instance`, + ) + }, + onLoad: async (filepath, button) => { + button.native = filepath.endsWith(".native.js") + button.filepath = filepath + cronList.add(button) + }, + }, +) + +export const cronList = new (class CronCollection extends discord.Collection< + string, + Cron +> { + add(cron: Cron): this { + this.validate(cron) + return this.set(cron.options.name, cron) + } + + validate(cron: Cron): void | never { + // cron has good format + cronConfigToPattern(cron.options.schedule) + + logger.log( + `loaded cron ${util.styleText("blueBright", cron.options.name)}${ + cron.native ? ` ${util.styleText("green", "native")}` : "" + } ${util.styleText("grey", cron.options.description)}`, + ) + } +})() + +export interface CronOptions { + name: string + description: string + schedule: CronIntervalKey | CronIntervalSimple | CronIntervalAdvanced + runOnStart?: boolean + run(this: Cron): unknown +} + +export class Cron { + task?: cron.ScheduledTask + native?: boolean + filepath?: string + + ranCount = 0 + + constructor(public options: CronOptions) {} + + stop() { + if (this.task) this.task.stop() + } + + start() { + if (this.task) this.task.start() + else + this.task = cron.schedule( + cronConfigToPattern(this.options.schedule), + () => { + this.options.run.bind(this)() + this.ranCount++ + }, + { + name: this.options.name, + timezone: env.BOT_TIMEZONE, + runOnInit: this.options.runOnStart, + }, + ) + } +} + +export enum CronDayOfWeek { + Sunday = 0, + Monday = 1, + Tuesday = 2, + Wednesday = 3, + Thursday = 4, + Friday = 5, + Saturday = 6, +} + +export enum CronMonth { + January = 1, + February = 2, + March = 3, + April = 4, + May = 5, + June = 6, + July = 7, + August = 8, + September = 9, + October = 10, + November = 11, + December = 12, +} + +export type CronIntervalKey = + | "minutely" + | "hourly" + | "daily" + | "weekly" + | "monthly" + | "yearly" + +export interface CronIntervalSimple { + type: "minute" | "hour" | "day" | "week" | "month" | "year" + duration: number +} + +/** + * @property minute from 0 to 59, or "*" for each + * @property hour from 0 to 23, or "*" for each + * @property dayOfMonth from 1 to 31, or "*" for each + * @property month from 1 to 12, or "*" for each + * @property dayOfWeek from 0 (Sunday) to 6 (Saturday), or "*" for each + */ +export interface CronIntervalAdvanced { + minute?: number | "*" + hour?: number | "*" + dayOfMonth?: number | "*" + month?: CronMonth | "*" + dayOfWeek?: CronDayOfWeek | "*" +} + +export function cronConfigToPattern(config: CronOptions["schedule"]): string { + if (typeof config === "string") return cronKeyToPattern(config) + + if ("type" in config) return cronSimpleToPattern(config) + + if ( + typeof config.dayOfMonth === "number" && + (config.dayOfMonth < 1 || config.dayOfMonth > 31) + ) + throw new CRON_Error("Invalid day of month") + + if (typeof config.hour === "number" && (config.hour < 0 || config.hour > 23)) + throw new CRON_Error("Invalid hour") + + if ( + typeof config.minute === "number" && + (config.minute < 0 || config.minute > 59) + ) + throw new CRON_Error("Invalid minute") + + return `${config.minute ?? "*"} ${config.hour ?? "*"} ${ + config.dayOfMonth ?? "*" + } ${config.month ?? "*"} ${config.dayOfWeek ?? "*"}` +} + +export function cronKeyToPattern(key: CronIntervalKey): string { + switch (key) { + case "minutely": + return "* * * * *" + case "hourly": + return "0 * * * *" + case "daily": + return "0 0 * * *" + case "weekly": + return "0 0 * * 0" + case "monthly": + return "0 0 1 * *" + case "yearly": + return "0 0 1 1 *" + default: + throw new CRON_Error("Invalid cron key") + } +} + +export function cronSimpleToPattern(simple: CronIntervalSimple): string { + switch (simple.type) { + case "minute": + return `*/${simple.duration} * * * *` + case "hour": + return `0 */${simple.duration} * * *` + case "day": + return `0 0 */${simple.duration} * *` + case "week": + return `0 0 * * */${simple.duration}` + case "month": + return `0 0 1 */${simple.duration} *` + case "year": + return `0 0 1 1 */${simple.duration}` + default: + throw new CRON_Error("Invalid cron simple type") + } +} diff --git a/src/app/listener.ts b/src/app/listener.ts index b02acc1f..60f65282 100644 --- a/src/app/listener.ts +++ b/src/app/listener.ts @@ -13,40 +13,45 @@ import client from "#client" const readyListeners = new discord.Collection, boolean>() -export const listenerHandler = new handler.Handler( +export const listenerHandler = new handler.Handler>( path.join(process.cwd(), "dist", "listeners"), { pattern: /\.js$/, loader: async (filepath) => { const file = await import(url.pathToFileURL(filepath).href) - return file.default as Listener + if (file.default instanceof Listener) return file.default + throw new Error(`${filepath}: default export must be a Listener instance`) }, onLoad: async (filepath, listener) => { - if (listener.event === "ready") readyListeners.set(listener, false) + if (listener.options.event === "ready") + readyListeners.set(listener, false) - client[listener.once ? "once" : "on"](listener.event, async (...args) => { - try { - await listener.run(...args) + client[listener.options.once ? "once" : "on"]( + listener.options.event, + async (...args) => { + try { + await listener.options.run(...args) - if (listener.event === "ready") { - readyListeners.set(listener, true) + if (listener.options.event === "ready") { + readyListeners.set(listener, true) - if (readyListeners.every((launched) => launched)) { - client.emit("afterReady", ...args) + if (readyListeners.every((launched) => launched)) { + client.emit("afterReady", ...args) + } } + } catch (error: any) { + logger.error(error, filepath, true) } - } catch (error: any) { - logger.error(error, filepath, true) - } - }) + }, + ) const isNative = filepath.includes(".native.") const category = path .basename(filepath, ".js") - .replace(`${listener.event}.`, "") + .replace(`${listener.options.event}.`, "") .split(".") - .filter((x) => x !== "native" && x !== listener.event) + .filter((x) => x !== "native" && x !== listener.options.event) .join(" ") logger.log( @@ -55,10 +60,10 @@ export const listenerHandler = new handler.Handler( category, )} ${util.styleText( "yellow", - listener.once ? "once" : "on", - )} ${util.styleText("blueBright", listener.event)}${ + listener.options.once ? "once" : "on", + )} ${util.styleText("blueBright", listener.options.event)}${ isNative ? ` ${util.styleText("green", "native")}` : "" - } ${util.styleText("grey", listener.description)}`, + } ${util.styleText("grey", listener.options.description)}`, ) }, }, @@ -71,9 +76,13 @@ export interface MoreClientEvents { export type AllClientEvents = discord.ClientEvents & MoreClientEvents -export type Listener = { +export type ListenerOptions = { event: EventName description: string run: (...args: AllClientEvents[EventName]) => unknown once?: boolean } + +export class Listener { + constructor(public options: ListenerOptions) {} +} diff --git a/src/app/slash.ts b/src/app/slash.ts index ba0b7f35..6ea1f8ab 100644 --- a/src/app/slash.ts +++ b/src/app/slash.ts @@ -24,13 +24,16 @@ export class SlashCommandError extends Error { } } -export const slashCommandHandler = new handler.Handler( +export const slashCommandHandler = new handler.Handler( path.join(process.cwd(), "dist", "slash"), { pattern: /\.js$/, loader: async (filepath) => { const file = await import(url.pathToFileURL(filepath).href) - return file.default as ISlashCommand + if (file.default instanceof SlashCommand) return file.default + throw new Error( + `${filepath}: default export must be a SlashCommand instance`, + ) }, onLoad: async (filepath, command) => { command.native = filepath.endsWith("native.js") diff --git a/src/app/util.ts b/src/app/util.ts index 5252c314..098c3dab 100644 --- a/src/app/util.ts +++ b/src/app/util.ts @@ -479,6 +479,7 @@ export interface SystemMessageOptions { body: string | Error footer?: string date?: Date + url?: string } export type SystemMessage = Pick< @@ -491,15 +492,21 @@ export type SystemMessageType = "default" | keyof SystemEmojis export interface GetSystemMessageOptions { /** * js, json, ts, etc.
- * If given, a formatted code clock will be displayed
- * If true, the code block will be displayed without lang + * If given, a formatted code clock will be displayed.
+ * If true, the code block will be displayed without lang. */ code?: boolean | string /** - * If true, the error stack will be displayed + * If true, the error stack will be displayed. */ stack?: boolean + + /** + * If the output is an embed, you can edit it in this callback.
+ * The result of this callback will be returned as the final embed. + */ + editEmbed?: (embed: discord.EmbedBuilder) => discord.EmbedBuilder } export async function getSystemMessage( @@ -541,25 +548,23 @@ export async function getSystemMessage( } // if the input has a header or a footer, use an embed - if ( - typeof message !== "string" && - !(message instanceof Error) && - (message.header || message.footer) - ) { - output.embeds = [ - new discord.EmbedBuilder() - .setColor(systemColors[type]) - .setDescription( - message.header - ? `### ${ - type === "default" ? "" : getSystemEmoji(type) - } ${message.header}\n${output.content}`.trim() - : output.content!, - ) - .setFooter(message.footer ? { text: message.footer } : null) - .setTimestamp(message.date ?? null) - .toJSON(), - ] + if (typeof message !== "string" && !(message instanceof Error)) { + const embed = new discord.EmbedBuilder() + .setURL(message.url ?? null) + .setColor(systemColors[type]) + .setDescription( + message.header + ? `### ${ + type === "default" ? "" : getSystemEmoji(type) + } ${message.header}\n${output.content}`.trim() + : output.content!, + ) + .setFooter(message.footer ? { text: message.footer } : null) + .setTimestamp(message.date ?? null) + + if (options?.editEmbed) options.editEmbed(embed) + + output.embeds = [embed.toJSON()] delete output.content } else if (type !== "default") { // else, add an emoji to the message diff --git a/src/buttons/givePoints.ts b/src/buttons/givePoints.ts index 959adba5..54a9b2e2 100644 --- a/src/buttons/givePoints.ts +++ b/src/buttons/givePoints.ts @@ -6,7 +6,7 @@ export default new app.Button<{ targetId: string amount: number }>({ - key: "givePoints", + name: "givePoints", description: "Gives some helping points to a user", guildOnly: true, builder: (builder) => builder.setEmoji("👍"), diff --git a/src/buttons/pagination.native.ts b/src/buttons/pagination.native.ts index 2ed7729b..c790b74b 100644 --- a/src/buttons/pagination.native.ts +++ b/src/buttons/pagination.native.ts @@ -4,7 +4,7 @@ import type * as pagination from "#src/app/pagination.ts" export default new button.Button<{ key: pagination.PaginatorKey }>({ - key: "pagination", + name: "pagination", description: "The pagination button", async run(interaction, { key }) { const app = await import("#app") diff --git a/src/buttons/resolveTopic.ts b/src/buttons/resolveTopic.ts index 0a5750be..b9c5bd2b 100644 --- a/src/buttons/resolveTopic.ts +++ b/src/buttons/resolveTopic.ts @@ -2,7 +2,7 @@ import * as app from "#app" import helping from "#tables/helping.ts" export default new app.Button({ - key: "resolveTopic", + name: "resolveTopic", description: "Mark the topic as resolved", guildOnly: true, builder: (builder) => diff --git a/src/buttons/upTopic.ts b/src/buttons/upTopic.ts index c8d76705..45f3613d 100644 --- a/src/buttons/upTopic.ts +++ b/src/buttons/upTopic.ts @@ -1,7 +1,7 @@ import * as app from "#app" export default new app.Button({ - key: "upTopic", + name: "upTopic", description: "Up the topic in the help forum", guildOnly: true, cooldown: { diff --git a/src/commands/active.ts b/src/commands/active.ts index b1ff8b92..776e7940 100644 --- a/src/commands/active.ts +++ b/src/commands/active.ts @@ -36,15 +36,6 @@ export default new app.Command({ validate: (value) => value > 0, validationErrorMessage: "The period must be greater than 0.", }), - app.option({ - name: "refreshInterval", - aliases: ["interval"], - description: "The interval to refresh the active list (in hours)", - type: "number", - validate: (value) => value > 0 && value < 24, - validationErrorMessage: - "The interval must be greater than 0 and less than 24.", - }), ], async run(message) { used = true @@ -73,12 +64,6 @@ export default new app.Command({ guildConfig: config, }) - await app.launchActiveInterval(message.guild, { - refreshInterval: +config.active_refresh_interval, - period: message.args.period ?? +config.active_period, - messageCount: message.args.messageCount ?? +config.active_message_count, - }) - used = false }, subs: [ diff --git a/src/cron/active.ts b/src/cron/active.ts new file mode 100644 index 00000000..cb225d04 --- /dev/null +++ b/src/cron/active.ts @@ -0,0 +1,79 @@ +import * as app from "#app" + +const REFRESH_INTERVAL = 12 + +/** + * See the {@link https://ghom.gitbook.io/bot.ts/usage/create-a-cron cron guide} for more information. + */ +export default new app.Cron({ + name: "active", + description: "Refresh the active member list every 12 hours", + schedule: { + type: "hour", + duration: REFRESH_INTERVAL, + }, + async run() { + const guilds = await app.client.guilds.fetch() + + for (const [, guild] of guilds) { + const config = await app.getGuild(guild) + + if (!config?.active_role_id) continue + + const period = Number(config.active_period) + const messageCount = Number(config.active_message_count) + + const realGuild = await guild.fetch() + + if (!(await app.hasActivity(config._id, REFRESH_INTERVAL))) return + + let found: number + + try { + found = await app.updateActive(realGuild, { + force: false, + period, + messageCount, + guildConfig: config, + }) + } catch (error: any) { + await app.sendLog( + realGuild, + `Failed to update the active list...${await app.code.stringify({ + content: error.message, + lang: "js", + })}`, + ) + + return + } + + const cacheId = app.lastActiveCountCacheId(realGuild) + + const lastActiveCount = app.cache.ensure(cacheId, 0) + + if (found > lastActiveCount) { + await app.sendLog( + realGuild, + `Finished updating the active list, found **${ + found - lastActiveCount + }** active members.`, + ) + } else if (found < lastActiveCount) { + await app.sendLog( + realGuild, + `Finished updating the active list, **${ + lastActiveCount - found + }** members have been removed.`, + ) + } else { + await app.sendLog( + realGuild, + `Finished updating the active list, no changes were made.`, + ) + } + + app.cache.set(cacheId, found) + } + }, +}) diff --git a/src/cron/money.ts b/src/cron/money.ts new file mode 100644 index 00000000..90829224 --- /dev/null +++ b/src/cron/money.ts @@ -0,0 +1,13 @@ +import * as app from "#app" + +/** + * See the {@link https://ghom.gitbook.io/bot.ts/usage/create-a-cron cron guide} for more information. + */ +export default new app.Cron({ + name: "money", + description: "Give money to users hourly", + schedule: "hourly", + async run() { + await app.giveHourlyCoins() + }, +}) diff --git a/src/cron/remind.ts b/src/cron/remind.ts new file mode 100644 index 00000000..71c0282d --- /dev/null +++ b/src/cron/remind.ts @@ -0,0 +1,13 @@ +import * as app from "#app" + +/** + * See the {@link https://ghom.gitbook.io/bot.ts/usage/create-a-cron cron guide} for more information. + */ +export default new app.Cron({ + name: "remind", + description: "Check reminders every minute", + schedule: "minutely", + async run() { + await app.checkReminds() + }, +}) diff --git a/src/cron/tracker.ts b/src/cron/tracker.ts new file mode 100644 index 00000000..7998fa80 --- /dev/null +++ b/src/cron/tracker.ts @@ -0,0 +1,16 @@ +import * as app from "#app" + +/** + * See the {@link https://ghom.gitbook.io/bot.ts/usage/create-a-cron cron guide} for more information. + */ +export default new app.Cron({ + name: "tracker", + description: "A tracker cron", + schedule: "hourly", + async run() { + for (const guild of app.client.guilds.cache.values()) { + await app.updateGuildOnlineCountTracker(guild) + await app.updateGuildMessageCountTracker(guild) + } + }, +}) diff --git a/src/index.ts b/src/index.ts index f8f96bf6..bfe21d8b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ import env from "./app/env.ts" const app = await import("#app") try { + await app.cronHandler.init() await app.database.init() await app.buttonHandler.init() await app.commandHandler.init() diff --git a/src/listeners/activity.messageCreate.ts b/src/listeners/activity.messageCreate.ts index 33a9b450..5df951e1 100644 --- a/src/listeners/activity.messageCreate.ts +++ b/src/listeners/activity.messageCreate.ts @@ -2,7 +2,7 @@ import * as app from "#app" import messages from "#tables/message.ts" -const listener: app.Listener<"messageCreate"> = { +export default new app.Listener({ event: "messageCreate", description: "Record sent messages", async run(message) { @@ -17,6 +17,4 @@ const listener: app.Listener<"messageCreate"> = { guild_id: guild._id, }) }, -} - -export default listener +}) diff --git a/src/listeners/activity.ready.ts b/src/listeners/activity.ready.ts deleted file mode 100644 index 47258d74..00000000 --- a/src/listeners/activity.ready.ts +++ /dev/null @@ -1,27 +0,0 @@ -import * as app from "#app" - -const listener: app.Listener<"ready"> = { - event: "ready", - description: "Start an interval to update the active list", - async run(client) { - const guilds = await client.guilds.fetch() - - for (const [, guild] of guilds) { - const config = await app.getGuild(guild) - - if (!config?.active_role_id) continue - - const refreshInterval = Number(config.active_refresh_interval) - const period = Number(config.active_period) - const messageCount = Number(config.active_message_count) - - await app.launchActiveInterval(guild, { - refreshInterval, - period, - messageCount, - }) - } - }, -} - -export default listener diff --git a/src/listeners/automod.messageCreate.ts b/src/listeners/automod.messageCreate.ts index 4e3b2682..e655b417 100644 --- a/src/listeners/automod.messageCreate.ts +++ b/src/listeners/automod.messageCreate.ts @@ -1,6 +1,6 @@ import * as app from "#app" -const listener: app.Listener<"messageCreate"> = { +export default new app.Listener({ event: "messageCreate", description: "Watch sent messages to detect and ban spammers", async run(message) { @@ -8,6 +8,4 @@ const listener: app.Listener<"messageCreate"> = { .detectAndBanSpammer(message) .catch((error) => app.error(error, "automod.messageCreate")) }, -} - -export default listener +}) diff --git a/src/listeners/button.interactionCreate.native.ts b/src/listeners/button.interactionCreate.native.ts index 7519e9ca..c3b2fba8 100644 --- a/src/listeners/button.interactionCreate.native.ts +++ b/src/listeners/button.interactionCreate.native.ts @@ -2,7 +2,7 @@ import * as app from "#app" -const listener: app.Listener<"interactionCreate"> = { +export default new app.Listener({ event: "interactionCreate", description: "Handle the interactions for buttons", async run(interaction) { @@ -52,6 +52,4 @@ const listener: app.Listener<"interactionCreate"> = { } } }, -} - -export default listener +}) diff --git a/src/listeners/clean.guildDelete.ts b/src/listeners/clean.guildDelete.ts index bb0b0335..2141af1b 100644 --- a/src/listeners/clean.guildDelete.ts +++ b/src/listeners/clean.guildDelete.ts @@ -2,12 +2,10 @@ import * as app from "#app" import guilds from "#tables/guild.ts" -const listener: app.Listener<"guildDelete"> = { +export default new app.Listener({ event: "guildDelete", description: "Remove guild from db", async run(guild) { await guilds.query.delete().where("id", guild.id) }, -} - -export default listener +}) diff --git a/src/listeners/clean.guildMemberRemove.ts b/src/listeners/clean.guildMemberRemove.ts index 0e69cf67..ec3fed55 100644 --- a/src/listeners/clean.guildMemberRemove.ts +++ b/src/listeners/clean.guildMemberRemove.ts @@ -2,7 +2,7 @@ import * as app from "#app" import users from "#tables/user.ts" -const listener: app.Listener<"guildMemberRemove"> = { +export default new app.Listener({ event: "guildMemberRemove", description: "Delete member from db", async run(member) { @@ -31,6 +31,4 @@ const listener: app.Listener<"guildMemberRemove"> = { ) } }, -} - -export default listener +}) diff --git a/src/listeners/coins.afterReady.ts b/src/listeners/coins.afterReady.ts deleted file mode 100644 index 285d0558..00000000 --- a/src/listeners/coins.afterReady.ts +++ /dev/null @@ -1,18 +0,0 @@ -import * as app from "#app" - -const listener: app.Listener<"afterReady"> = { - event: "afterReady", - description: "Gain coins hourly according to the user's points & ratings", - async run() { - await app.giveHourlyCoins() - - setInterval( - async () => { - await app.giveHourlyCoins() - }, - 1000 * 60 * 60, - ) - }, -} - -export default listener diff --git a/src/listeners/command.messageCreate.native.ts b/src/listeners/command.messageCreate.native.ts index a2636228..11bd9e27 100644 --- a/src/listeners/command.messageCreate.native.ts +++ b/src/listeners/command.messageCreate.native.ts @@ -6,7 +6,7 @@ import env from "#env" import yargsParser from "yargs-parser" -const listener: app.Listener<"messageCreate"> = { +export default new app.Listener({ event: "messageCreate", description: "Handle the messages for commands", async run(message) { @@ -159,6 +159,4 @@ const listener: app.Listener<"messageCreate"> = { }) } }, -} - -export default listener +}) diff --git a/src/listeners/helping.clean.threadDelete.ts b/src/listeners/helping.clean.threadDelete.ts index 10fbe08b..c935cf79 100644 --- a/src/listeners/helping.clean.threadDelete.ts +++ b/src/listeners/helping.clean.threadDelete.ts @@ -1,7 +1,7 @@ import * as app from "#app" import helping from "#tables/helping.ts" -const listener: app.Listener<"threadDelete"> = { +export default new app.Listener({ event: "threadDelete", description: "Clean up the helping table when a thread is deleted", async run(channel) { @@ -16,6 +16,4 @@ const listener: app.Listener<"threadDelete"> = { await helping.query.where("id", channel.id).delete() }, -} - -export default listener +}) diff --git a/src/listeners/helping.footer.messageCreate.ts b/src/listeners/helping.footer.messageCreate.ts index d889713e..67a1b966 100644 --- a/src/listeners/helping.footer.messageCreate.ts +++ b/src/listeners/helping.footer.messageCreate.ts @@ -1,6 +1,6 @@ import * as app from "#app" -const listener: app.Listener<"messageCreate"> = { +export default new app.Listener({ event: "messageCreate", description: "Handle messages in the help forum channels", async run(message) { @@ -38,6 +38,4 @@ const listener: app.Listener<"messageCreate"> = { ), ) }, -} - -export default listener +}) diff --git a/src/listeners/helping.info.threadCreate.ts b/src/listeners/helping.info.threadCreate.ts index 05c55f3e..21c78cb6 100644 --- a/src/listeners/helping.info.threadCreate.ts +++ b/src/listeners/helping.info.threadCreate.ts @@ -1,6 +1,6 @@ import * as app from "#app" -const listener: app.Listener<"threadCreate"> = { +export default new app.Listener({ event: "threadCreate", description: "A threadCreate listener for helping.info", async run(thread) { @@ -16,19 +16,11 @@ const listener: app.Listener<"threadCreate"> = { if (thread.parent.id !== guild.help_forum_channel_id) return return thread.send( - await app - .getSystemMessage("default", { - header: "Bienvenue sur le forum d'entraide", - body: "Vous pouvez poser vos questions ici, n'oubliez pas de donner le plus de détails possible pour que nous puissions vous aider au mieux.", - }) - .then((systemMessage) => { - ;(systemMessage.embeds![0] as app.EmbedBuilder).setURL( - app.HELPING_URL_AS_ID, - ) - return systemMessage - }), + await app.getSystemMessage("default", { + header: "Bienvenue sur le forum d'entraide", + body: "Vous pouvez poser vos questions ici, n'oubliez pas de donner le plus de détails possible pour que nous puissions vous aider au mieux.", + url: app.HELPING_URL_AS_ID, + }), ) }, -} - -export default listener +}) diff --git a/src/listeners/log.afterReady.native.ts b/src/listeners/log.afterReady.native.ts index f58e1bcf..f6372f08 100644 --- a/src/listeners/log.afterReady.native.ts +++ b/src/listeners/log.afterReady.native.ts @@ -12,7 +12,7 @@ import { filename } from "dirname-filename-esm" const __filename = filename(import.meta) -const listener: app.Listener<"afterReady"> = { +export default new app.Listener({ event: "afterReady", description: "Just log that bot is ready", once: true, @@ -47,6 +47,4 @@ const listener: app.Listener<"afterReady"> = { ) }) }, -} - -export default listener +}) diff --git a/src/listeners/pagination.messageDelete.native.ts b/src/listeners/pagination.messageDelete.native.ts index 51e58ade..2e41b3a0 100644 --- a/src/listeners/pagination.messageDelete.native.ts +++ b/src/listeners/pagination.messageDelete.native.ts @@ -2,7 +2,7 @@ import * as app from "#app" -const listener: app.Listener<"messageDelete"> = { +export default new app.Listener({ event: "messageDelete", description: "Remove existing deleted paginator", async run(message) { @@ -10,6 +10,4 @@ const listener: app.Listener<"messageDelete"> = { if (paginator) return paginator.deactivate() }, -} - -export default listener +}) diff --git a/src/listeners/pagination.messageReactionAdd.native.ts b/src/listeners/pagination.messageReactionAdd.native.ts index f75fa6eb..bedebb0b 100644 --- a/src/listeners/pagination.messageReactionAdd.native.ts +++ b/src/listeners/pagination.messageReactionAdd.native.ts @@ -2,7 +2,7 @@ import * as app from "#app" -const listener: app.Listener<"messageReactionAdd"> = { +export default new app.Listener({ event: "messageReactionAdd", description: "Handle the reactions for pagination", async run(reaction, user) { @@ -12,6 +12,4 @@ const listener: app.Listener<"messageReactionAdd"> = { if (paginator) return paginator.handleReaction(reaction, user) }, -} - -export default listener +}) diff --git a/src/listeners/remind.afterReady.ts b/src/listeners/remind.afterReady.ts deleted file mode 100644 index d7708b1f..00000000 --- a/src/listeners/remind.afterReady.ts +++ /dev/null @@ -1,13 +0,0 @@ -import * as app from "#app" - -const listener: app.Listener<"afterReady"> = { - event: "afterReady", - description: "A afterReady listener for remind", - async run() { - setInterval(async () => { - await app.checkReminds() - }, 1000) - }, -} - -export default listener diff --git a/src/listeners/reply.messageCreate.ts b/src/listeners/reply.messageCreate.ts index d8ff1641..688390aa 100644 --- a/src/listeners/reply.messageCreate.ts +++ b/src/listeners/reply.messageCreate.ts @@ -1,6 +1,6 @@ import * as app from "#app" -const listener: app.Listener<"messageCreate"> = { +export default new app.Listener({ event: "messageCreate", description: "A messageCreate listener for reply", async run(message) { @@ -29,6 +29,4 @@ const listener: app.Listener<"messageCreate"> = { await message.channel.send(r.message) } }, -} - -export default listener +}) diff --git a/src/listeners/restart.ready.ts b/src/listeners/restart.ready.ts index 032d6a95..9244d3b9 100644 --- a/src/listeners/restart.ready.ts +++ b/src/listeners/restart.ready.ts @@ -6,7 +6,7 @@ import { filename } from "dirname-filename-esm" const __filename = filename(import.meta) -const listener: app.Listener<"ready"> = { +export default new app.Listener({ event: "ready", description: "Send restart messages", once: true, @@ -46,6 +46,4 @@ const listener: app.Listener<"ready"> = { await restart.query.delete() }, -} - -export default listener +}) diff --git a/src/listeners/slash.guildCreate.native.ts b/src/listeners/slash.guildCreate.native.ts index 760cada3..2947a9f7 100644 --- a/src/listeners/slash.guildCreate.native.ts +++ b/src/listeners/slash.guildCreate.native.ts @@ -1,12 +1,12 @@ +// system file, please don't modify it + import * as app from "#app" -const listener: app.Listener<"guildCreate"> = { +export default new app.Listener({ event: "guildCreate", description: "Deploy the slash commands to the new guild", async run(guild) { if (app.env.BOT_GUILD !== guild.id) return return app.registerSlashCommands(guild.client, guild.id) }, -} - -export default listener +}) diff --git a/src/listeners/slash.interactionCreate.native.ts b/src/listeners/slash.interactionCreate.native.ts index b94e1052..0d8e20e0 100644 --- a/src/listeners/slash.interactionCreate.native.ts +++ b/src/listeners/slash.interactionCreate.native.ts @@ -1,6 +1,8 @@ +// system file, please don't modify it + import * as app from "#app" -const listener: app.Listener<"interactionCreate"> = { +export default new app.Listener({ event: "interactionCreate", description: "Handle the interactions for slash commands", async run(interaction) { @@ -68,6 +70,4 @@ const listener: app.Listener<"interactionCreate"> = { } } }, -} - -export default listener +}) diff --git a/src/listeners/slash.ready.native.ts b/src/listeners/slash.ready.native.ts index a5cdd6aa..0e0023ba 100644 --- a/src/listeners/slash.ready.native.ts +++ b/src/listeners/slash.ready.native.ts @@ -1,6 +1,8 @@ +// system file, please don't modify it + import * as app from "#app" -const listener: app.Listener<"ready"> = { +export default new app.Listener({ event: "ready", description: "Deploy the slash commands", once: true, @@ -9,6 +11,4 @@ const listener: app.Listener<"ready"> = { return app.registerSlashCommands(client, app.env.BOT_GUILD) return app.registerSlashCommands(client) }, -} - -export default listener +}) diff --git a/src/listeners/tracker.guildMemberAdd.ts b/src/listeners/tracker.guildMemberAdd.ts index 40117071..ef1f8f41 100644 --- a/src/listeners/tracker.guildMemberAdd.ts +++ b/src/listeners/tracker.guildMemberAdd.ts @@ -1,11 +1,9 @@ import * as app from "#app" -const listener: app.Listener<"guildMemberAdd"> = { +export default new app.Listener({ event: "guildMemberAdd", description: "Update the tracker", async run(member) { await app.updateGuildMemberCountTracker(member.guild) }, -} - -export default listener +}) diff --git a/src/listeners/tracker.guildMemberRemove.ts b/src/listeners/tracker.guildMemberRemove.ts index 41cfb9c2..b04a0516 100644 --- a/src/listeners/tracker.guildMemberRemove.ts +++ b/src/listeners/tracker.guildMemberRemove.ts @@ -1,11 +1,9 @@ import * as app from "#app" -const listener: app.Listener<"guildMemberRemove"> = { +export default new app.Listener({ event: "guildMemberRemove", description: "Update the tracker", async run(member) { await app.updateGuildMemberCountTracker(member.guild) }, -} - -export default listener +}) diff --git a/src/listeners/tracker.ready.ts b/src/listeners/tracker.ready.ts deleted file mode 100644 index 50ff038c..00000000 --- a/src/listeners/tracker.ready.ts +++ /dev/null @@ -1,20 +0,0 @@ -import * as app from "#app" - -const listener: app.Listener<"ready"> = { - event: "ready", - description: "Launch the hourly check for tracker", - once: true, - async run(client) { - setInterval( - async () => { - for (const guild of client.guilds.cache.values()) { - await app.updateGuildOnlineCountTracker(guild) - await app.updateGuildMessageCountTracker(guild) - } - }, - 1000 * 60 * 5, - ) // 5 minutes - }, -} - -export default listener diff --git a/src/listeners/traffic.guildMemberAdd.ts b/src/listeners/traffic.guildMemberAdd.ts index 1aa8b07e..5a87b4e0 100644 --- a/src/listeners/traffic.guildMemberAdd.ts +++ b/src/listeners/traffic.guildMemberAdd.ts @@ -4,7 +4,7 @@ import { filename } from "dirname-filename-esm" const __filename = filename(import.meta) -const listener: app.Listener<"guildMemberAdd"> = { +export default new app.Listener({ event: "guildMemberAdd", description: "Prepares to welcome a new member", async run(member) { @@ -82,6 +82,4 @@ const listener: app.Listener<"guildMemberAdd"> = { config, ) }, -} - -export default listener +}) diff --git a/src/listeners/traffic.guildMemberRemove.ts b/src/listeners/traffic.guildMemberRemove.ts index 6fb9350f..23286d74 100644 --- a/src/listeners/traffic.guildMemberRemove.ts +++ b/src/listeners/traffic.guildMemberRemove.ts @@ -1,6 +1,6 @@ import * as app from "#app" -const listener: app.Listener<"guildMemberRemove"> = { +export default new app.Listener({ event: "guildMemberRemove", description: "Announces when a member leaves the server", async run(member) { @@ -43,6 +43,4 @@ const listener: app.Listener<"guildMemberRemove"> = { } } }, -} - -export default listener +}) diff --git a/src/namespaces/active.ts b/src/namespaces/active.ts index a5711ba6..2aecfe09 100644 --- a/src/namespaces/active.ts +++ b/src/namespaces/active.ts @@ -245,80 +245,3 @@ export const activeLadder = (guild_id: number) => )}\` msg - <@${line.target}>` }, }) - -export async function launchActiveInterval( - guild: app.OAuth2Guild | app.Guild, - options: { - period: number - messageCount: number - refreshInterval: number - }, -) { - const config = await app.getGuild(guild, { forceExists: true }) - - const intervalId = app.activeIntervalCacheId(guild) - const interval = app.cache.get(intervalId) - - if (interval !== undefined) clearInterval(interval) - - app.cache.set( - intervalId, - setInterval( - async () => { - const realGuild = await guild.fetch() - - if (!(await app.hasActivity(config._id, options.refreshInterval))) - return - - let found: number - - try { - found = await app.updateActive(realGuild, { - force: false, - period: options.period, - messageCount: options.messageCount, - guildConfig: config, - }) - } catch (error: any) { - await app.sendLog( - realGuild, - `Failed to update the active list...${await app.code.stringify({ - content: error.message, - lang: "js", - })}`, - ) - - return - } - - const cacheId = app.lastActiveCountCacheId(realGuild) - - const lastActiveCount = app.cache.ensure(cacheId, 0) - - if (found > lastActiveCount) { - await app.sendLog( - realGuild, - `Finished updating the active list, found **${ - found - lastActiveCount - }** active members.`, - ) - } else if (found < lastActiveCount) { - await app.sendLog( - realGuild, - `Finished updating the active list, **${ - lastActiveCount - found - }** members have been removed.`, - ) - } else { - await app.sendLog( - realGuild, - `Finished updating the active list, no changes were made.`, - ) - } - - app.cache.set(cacheId, found) - }, - options.refreshInterval * 1000 * 60 * 60, - ), - ) -} diff --git a/src/namespaces/tools.ts b/src/namespaces/tools.ts index 5413cb21..5d2ac315 100644 --- a/src/namespaces/tools.ts +++ b/src/namespaces/tools.ts @@ -68,6 +68,10 @@ export async function getGuild( guild: { id: string }, options: { forceExists: true; forceFetch?: boolean }, ): Promise +export async function getGuild( + guild: { id: string }, + options?: { forceExists?: boolean; forceFetch: true }, +): Promise export async function getGuild( guild: { id: string }, options?: { forceExists?: boolean; forceFetch?: boolean }, diff --git a/src/tables/guild.ts b/src/tables/guild.ts index 58caffdf..3892e7da 100644 --- a/src/tables/guild.ts +++ b/src/tables/guild.ts @@ -29,7 +29,6 @@ export interface Guild { online_tracker_pattern: string active_period: `${number}` active_message_count: `${number}` - active_refresh_interval: `${number}` resolved_channel_indicator: string resolved_channel_tag: string | null } @@ -67,6 +66,9 @@ export default new Table({ 7: (table) => { table.string("help_forum_channel_id") }, + 8: (table) => { + table.dropColumn("active_refresh_interval") + }, }, setup: (table) => { table.increments("_id", { primaryKey: true }).unsigned() diff --git a/templates/button b/templates/button index ba7a0ac7..704cf5e3 100644 --- a/templates/button +++ b/templates/button @@ -13,7 +13,7 @@ export type {{ Name }}ButtonParams = {} * See the {@link https://ghom.gitbook.io/bot.ts/usage/create-a-button guide} for more information. */ export default new app.Button<{{ Name }}ButtonParams>({ - key: "{{ name }}", + name: "{{ name }}", description: "The {{ name }} button", builder: (builder) => builder.setLabel("{{ Name }}"), async run(interaction /*, ...params */) { diff --git a/templates/cron b/templates/cron new file mode 100644 index 00000000..cec67581 --- /dev/null +++ b/templates/cron @@ -0,0 +1,13 @@ +import * as app from "#app" + +/** + * See the {@link https://ghom.gitbook.io/bot.ts/usage/create-a-cron cron guide} for more information. + */ +export default new app.Cron({ + name: "{{ name }}", + description: "A {{ name }} cron", + schedule: "hourly", + async run() { + // todo: code here + } +}) \ No newline at end of file diff --git a/templates/listener b/templates/listener index bd48a4a0..f286485e 100644 --- a/templates/listener +++ b/templates/listener @@ -1,14 +1,12 @@ import * as app from "#app" /** - * See the {@link https://ghom.gitbook.io/bot.ts/usage/create-a-listener guide} for more information. + * See the {@link https://ghom.gitbook.io/bot.ts/usage/create-a-listener listener guide} for more information. */ -const listener: app.Listener<"{{ event }}"> = { +export default new app.Listener({ event: "{{ event }}", description: "A {{ event }} listener for {{ category }}", async run({{ args }}) { - // todo: code here + // todo: code here } -} - -export default listener \ No newline at end of file +}) \ No newline at end of file diff --git a/templates/table b/templates/table index 72a024ae..d03c00f7 100644 --- a/templates/table +++ b/templates/table @@ -5,7 +5,7 @@ export interface {{ Name }} { } /** - * See the {@link https://ghom.gitbook.io/bot.ts/usage/use-database guide} for more information. + * See the {@link https://ghom.gitbook.io/bot.ts/usage/use-database database guide} for more information. */ export default new Table<{{ Name }}>({ name: "{{ name }}", diff --git a/tests/index.test.js b/tests/index.test.js index 3e6317b7..f019e4bd 100644 --- a/tests/index.test.js +++ b/tests/index.test.js @@ -4,6 +4,7 @@ process.env.BOT_MODE = "test" const app = await import("#app") try { + await app.cronHandler.init() await app.database.handler.init() await app.buttonHandler.init() await app.commandHandler.init()