From d7dc06b4747186b8729ee58583330e9e763acef0 Mon Sep 17 00:00:00 2001 From: twlite <46562212+twlite@users.noreply.github.com> Date: Thu, 24 Jul 2025 20:39:00 +0545 Subject: [PATCH] feat: tasks plugin --- .github/workflows/publish-dev.yaml | 2 + .github/workflows/publish-latest.yaml | 1 + apps/test-bot/.gitignore | 2 +- apps/test-bot/commandkit.config.ts | 2 + apps/test-bot/package.json | 3 + apps/test-bot/src/app.ts | 4 + apps/test-bot/src/app/commands/remind.ts | 72 ++++ apps/test-bot/src/app/tasks/remind.ts | 31 ++ .../ai/functions/configure-ai.mdx | 2 +- .../ai/functions/get-aiconfig.mdx | 2 +- .../commandkit/classes/command-kit.mdx | 2 +- .../functions/on-application-bootstrap.mdx | 2 +- .../commandkit/functions/on-bootstrap.mdx | 2 +- .../interfaces/command-kit-configuration.mdx | 2 +- .../commandkit/types/bootstrap-function.mdx | 2 +- .../commandkit/variables/commandkit.mdx | 2 +- .../guide/17-tasks/01-getting-started.mdx | 156 ++++++++ .../docs/guide/17-tasks/02-task-drivers.mdx | 230 +++++++++++ .../docs/guide/17-tasks/03-dynamic-tasks.mdx | 337 ++++++++++++++++ .../guide/17-tasks/04-advanced-patterns.mdx | 365 ++++++++++++++++++ packages/commandkit/package.json | 2 +- packages/commandkit/src/index.ts | 2 + packages/tasks/LICENSE | 11 + packages/tasks/README.md | 301 +++++++++++++++ packages/tasks/package.json | 59 +++ packages/tasks/src/context.ts | 119 ++++++ packages/tasks/src/driver-manager.ts | 155 ++++++++ packages/tasks/src/driver.ts | 78 ++++ packages/tasks/src/drivers/bullmq.ts | 126 ++++++ packages/tasks/src/drivers/hypercron.ts | 96 +++++ packages/tasks/src/index.ts | 38 ++ packages/tasks/src/plugin.ts | 232 +++++++++++ packages/tasks/src/task.ts | 134 +++++++ packages/tasks/src/types.ts | 88 +++++ packages/tasks/tsconfig.json | 16 + pnpm-lock.yaml | 168 +++++++- 36 files changed, 2835 insertions(+), 11 deletions(-) create mode 100644 apps/test-bot/src/app/commands/remind.ts create mode 100644 apps/test-bot/src/app/tasks/remind.ts create mode 100644 apps/website/docs/guide/17-tasks/01-getting-started.mdx create mode 100644 apps/website/docs/guide/17-tasks/02-task-drivers.mdx create mode 100644 apps/website/docs/guide/17-tasks/03-dynamic-tasks.mdx create mode 100644 apps/website/docs/guide/17-tasks/04-advanced-patterns.mdx create mode 100644 packages/tasks/LICENSE create mode 100644 packages/tasks/README.md create mode 100644 packages/tasks/package.json create mode 100644 packages/tasks/src/context.ts create mode 100644 packages/tasks/src/driver-manager.ts create mode 100644 packages/tasks/src/driver.ts create mode 100644 packages/tasks/src/drivers/bullmq.ts create mode 100644 packages/tasks/src/drivers/hypercron.ts create mode 100644 packages/tasks/src/index.ts create mode 100644 packages/tasks/src/plugin.ts create mode 100644 packages/tasks/src/task.ts create mode 100644 packages/tasks/src/types.ts create mode 100644 packages/tasks/tsconfig.json diff --git a/.github/workflows/publish-dev.yaml b/.github/workflows/publish-dev.yaml index 3fd4cc3d..839646cd 100644 --- a/.github/workflows/publish-dev.yaml +++ b/.github/workflows/publish-dev.yaml @@ -61,6 +61,7 @@ jobs: "@commandkit/analytics:packages/analytics" "@commandkit/ai:packages/ai" "@commandkit/queue:packages/queue" + "@commandkit/tasks:packages/tasks" ) for entry in "${PACKAGES[@]}"; do @@ -84,6 +85,7 @@ jobs: "@commandkit/analytics" "@commandkit/ai" "@commandkit/queue" + "@commandkit/tasks" ) for pkg in "${PACKAGES[@]}"; do diff --git a/.github/workflows/publish-latest.yaml b/.github/workflows/publish-latest.yaml index 20b22c46..8446ea98 100644 --- a/.github/workflows/publish-latest.yaml +++ b/.github/workflows/publish-latest.yaml @@ -40,6 +40,7 @@ jobs: "@commandkit/devtools:packages/devtools" "@commandkit/cache:packages/cache" "@commandkit/queue:packages/queue" + "@commandkit/tasks:packages/tasks" ) for entry in "${PACKAGES[@]}"; do diff --git a/apps/test-bot/.gitignore b/apps/test-bot/.gitignore index 9f29836c..18b4e1cb 100644 --- a/apps/test-bot/.gitignore +++ b/apps/test-bot/.gitignore @@ -1,4 +1,4 @@ .env .commandkit compiled-commandkit.config.mjs -commandkit*.db* \ No newline at end of file +*.db* \ No newline at end of file diff --git a/apps/test-bot/commandkit.config.ts b/apps/test-bot/commandkit.config.ts index 86dea81c..3b330519 100644 --- a/apps/test-bot/commandkit.config.ts +++ b/apps/test-bot/commandkit.config.ts @@ -4,6 +4,7 @@ import { i18n } from '@commandkit/i18n'; import { devtools } from '@commandkit/devtools'; import { cache } from '@commandkit/cache'; import { ai } from '@commandkit/ai'; +import { tasks } from '@commandkit/tasks'; export default defineConfig({ plugins: [ @@ -12,5 +13,6 @@ export default defineConfig({ devtools(), cache(), ai(), + tasks(), ], }); diff --git a/apps/test-bot/package.json b/apps/test-bot/package.json index 840f59d5..f64bb21c 100644 --- a/apps/test-bot/package.json +++ b/apps/test-bot/package.json @@ -16,12 +16,15 @@ "@commandkit/devtools": "workspace:*", "@commandkit/i18n": "workspace:*", "@commandkit/legacy": "workspace:*", + "@commandkit/tasks": "workspace:*", "commandkit": "workspace:*", "discord.js": "catalog:discordjs", "dotenv": "^16.4.7", + "ms": "^2.1.3", "zod": "^3.25.56" }, "devDependencies": { + "@types/ms": "^2.1.0", "tsx": "^4.7.0" } } diff --git a/apps/test-bot/src/app.ts b/apps/test-bot/src/app.ts index 56561a81..c55e3a7e 100644 --- a/apps/test-bot/src/app.ts +++ b/apps/test-bot/src/app.ts @@ -1,5 +1,7 @@ import { Client } from 'discord.js'; import { Logger, commandkit } from 'commandkit'; +import { setDriver } from '@commandkit/tasks'; +import { HyperCronDriver } from '@commandkit/tasks/hypercron'; import './ai.ts'; const client = new Client({ @@ -12,6 +14,8 @@ const client = new Client({ ], }); +setDriver(new HyperCronDriver()); + Logger.log('Application bootstrapped successfully!'); commandkit.setPrefixResolver((message) => { diff --git a/apps/test-bot/src/app/commands/remind.ts b/apps/test-bot/src/app/commands/remind.ts new file mode 100644 index 00000000..068847cf --- /dev/null +++ b/apps/test-bot/src/app/commands/remind.ts @@ -0,0 +1,72 @@ +import type { CommandData, ChatInputCommand, MessageCommand } from 'commandkit'; +import { ApplicationCommandOptionType } from 'discord.js'; +import ms from 'ms'; +import { createTask } from '@commandkit/tasks'; +import { RemindTaskData } from '../tasks/remind'; + +export const command: CommandData = { + name: 'remind', + description: 'remind command', + options: [ + { + name: 'time', + description: 'The time to remind after. Eg: 6h, 10m, 1d', + type: ApplicationCommandOptionType.String, + required: true, + }, + { + name: 'message', + description: 'The message to remind about.', + type: ApplicationCommandOptionType.String, + required: true, + }, + ], +}; + +export const chatInput: ChatInputCommand = async (ctx) => { + const time = ctx.options.getString('time', true); + const message = ctx.options.getString('message', true); + const timeMs = Date.now() + ms(time as `${number}`); + + await createTask({ + name: 'remind', + data: { + userId: ctx.interaction.user.id, + message, + channelId: ctx.interaction.channelId, + setAt: Date.now(), + } satisfies RemindTaskData, + schedule: { + type: 'date', + value: timeMs, + }, + }); + + await ctx.interaction.reply( + `I will remind you for \`${message}\``, + ); +}; + +export const message: MessageCommand = async (ctx) => { + const [time, ...messageParts] = ctx.args(); + const message = messageParts.join(' '); + const timeMs = Date.now() + ms(time as `${number}`); + + await createTask({ + name: 'remind', + data: { + userId: ctx.message.author.id, + message, + channelId: ctx.message.channelId, + setAt: Date.now(), + } satisfies RemindTaskData, + schedule: { + type: 'date', + value: timeMs, + }, + }); + + await ctx.message.reply( + `I will remind you for \`${message}\``, + ); +}; diff --git a/apps/test-bot/src/app/tasks/remind.ts b/apps/test-bot/src/app/tasks/remind.ts new file mode 100644 index 00000000..119cf12f --- /dev/null +++ b/apps/test-bot/src/app/tasks/remind.ts @@ -0,0 +1,31 @@ +import { task } from '@commandkit/tasks'; + +export interface RemindTaskData { + userId: string; + message: string; + channelId: string; + setAt: number; +} + +export default task({ + name: 'remind', + async execute(ctx) { + const { userId, message, channelId, setAt } = ctx.data; + + const channel = await ctx.client.channels.fetch(channelId); + + if (!channel?.isTextBased() || !channel.isSendable()) return; + + await channel.send({ + content: `<@${userId}>`, + embeds: [ + { + title: `You asked me to remind you about:`, + description: message, + color: 0x0099ff, + timestamp: new Date(setAt).toISOString(), + }, + ], + }); + }, +}); diff --git a/apps/website/docs/api-reference/ai/functions/configure-ai.mdx b/apps/website/docs/api-reference/ai/functions/configure-ai.mdx index 77fe6d1d..5862e579 100644 --- a/apps/website/docs/api-reference/ai/functions/configure-ai.mdx +++ b/apps/website/docs/api-reference/ai/functions/configure-ai.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## configureAI - + Configures the AI plugin with the provided options. This function allows you to set a message filter, select an AI model, and generate a system prompt. diff --git a/apps/website/docs/api-reference/ai/functions/get-aiconfig.mdx b/apps/website/docs/api-reference/ai/functions/get-aiconfig.mdx index 0bb707b7..4d79d118 100644 --- a/apps/website/docs/api-reference/ai/functions/get-aiconfig.mdx +++ b/apps/website/docs/api-reference/ai/functions/get-aiconfig.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## getAIConfig - + Retrieves the current AI configuration. diff --git a/apps/website/docs/api-reference/commandkit/classes/command-kit.mdx b/apps/website/docs/api-reference/commandkit/classes/command-kit.mdx index 0e042d4d..9301cfac 100644 --- a/apps/website/docs/api-reference/commandkit/classes/command-kit.mdx +++ b/apps/website/docs/api-reference/commandkit/classes/command-kit.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## CommandKit - + The commandkit class that serves as the main entry point for the CommandKit framework. diff --git a/apps/website/docs/api-reference/commandkit/functions/on-application-bootstrap.mdx b/apps/website/docs/api-reference/commandkit/functions/on-application-bootstrap.mdx index ba56cde8..3a99fd07 100644 --- a/apps/website/docs/api-reference/commandkit/functions/on-application-bootstrap.mdx +++ b/apps/website/docs/api-reference/commandkit/functions/on-application-bootstrap.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## onApplicationBootstrap - + Registers a bootstrap hook that will be called when the CommandKit instance is created. This is useful for plugins that need to run some code after the CommandKit instance is fully initialized. diff --git a/apps/website/docs/api-reference/commandkit/functions/on-bootstrap.mdx b/apps/website/docs/api-reference/commandkit/functions/on-bootstrap.mdx index 231edfab..31a15aaf 100644 --- a/apps/website/docs/api-reference/commandkit/functions/on-bootstrap.mdx +++ b/apps/website/docs/api-reference/commandkit/functions/on-bootstrap.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## onBootstrap - + Registers a bootstrap hook that will be called when the CommandKit instance is created. This is useful for plugins that need to run some code after the CommandKit instance is fully initialized. diff --git a/apps/website/docs/api-reference/commandkit/interfaces/command-kit-configuration.mdx b/apps/website/docs/api-reference/commandkit/interfaces/command-kit-configuration.mdx index 681bc7f5..dad63115 100644 --- a/apps/website/docs/api-reference/commandkit/interfaces/command-kit-configuration.mdx +++ b/apps/website/docs/api-reference/commandkit/interfaces/command-kit-configuration.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## CommandKitConfiguration - + Configurations for the CommandKit instance. diff --git a/apps/website/docs/api-reference/commandkit/types/bootstrap-function.mdx b/apps/website/docs/api-reference/commandkit/types/bootstrap-function.mdx index 91ea849e..388df43a 100644 --- a/apps/website/docs/api-reference/commandkit/types/bootstrap-function.mdx +++ b/apps/website/docs/api-reference/commandkit/types/bootstrap-function.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## BootstrapFunction - + Represents the function executed during the bootstrap phase of CommandKit. diff --git a/apps/website/docs/api-reference/commandkit/variables/commandkit.mdx b/apps/website/docs/api-reference/commandkit/variables/commandkit.mdx index 008c5e95..315b5a02 100644 --- a/apps/website/docs/api-reference/commandkit/variables/commandkit.mdx +++ b/apps/website/docs/api-reference/commandkit/variables/commandkit.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## commandkit - + The singleton instance of CommandKit. diff --git a/apps/website/docs/guide/17-tasks/01-getting-started.mdx b/apps/website/docs/guide/17-tasks/01-getting-started.mdx new file mode 100644 index 00000000..bf31501c --- /dev/null +++ b/apps/website/docs/guide/17-tasks/01-getting-started.mdx @@ -0,0 +1,156 @@ +--- +title: Getting Started with Tasks +description: Learn how to set up and use the tasks plugin for scheduling background tasks in your CommandKit bot. +--- + +# Getting Started with Tasks + +The tasks plugin provides a powerful way to schedule and manage background tasks in your CommandKit bot. Whether you need to run periodic maintenance, send scheduled reminders, or perform data cleanup, the tasks plugin has you covered. + +## Installation + +First, install the tasks plugin: + +```bash +npm install @commandkit/tasks +``` + +## Basic Setup + +Add the tasks plugin to your CommandKit configuration: + +```ts +import { tasks } from '@commandkit/tasks'; + +export default { + plugins: [tasks()], +}; +``` + +## Creating Your First Task + +Create a file in `src/app/tasks/` to define your tasks: + +```ts +import { task } from '@commandkit/tasks'; + +export const dailyBackup = task({ + name: 'daily-backup', + schedule: { type: 'cron', value: '0 0 * * *' }, // Daily at midnight + async execute(ctx) { + // Your task logic here + await performBackup(); + console.log('Daily backup completed!'); + }, +}); +``` + +## Task Structure + +Every task has the following components: + +- **name**: A unique identifier for the task +- **schedule**: When the task should run (optional for manual execution) +- **execute**: The main function that performs the task work +- **prepare**: Optional function to determine if the task should run + +## Schedule Types + +### Cron Schedules + +Use cron expressions for recurring tasks: + +```ts +export const hourlyTask = task({ + name: 'hourly-task', + schedule: { type: 'cron', value: '0 * * * *' }, // Every hour + async execute(ctx) { + // Task logic + }, +}); +``` + +### Date Schedules + +Schedule tasks for specific times: + +```ts +export const reminderTask = task({ + name: 'reminder', + schedule: { type: 'date', value: new Date('2024-01-01T12:00:00Z') }, + async execute(ctx) { + // Send reminder + }, +}); +``` + +### Timezone Support + +Specify timezones for your schedules: + +```ts +export const timezoneTask = task({ + name: 'timezone-task', + schedule: { + type: 'cron', + value: '0 9 * * *', + timezone: 'America/New_York', + }, + async execute(ctx) { + // Runs at 9 AM Eastern Time + }, +}); +``` + +## Task Context + +The `execute` function receives a context object with useful properties: + +```ts +export const contextExample = task({ + name: 'context-example', + schedule: { type: 'cron', value: '0 */6 * * *' }, + async execute(ctx) { + // Access the Discord.js client + const client = ctx.commandkit.client; + + // Access custom data (for dynamic tasks) + const { userId, message } = ctx.data; + + // Use the temporary store + ctx.store.set('lastRun', Date.now()); + + // Send a message to a channel + const channel = client.channels.cache.get('channel-id'); + if (channel?.isTextBased()) { + await channel.send('Task executed!'); + } + }, +}); +``` + +## Conditional Execution + +Use the `prepare` function to conditionally execute tasks: + +```ts +export const conditionalTask = task({ + name: 'conditional-task', + schedule: { type: 'cron', value: '0 */2 * * *' }, // Every 2 hours + async prepare(ctx) { + // Only run if maintenance mode is not enabled + return !ctx.commandkit.store.get('maintenance-mode'); + }, + async execute(ctx) { + await performMaintenanceChecks(); + }, +}); +``` + +## Next Steps + +Now that you have the basics, explore: + +- [Task Drivers](./02-task-drivers) - Learn about different persistence options +- [Dynamic Tasks](./03-dynamic-tasks) - Create tasks on-demand from commands +- [Advanced Patterns](./04-advanced-patterns) - Best practices and advanced usage diff --git a/apps/website/docs/guide/17-tasks/02-task-drivers.mdx b/apps/website/docs/guide/17-tasks/02-task-drivers.mdx new file mode 100644 index 00000000..07c867d4 --- /dev/null +++ b/apps/website/docs/guide/17-tasks/02-task-drivers.mdx @@ -0,0 +1,230 @@ +--- +title: Task Drivers +description: Learn about different task drivers and how to configure them for your use case. +--- + +# Task Drivers + +Task drivers handle the persistence and scheduling of your tasks. The tasks plugin supports multiple drivers to fit different deployment scenarios and requirements. + +## Available Drivers + +### HyperCron Driver (Default) + +The HyperCron driver provides lightweight, in-memory task scheduling. It's perfect for single-instance applications or development environments. + +**Pros:** + +- Simple setup, no external dependencies +- Lightweight and fast +- Perfect for development and single-instance bots + +**Cons:** + +- Tasks are lost on restart +- No distributed scheduling support +- Limited to single instance + +**Installation:** + +```bash +npm install hypercron +``` + +**Usage:** + +```ts +import { HyperCronDriver } from '@commandkit/tasks/hypercron'; +import { setDriver } from '@commandkit/tasks'; + +const driver = new HyperCronDriver(); +setDriver(driver); +``` + +### BullMQ Driver + +The BullMQ driver provides robust, distributed task scheduling using Redis as the backend. It's ideal for production environments with multiple bot instances. + +**Pros:** + +- Distributed scheduling across multiple instances +- Persistent task storage +- Built-in retry mechanisms +- Production-ready with Redis + +**Cons:** + +- Requires Redis server +- More complex setup +- Additional dependency + +**Installation:** + +```bash +npm install bullmq +``` + +**Usage:** + +```ts +import { BullMQDriver } from '@commandkit/tasks/bullmq'; +import { setDriver } from '@commandkit/tasks'; + +const driver = new BullMQDriver( + { + host: 'localhost', + port: 6379, + }, + 'my-tasks-queue', +); + +setDriver(driver); +``` + +**Advanced Redis Configuration:** + +```ts +const driver = new BullMQDriver({ + host: 'redis.example.com', + port: 6379, + password: 'your-password', + tls: true, + retryDelayOnFailover: 100, + maxRetriesPerRequest: 3, +}); +``` + +## Setting Up Drivers + +### Basic Setup + +Configure your driver before the tasks plugin loads: + +```ts +import { tasks } from '@commandkit/tasks'; +import { HyperCronDriver } from '@commandkit/tasks/hypercron'; +import { setDriver } from '@commandkit/tasks'; + +// Set up the driver +setDriver(new HyperCronDriver()); + +export default { + plugins: [tasks()], +}; +``` + +### Advanced Setup with Custom Configuration + +```ts +import { tasks } from '@commandkit/tasks'; +import { BullMQDriver } from '@commandkit/tasks/bullmq'; +import { setDriver } from '@commandkit/tasks'; + +// Configure BullMQ with custom settings +const driver = new BullMQDriver( + { + host: process.env.REDIS_HOST || 'localhost', + port: parseInt(process.env.REDIS_PORT || '6379'), + password: process.env.REDIS_PASSWORD, + }, + 'commandkit-tasks', +); + +setDriver(driver); + +export default { + plugins: [tasks()], +}; +``` + +## Driver Comparison + +| Feature | HyperCron | BullMQ | +| -------------------- | ----------- | --------------- | +| **Setup Complexity** | Simple | Moderate | +| **Dependencies** | `hypercron` | `bullmq`, Redis | +| **Persistence** | In-memory | Redis | +| **Distributed** | No | Yes | +| **Retry Logic** | Basic | Advanced | +| **Production Ready** | Development | Yes | +| **Memory Usage** | Low | Moderate | + +## Choosing the Right Driver + +### Use HyperCron when: + +- You're developing locally +- You have a single bot instance +- You don't need task persistence across restarts +- You want minimal setup complexity + +### Use BullMQ when: + +- You have multiple bot instances +- You need task persistence across restarts +- You're deploying to production +- You need advanced features like retries and monitoring + +## Environment-Specific Configuration + +### Development Environment + +```ts +import { HyperCronDriver } from '@commandkit/tasks/hypercron'; + +if (process.env.NODE_ENV === 'development') { + setDriver(new HyperCronDriver()); +} +``` + +### Production Environment + +```ts +import { BullMQDriver } from '@commandkit/tasks/bullmq'; + +if (process.env.NODE_ENV === 'production') { + setDriver( + new BullMQDriver({ + host: process.env.REDIS_HOST, + port: parseInt(process.env.REDIS_PORT || '6379'), + password: process.env.REDIS_PASSWORD, + }), + ); +} +``` + +## Custom Drivers + +You can create your own driver by implementing the `TaskDriver` interface: + +```ts +import { TaskDriver, TaskRunner } from '@commandkit/tasks'; +import { TaskData } from '@commandkit/tasks'; + +class CustomDriver implements TaskDriver { + private runner: TaskRunner | null = null; + + async create(task: TaskData): Promise { + // Implement your scheduling logic + const id = await this.scheduler.schedule(task); + return id; + } + + async delete(task: string): Promise { + // Implement your deletion logic + await this.scheduler.cancel(task); + } + + async setTaskRunner(runner: TaskRunner): Promise { + this.runner = runner; + } +} + +// Use your custom driver +setDriver(new CustomDriver()); +``` + +## Next Steps + +- [Dynamic Tasks](./03-dynamic-tasks) - Learn how to create tasks on-demand +- [Advanced Patterns](./04-advanced-patterns) - Best practices for task management diff --git a/apps/website/docs/guide/17-tasks/03-dynamic-tasks.mdx b/apps/website/docs/guide/17-tasks/03-dynamic-tasks.mdx new file mode 100644 index 00000000..9fbc0921 --- /dev/null +++ b/apps/website/docs/guide/17-tasks/03-dynamic-tasks.mdx @@ -0,0 +1,337 @@ +--- +title: Dynamic Tasks +description: Learn how to create tasks on-demand from commands and events. +--- + +# Dynamic Tasks + +While static tasks are great for recurring operations, you often need to create tasks dynamically based on user interactions or events. The tasks plugin provides utilities for creating tasks on-demand. + +## Creating Dynamic Tasks + +Use the `createTask` function to create tasks programmatically: + +```ts +import { createTask } from '@commandkit/tasks'; + +// Create a task that runs in 5 minutes +const taskId = await createTask({ + name: 'reminder', + data: { userId: '123', message: "Don't forget your meeting!" }, + schedule: { type: 'date', value: Date.now() + 5 * 60 * 1000 }, +}); +``` + +## Reminder System Example + +Here's a complete example of a reminder command that creates dynamic tasks: + +```ts +import { createTask } from '@commandkit/tasks'; + +export default { + name: 'remind', + description: 'Set a reminder', + options: [ + { + name: 'time', + description: 'When to remind you (e.g., 5m, 1h, 1d)', + type: 'string', + required: true, + }, + { + name: 'message', + description: 'What to remind you about', + type: 'string', + required: true, + }, + ], + async run(ctx) { + const timeStr = ctx.interaction.options.getString('time', true); + const message = ctx.interaction.options.getString('message', true); + + // Parse time string (you can use a library like ms) + const delay = parseTime(timeStr); + if (!delay) { + await ctx.interaction.reply('Invalid time format. Use: 5m, 1h, 1d, etc.'); + return; + } + + // Create the reminder task + const taskId = await createTask({ + name: 'reminder', + data: { + userId: ctx.interaction.user.id, + channelId: ctx.interaction.channelId, + message, + }, + schedule: { type: 'date', value: Date.now() + delay }, + }); + + await ctx.interaction.reply(`Reminder set! I'll remind you in ${timeStr}.`); + }, +}; +``` + +## The Reminder Task + +Create a static task definition that handles the actual reminder: + +```ts +// src/app/tasks/reminder.ts +import { task } from '@commandkit/tasks'; + +export const reminder = task({ + name: 'reminder', + async execute(ctx) { + const { userId, channelId, message } = ctx.data; + + try { + const user = await ctx.commandkit.client.users.fetch(userId); + await user.send(`Reminder: ${message}`); + } catch (error) { + // If DM fails, try to send to the original channel + const channel = ctx.commandkit.client.channels.cache.get(channelId); + if (channel?.isTextBased()) { + await channel.send(`<@${userId}> Reminder: ${message}`); + } + } + }, +}); +``` + +## Advanced Dynamic Task Patterns + +### Conditional Task Creation + +```ts +export default { + name: 'schedule-maintenance', + description: 'Schedule maintenance for a specific time', + async run(ctx) { + const maintenanceMode = ctx.commandkit.store.get('maintenance-mode'); + + if (maintenanceMode) { + await ctx.interaction.reply('Maintenance mode is already enabled.'); + return; + } + + // Create maintenance task + await createTask({ + name: 'maintenance', + data: { + requestedBy: ctx.interaction.user.id, + duration: 30 * 60 * 1000, // 30 minutes + }, + schedule: { type: 'date', value: Date.now() + 5 * 60 * 1000 }, // 5 minutes from now + }); + + await ctx.interaction.reply( + 'Maintenance scheduled for 5 minutes from now.', + ); + }, +}; +``` + +### Batch Task Creation + +```ts +export default { + name: 'schedule-events', + description: 'Schedule multiple events', + async run(ctx) { + const events = [ + { name: 'Event 1', time: Date.now() + 60 * 60 * 1000 }, + { name: 'Event 2', time: Date.now() + 2 * 60 * 60 * 1000 }, + { name: 'Event 3', time: Date.now() + 3 * 60 * 60 * 1000 }, + ]; + + const taskIds = await Promise.all( + events.map((event) => + createTask({ + name: 'event-notification', + data: { + eventName: event.name, + channelId: ctx.interaction.channelId, + }, + schedule: { type: 'date', value: event.time }, + }), + ), + ); + + await ctx.interaction.reply(`Scheduled ${events.length} events.`); + }, +}; +``` + +## Managing Dynamic Tasks + +### Deleting Tasks + +```ts +import { deleteTask } from '@commandkit/tasks'; + +export default { + name: 'cancel-reminder', + description: 'Cancel a scheduled reminder', + async run(ctx) { + const taskId = ctx.interaction.options.getString('task-id', true); + + try { + await deleteTask(taskId); + await ctx.interaction.reply('Reminder cancelled successfully.'); + } catch (error) { + await ctx.interaction.reply( + 'Failed to cancel reminder. It may have already been executed.', + ); + } + }, +}; +``` + +### Task Storage and Retrieval + +For more complex scenarios, you might want to store task IDs in your database: + +```ts +export default { + name: 'remind', + description: 'Set a reminder', + async run(ctx) { + const timeStr = ctx.interaction.options.getString('time', true); + const message = ctx.interaction.options.getString('message', true); + + const delay = parseTime(timeStr); + const taskId = await createTask({ + name: 'reminder', + data: { + userId: ctx.interaction.user.id, + message, + }, + schedule: { type: 'date', value: Date.now() + delay }, + }); + + // Store the task ID in your database + await db.reminders.create({ + userId: ctx.interaction.user.id, + taskId, + message, + scheduledFor: new Date(Date.now() + delay), + }); + + await ctx.interaction.reply(`Reminder set! Task ID: ${taskId}`); + }, +}; +``` + +## Error Handling + +Always handle potential errors when creating dynamic tasks: + +```ts +export default { + name: 'schedule-task', + description: 'Schedule a custom task', + async run(ctx) { + try { + const taskId = await createTask({ + name: 'custom-task', + data: { userId: ctx.interaction.user.id }, + schedule: { type: 'date', value: Date.now() + 60000 }, + }); + + await ctx.interaction.reply(`Task scheduled successfully. ID: ${taskId}`); + } catch (error) { + console.error('Failed to schedule task:', error); + await ctx.interaction.reply('Failed to schedule task. Please try again.'); + } + }, +}; +``` + +## Best Practices + +### 1. Use Descriptive Task Names + +```ts +// Good +await createTask({ + name: 'user-reminder', + data: { userId, message }, + schedule: { type: 'date', value: reminderTime }, +}); + +// Avoid +await createTask({ + name: 'task', + data: { userId, message }, + schedule: { type: 'date', value: reminderTime }, +}); +``` + +### 2. Validate Input Data + +```ts +export default { + name: 'schedule-reminder', + async run(ctx) { + const timeStr = ctx.interaction.options.getString('time', true); + const message = ctx.interaction.options.getString('message', true); + + // Validate time format + const delay = parseTime(timeStr); + if (!delay || delay < 60000 || delay > 30 * 24 * 60 * 60 * 1000) { + await ctx.interaction.reply( + 'Please specify a time between 1 minute and 30 days.', + ); + return; + } + + // Validate message length + if (message.length > 1000) { + await ctx.interaction.reply( + 'Message too long. Please keep it under 1000 characters.', + ); + return; + } + + // Create task + await createTask({ + name: 'reminder', + data: { userId: ctx.interaction.user.id, message }, + schedule: { type: 'date', value: Date.now() + delay }, + }); + }, +}; +``` + +### 3. Handle Task Limits + +```ts +export default { + name: 'schedule-reminder', + async run(ctx) { + const userId = ctx.interaction.user.id; + + // Check existing reminders + const existingReminders = await db.reminders.count({ + where: { userId, active: true }, + }); + + if (existingReminders >= 10) { + await ctx.interaction.reply( + 'You already have 10 active reminders. Please cancel some first.', + ); + return; + } + + // Create new reminder + // ... + }, +}; +``` + +## Next Steps + +- [Advanced Patterns](./04-advanced-patterns) - Learn advanced task management techniques +- [Task Drivers](./02-task-drivers) - Understand different persistence options diff --git a/apps/website/docs/guide/17-tasks/04-advanced-patterns.mdx b/apps/website/docs/guide/17-tasks/04-advanced-patterns.mdx new file mode 100644 index 00000000..d203af64 --- /dev/null +++ b/apps/website/docs/guide/17-tasks/04-advanced-patterns.mdx @@ -0,0 +1,365 @@ +--- +title: Advanced Patterns +description: Learn advanced patterns and best practices for task management. +--- + +# Advanced Patterns + +Once you're comfortable with the basics, you can implement more sophisticated task management patterns. This guide covers advanced techniques for complex use cases. + +## Task Workflows + +Organize related tasks to create complex workflows: + +```ts +// src/app/tasks/data-processing.ts +import { task } from '@commandkit/tasks'; + +export const dataProcessing = task({ + name: 'data-processing', + schedule: { type: 'cron', value: '0 2 * * *' }, // Daily at 2 AM + async execute(ctx) { + // Step 1: Collect data + const data = await collectData(); + ctx.store.set('collectedData', data); + + // Step 2: Process data immediately + const processedData = await processData(data); + + // Step 3: Send notification + const channel = ctx.commandkit.client.channels.cache.get('log-channel'); + if (channel?.isTextBased()) { + await channel.send(`Data processing completed for ID: ${data.id}`); + } + }, +}); +``` + +## Task Retry Logic + +Implement custom retry logic for failed tasks: + +```ts +// src/app/tasks/retry-example.ts +import { task } from '@commandkit/tasks'; + +export const retryTask = task({ + name: 'retry-task', + async execute(ctx) { + const { attempt = 1, maxAttempts = 3 } = ctx.data; + + try { + // Attempt the operation + await performRiskyOperation(); + + // Success - no need to retry + console.log('Operation completed successfully'); + } catch (error) { + console.error(`Attempt ${attempt} failed:`, error); + + if (attempt < maxAttempts) { + // Log retry attempt + console.log(`Will retry attempt ${attempt + 1} of ${maxAttempts}`); + + // In a real implementation, you might use a different approach + // such as storing retry state in a database + } else { + // Max attempts reached + console.error('Max retry attempts reached'); + + // Notify about failure + const channel = + ctx.commandkit.client.channels.cache.get('error-channel'); + if (channel?.isTextBased()) { + await channel.send(`Task failed after ${maxAttempts} attempts`); + } + } + } + }, +}); +``` + +## Task Dependencies + +Manage tasks that depend on other conditions: + +```ts +// src/app/tasks/dependency-example.ts +import { task } from '@commandkit/tasks'; + +export const dependencyTask = task({ + name: 'dependency-task', + async execute(ctx) { + const { requiredData } = ctx.data; + + // Check if required conditions are met + const conditionsMet = await checkRequiredConditions(); + + if (!conditionsMet) { + console.log('Required conditions not met, skipping task execution'); + return; + } + + // Conditions met, proceed with the task + await processWithDependentData(requiredData); + }, +}); +``` + +## Task Batching + +Process multiple items in batches: + +```ts +// src/app/tasks/batch-processing.ts +import { task } from '@commandkit/tasks'; + +export const batchProcessor = task({ + name: 'batch-processor', + schedule: { type: 'cron', value: '0 */6 * * *' }, // Every 6 hours + async execute(ctx) { + // Get items to process + const items = await getItemsToProcess(); + + if (items.length === 0) { + console.log('No items to process'); + return; + } + + // Process in batches of 10 + const batchSize = 10; + let processedCount = 0; + + for (let i = 0; i < items.length; i += batchSize) { + const batch = items.slice(i, i + batchSize); + console.log( + `Processing batch ${Math.floor(i / batchSize) + 1}/${Math.ceil(items.length / batchSize)}`, + ); + + // Process the batch + for (const item of batch) { + await processItem(item); + processedCount++; + } + } + + console.log(`Completed processing ${processedCount} items`); + }, +}); +``` + +## Task Monitoring and Metrics + +Track task execution metrics: + +```ts +// src/app/tasks/metrics.ts +import { task } from '@commandkit/tasks'; + +export const metricsTask = task({ + name: 'metrics-task', + schedule: { type: 'cron', value: '0 * * * *' }, // Every hour + async execute(ctx) { + const startTime = Date.now(); + + try { + // Perform the task + await performTask(); + + // Record success metrics + await recordMetrics({ + taskName: 'metrics-task', + status: 'success', + duration: Date.now() - startTime, + timestamp: new Date(), + }); + } catch (error) { + // Record failure metrics + await recordMetrics({ + taskName: 'metrics-task', + status: 'error', + duration: Date.now() - startTime, + error: error.message, + timestamp: new Date(), + }); + + throw error; // Re-throw to let the task system handle it + } + }, +}); +``` + +## Task State Management + +Use the context store to manage state across task executions: + +```ts +// src/app/tasks/state-management.ts +import { task } from '@commandkit/tasks'; + +export const statefulTask = task({ + name: 'stateful-task', + schedule: { type: 'cron', value: '0 */2 * * *' }, // Every 2 hours + async prepare(ctx) { + // Check if we're in a cooldown period + const lastRun = ctx.commandkit.store.get('last-run'); + const cooldown = 30 * 60 * 1000; // 30 minutes + + if (lastRun && Date.now() - lastRun < cooldown) { + return false; // Skip execution + } + + return true; + }, + async execute(ctx) { + // Update last run time + ctx.commandkit.store.set('last-run', Date.now()); + + // Get or initialize counter + const counter = ctx.commandkit.store.get('execution-counter') || 0; + ctx.commandkit.store.set('execution-counter', counter + 1); + + console.log(`Task executed ${counter + 1} times`); + + // Perform the actual work + await performWork(); + }, +}); +``` + +## Task Cleanup + +Implement cleanup tasks for resource management: + +```ts +// src/app/tasks/cleanup.ts +import { task } from '@commandkit/tasks'; + +export const cleanupTask = task({ + name: 'cleanup', + schedule: { type: 'cron', value: '0 3 * * *' }, // Daily at 3 AM + async execute(ctx) { + const cutoffDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); // 7 days ago + + // Clean up old data + await cleanupOldRecords(cutoffDate); + + // Clean up expired tasks + await cleanupExpiredTasks(); + + // Clean up temporary files + await cleanupTempFiles(); + + console.log('Cleanup completed successfully'); + }, +}); +``` + +## Task Error Recovery + +Implement robust error recovery mechanisms: + +```ts +// src/app/tasks/error-recovery.ts +import { task } from '@commandkit/tasks'; + +export const resilientTask = task({ + name: 'resilient-task', + async execute(ctx) { + const { operation, fallbackOperation } = ctx.data; + + try { + // Try the primary operation + await performOperation(operation); + } catch (error) { + console.error('Primary operation failed:', error); + + // Try fallback operation + try { + await performOperation(fallbackOperation); + console.log('Fallback operation succeeded'); + } catch (fallbackError) { + console.error('Fallback operation also failed:', fallbackError); + + // Log the failure for manual intervention + const channel = + ctx.commandkit.client.channels.cache.get('error-channel'); + if (channel?.isTextBased()) { + await channel.send( + `Task failed: ${error.message}. Fallback also failed: ${fallbackError.message}`, + ); + } + } + } + }, +}); +``` + +## Task Scheduling Patterns + +### Rolling Windows + +```ts +// src/app/tasks/rolling-window.ts +import { task } from '@commandkit/tasks'; + +export const rollingWindowTask = task({ + name: 'rolling-window', + schedule: { type: 'cron', value: '*/15 * * * *' }, // Every 15 minutes + async execute(ctx) { + const windowSize = 60 * 60 * 1000; // 1 hour + const now = Date.now(); + + // Get data from the last hour + const data = await getDataInWindow(now - windowSize, now); + + // Process the rolling window + await processRollingWindow(data); + }, +}); +``` + +### Adaptive Processing + +```ts +// src/app/tasks/adaptive-processing.ts +import { task } from '@commandkit/tasks'; + +export const adaptiveTask = task({ + name: 'adaptive-task', + schedule: { type: 'cron', value: '*/5 * * * *' }, // Every 5 minutes + async execute(ctx) { + // Get current system load + const load = await getCurrentLoad(); + + // Adjust processing based on load + if (load > 0.8) { + // High load - process fewer items + await performTask({ maxItems: 10 }); + } else if (load > 0.5) { + // Medium load - process normal amount + await performTask({ maxItems: 50 }); + } else { + // Low load - process more items + await performTask({ maxItems: 100 }); + } + }, +}); +``` + +## Best Practices Summary + +1. **Error Handling**: Always handle errors gracefully and implement fallback mechanisms +2. **Resource Management**: Clean up resources and implement proper cleanup tasks +3. **Monitoring**: Track task execution metrics and performance +4. **State Management**: Use the context store for sharing data between task phases +5. **Conditional Execution**: Check required conditions before proceeding with task work +6. **Batching**: Process large datasets in manageable batches +7. **Adaptive Processing**: Adjust processing based on system load +8. **Logging**: Implement comprehensive logging for debugging and monitoring + +## Next Steps + +- [Getting Started](./01-getting-started) - Review the basics +- [Task Drivers](./02-task-drivers) - Understand persistence options +- [Dynamic Tasks](./03-dynamic-tasks) - Learn about on-demand task creation diff --git a/packages/commandkit/package.json b/packages/commandkit/package.json index 3ccdfcba..2ef3acd7 100644 --- a/packages/commandkit/package.json +++ b/packages/commandkit/package.json @@ -190,4 +190,4 @@ "engines": { "node": ">=24" } -} \ No newline at end of file +} diff --git a/packages/commandkit/src/index.ts b/packages/commandkit/src/index.ts index 0beeab76..c584821d 100644 --- a/packages/commandkit/src/index.ts +++ b/packages/commandkit/src/index.ts @@ -23,10 +23,12 @@ export { debounce, defer, } from './utils/utilities'; +export { toFileURL } from './utils/resolve-file-url'; export * from './app/interrupt/signals'; export type { CommandKitHMREvent } from './utils/dev-hooks'; export * from './utils/constants'; export * from './app/events/EventWorkerContext'; +export { Collection, type Client } from 'discord.js'; // cli export { bootstrapCommandkitCLI } from './cli/init'; diff --git a/packages/tasks/LICENSE b/packages/tasks/LICENSE new file mode 100644 index 00000000..be2ae331 --- /dev/null +++ b/packages/tasks/LICENSE @@ -0,0 +1,11 @@ +Copyright 2025 Avraj Sahota + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated +documentation files (the “Software”), to deal in the Software without restriction, including without limitation the +rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/packages/tasks/README.md b/packages/tasks/README.md new file mode 100644 index 00000000..4152cc38 --- /dev/null +++ b/packages/tasks/README.md @@ -0,0 +1,301 @@ +# @commandkit/tasks + +Task management plugin for CommandKit. Provides on-demand task creation and management with support for both static and dynamic tasks. + +## Features + +- **Static Tasks**: Define tasks in your codebase that run on schedules +- **Dynamic Tasks**: Create tasks on-demand from commands or events +- **Multiple Drivers**: Support for in-memory, SQLite, and BullMQ persistence +- **HMR Support**: Hot reload tasks during development +- **Flexible Scheduling**: Support for cron expressions, dates, and dynamic schedules + +## Installation + +```bash +npm install @commandkit/tasks +``` + +## Quick Start + +### 1. Add the plugin to your CommandKit configuration + +```ts +import { tasks } from '@commandkit/tasks'; + +export default { + plugins: [ + tasks({ + tasksPath: 'app/tasks', // optional, defaults to 'app/tasks' + enableHMR: true, // optional, defaults to true in development + }), + ], +}; +``` + +### 2. Create static tasks + +Create a file in `src/app/tasks/`: + +```ts +import { task } from '@commandkit/tasks'; + +export const refreshExchangeRate = task({ + name: 'refresh-exchange-rate', + schedule: '0 0 * * *', // cron expression - daily at midnight + async execute(ctx) { + // Fetch latest exchange rates + const rates = await fetchExchangeRates(); + await updateDatabase(rates); + }, +}); + +export const cleanupOldData = task({ + name: 'cleanup-old-data', + schedule: () => new Date(Date.now() + 24 * 60 * 60 * 1000), // tomorrow + async prepare(ctx) { + // Only run if there's old data to clean + return await hasOldData(); + }, + async execute(ctx) { + await cleanupOldRecords(); + }, +}); +``` + +### 3. Create dynamic tasks from commands + +```ts +import { createTask } from '@commandkit/tasks'; + +export default { + name: 'remind-me', + description: 'Set a reminder', + async run(ctx) { + const time = ctx.interaction.options.getString('time'); + const reason = ctx.interaction.options.getString('reason'); + + await createTask({ + name: 'reminder', + schedule: new Date(Date.now() + ms(time)), + data: { + userId: ctx.interaction.user.id, + reason, + }, + }); + + await ctx.interaction.reply('Reminder set!'); + }, +}; +``` + +## API Reference + +### Plugin Options + +```ts +interface TasksPluginOptions { + tasksPath?: string; // Path to tasks directory, defaults to 'app/tasks' + enableHMR?: boolean; // Enable HMR for tasks, defaults to true in development +} +``` + +### Task Definition + +```ts +interface TaskDefinition { + name: string; + schedule?: ScheduleType; + prepare?: (ctx: TaskContext) => Promise | boolean; + execute: (ctx: TaskContext) => Promise | void; +} +``` + +### Schedule Types + +```ts +type ScheduleType = + | Date + | number // unix timestamp + | string // cron expression or date string + | (() => Date | number | string); // dynamic schedule +``` + +**Cron Expressions**: The plugin supports standard cron expressions (e.g., `'0 0 * * *'` for daily at midnight). Cron parsing is handled by `cron-parser` for in-memory and SQLite drivers, while BullMQ uses its built-in cron support. + +### Task Context + +```ts +interface TaskContext { + task: TaskData; + commandkit: CommandKit; + client: Client; +} +``` + +### Functions + +#### `task(definition: TaskDefinition)` + +Creates a task definition. + +```ts +import { task } from '@commandkit/tasks'; + +export const myTask = task({ + name: 'my-task', + schedule: '0 0 * * *', + async execute(ctx) { + // Task logic here + }, +}); +``` + +#### `createTask(options: CreateTaskOptions)` + +Creates a dynamic task. + +```ts +import { createTask } from '@commandkit/tasks'; + +await createTask({ + name: 'reminder', + schedule: new Date(Date.now() + 60000), // 1 minute from now + data: { userId: '123', message: 'Hello!' }, +}); +``` + +#### `executeTask(taskOrName: TaskDefinition | string)` + +Executes a task immediately. + +```ts +import { executeTask } from '@commandkit/tasks'; + +await executeTask('my-task'); +// or +await executeTask(myTask); +``` + +#### `cancelTask(taskOrName: TaskDefinition | string)` + +Cancels a scheduled task. + +```ts +import { cancelTask } from '@commandkit/tasks'; + +await cancelTask('my-task'); +``` + +#### `pauseTask(taskOrName: TaskDefinition | string)` + +Pauses a task. + +```ts +import { pauseTask } from '@commandkit/tasks'; + +await pauseTask('my-task'); +``` + +#### `resumeTask(taskOrName: TaskDefinition | string)` + +Resumes a paused task. + +```ts +import { resumeTask } from '@commandkit/tasks'; + +await resumeTask('my-task'); +``` + +## Persistence Drivers + +The drivers handle all scheduling and timing internally. When a task is due for execution, the driver calls the plugin's execution handler. + +### In-Memory Driver (Default) + +```ts +import { driver } from '@commandkit/tasks'; +import { InMemoryDriver } from '@commandkit/tasks/drivers'; + +driver.use(new InMemoryDriver()); +``` + +### SQLite Driver + +```ts +import { driver } from '@commandkit/tasks'; +import { SQLiteDriver } from '@commandkit/tasks/drivers'; + +driver.use(new SQLiteDriver('./tasks.db')); +``` + +**Note**: Requires `sqlite3`, `sqlite`, and `cron-parser` packages to be installed. + +### BullMQ Driver + +```ts +import { driver } from '@commandkit/tasks'; +import { BullMQDriver } from '@commandkit/tasks/drivers'; + +driver.use(new BullMQDriver({ + host: 'localhost', + port: 6379, +})); +``` + +**Note**: Requires `bullmq` package to be installed. BullMQ has built-in cron support, so no additional cron parsing is needed. + +## Examples + +### Scheduled Database Backup + +```ts +import { task } from '@commandkit/tasks'; + +export const databaseBackup = task({ + name: 'database-backup', + schedule: '0 2 * * *', // Daily at 2 AM + async execute(ctx) { + const backup = await createBackup(); + await uploadToCloud(backup); + await ctx.client.channels.cache.get('backup-log')?.send('Backup completed!'); + }, +}); +``` + +### User Reminder System + +```ts +import { task } from '@commandkit/tasks'; + +export const reminder = task({ + name: 'reminder', + async execute(ctx) { + const { userId, message } = ctx.task.data; + const user = await ctx.client.users.fetch(userId); + await user.send(`Reminder: ${message}`); + }, +}); +``` + +### Conditional Task + +```ts +import { task } from '@commandkit/tasks'; + +export const maintenanceCheck = task({ + name: 'maintenance-check', + schedule: '0 */6 * * *', // Every 6 hours + async prepare(ctx) { + // Only run if maintenance mode is not enabled + return !ctx.commandkit.store.get('maintenance-mode'); + }, + async execute(ctx) { + await performMaintenanceChecks(); + }, +}); +``` + +## License + +MIT diff --git a/packages/tasks/package.json b/packages/tasks/package.json new file mode 100644 index 00000000..93d2cc9d --- /dev/null +++ b/packages/tasks/package.json @@ -0,0 +1,59 @@ +{ + "name": "@commandkit/tasks", + "version": "0.0.0", + "description": "Task management plugin for CommandKit", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.js" + }, + "./bullmq": { + "types": "./dist/drivers/bullmq.d.ts", + "import": "./dist/drivers/bullmq.js", + "require": "./dist/drivers/bullmq.js" + }, + "./hypercron": { + "types": "./dist/drivers/hypercron.d.ts", + "import": "./dist/drivers/hypercron.js", + "require": "./dist/drivers/hypercron.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "lint": "tsc --noEmit", + "build": "tsc" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/underctrl-io/commandkit.git", + "directory": "packages/tasks" + }, + "keywords": [ + "commandkit", + "tasks" + ], + "contributors": [ + "Twilight ", + "Avraj " + ], + "license": "MIT", + "bugs": { + "url": "https://github.com/underctrl-io/commandkit/issues" + }, + "homepage": "https://github.com/underctrl-io/commandkit#readme", + "devDependencies": { + "bullmq": "^5.56.5", + "commandkit": "workspace:*", + "hypercron": "^0.1.0", + "tsconfig": "workspace:*", + "typescript": "catalog:build" + }, + "dependencies": { + "cron-parser": "^4.9.0" + } +} diff --git a/packages/tasks/src/context.ts b/packages/tasks/src/context.ts new file mode 100644 index 00000000..7ef070d2 --- /dev/null +++ b/packages/tasks/src/context.ts @@ -0,0 +1,119 @@ +import type { CommandKit, Client } from 'commandkit'; +import { Task } from './task'; + +/** + * Data structure for creating a task execution context. + * + * This interface contains all the necessary information to create a task context, + * including the task instance, custom data, and CommandKit instance. + */ +export interface TaskContextData< + T extends Record = Record, +> { + /** The task instance that is being executed */ + task: Task; + /** Custom data passed to the task execution */ + data: T; + /** The CommandKit instance for accessing bot functionality */ + commandkit: CommandKit; +} + +/** + * Execution context provided to task functions. + * + * This class provides access to the task instance, custom data, CommandKit instance, + * and a temporary store for sharing data between prepare and execute functions. + * + * @example + * ```ts + * import { task } from '@commandkit/tasks'; + * + * export const reminderTask = task({ + * name: 'reminder', + * async execute(ctx) { + * // Access custom data passed to the task + * const { userId, message } = ctx.data; + * + * // Access CommandKit and Discord.js client + * const user = await ctx.commandkit.client.users.fetch(userId); + * await user.send(`Reminder: ${message}`); + * + * // Use the store to share data between prepare and execute + * const processedCount = ctx.store.get('processedCount') || 0; + * ctx.store.set('processedCount', processedCount + 1); + * }, + * }); + * ``` + */ +export class TaskContext = Record> { + /** + * Temporary key-value store for sharing data between prepare and execute functions. + * + * This store is useful for passing computed values or state between the prepare + * and execute phases of task execution. + * + * @example + * ```ts + * export const conditionalTask = task({ + * name: 'conditional-task', + * async prepare(ctx) { + * const shouldRun = await checkConditions(); + * ctx.store.set('shouldRun', shouldRun); + * return shouldRun; + * }, + * async execute(ctx) { + * const shouldRun = ctx.store.get('shouldRun'); + * if (shouldRun) { + * await performAction(); + * } + * }, + * }); + * ``` + */ + public readonly store = new Map(); + + /** + * Creates a new task execution context. + * + * @param _data - The task context data containing task, data, and CommandKit instance + */ + public constructor(private _data: TaskContextData) {} + + /** + * Gets the task instance being executed. + * + * @returns The Task instance + */ + public get task(): Task { + return this._data.task; + } + + /** + * Gets the Discord.js client. + * @returns The Discord.js client + */ + public get client(): Client { + return this._data.commandkit.client; + } + + /** + * Gets the custom data passed to the task execution. + * + * @returns The custom data object + */ + public get data(): T { + return this._data.data; + } + + /** + * Gets the CommandKit instance for accessing bot functionality. + * + * This provides access to the Discord.js client, CommandKit store, and other + * bot-related functionality. + * + * @returns The CommandKit instance + */ + public get commandkit(): CommandKit { + return this._data.commandkit; + } +} diff --git a/packages/tasks/src/driver-manager.ts b/packages/tasks/src/driver-manager.ts new file mode 100644 index 00000000..d8f5c85c --- /dev/null +++ b/packages/tasks/src/driver-manager.ts @@ -0,0 +1,155 @@ +import { TaskDriver, TaskRunner } from './driver'; +import { PartialTaskData, TaskData } from './types'; + +/** + * Manages the active task driver and provides a unified interface for task operations. + * + * This class acts as a facade for the underlying task driver, providing methods + * to create, delete, and manage tasks. It ensures that a driver is set before + * any operations are performed. + * + * @example + * ```ts + * import { TaskDriverManager } from '@commandkit/tasks'; + * import { HyperCronDriver } from '@commandkit/tasks/hypercron'; + * + * const manager = new TaskDriverManager(); + * manager.setDriver(new HyperCronDriver()); + * + * // Now you can create and manage tasks + * const taskId = await manager.createTask({ + * name: 'my-task', + * data: { userId: '123' }, + * schedule: { type: 'date', value: Date.now() + 60000 }, + * }); + * ``` + */ +export class TaskDriverManager { + private driver: TaskDriver | null = null; + + /** + * Sets the active task driver. + * + * This method must be called before any task operations can be performed. + * The driver handles all scheduling, persistence, and execution timing. + * + * @param driver - The task driver to use for all operations + */ + public setDriver(driver: TaskDriver): void { + this.driver = driver; + } + + /** + * Creates a new scheduled task. + * + * This method delegates to the active driver to schedule the task according + * to its configuration. The task will be executed when its schedule is due. + * + * @param task - The task data containing name, schedule, and custom data + * @returns A unique identifier for the created task + * @throws {Error} If no driver has been set + */ + public async createTask(task: TaskData): Promise { + if (!this.driver) throw new Error('Task driver has not been set'); + + return await this.driver.create(task); + } + + /** + * Deletes a scheduled task by its identifier. + * + * This method delegates to the active driver to remove the task from the + * scheduling system and cancel any pending executions. + * + * @param identifier - The unique identifier of the task to delete + * @throws {Error} If no driver has been set + */ + public async deleteTask(identifier: string): Promise { + if (!this.driver) throw new Error('Task driver has not been set'); + + await this.driver.delete(identifier); + } + + /** + * Sets the task execution runner function. + * + * This method delegates to the active driver to set up the execution handler + * that will be called when tasks are due to run. + * + * @param runner - The function to call when a task should be executed + * @throws {Error} If no driver has been set + */ + public async setTaskRunner(runner: TaskRunner): Promise { + if (!this.driver) throw new Error('Task driver has not been set'); + + await this.driver.setTaskRunner(runner); + } +} + +/** + * Global task driver manager instance. + * + * This is the default instance used by the tasks plugin for managing task operations. + * You can use this instance directly or create your own TaskDriverManager instance. + */ +export const taskDriverManager = new TaskDriverManager(); + +/** + * Sets the global task driver. + * + * This is a convenience function that sets the driver on the global task driver manager. + * + * @param driver - The task driver to use for all operations + * + * @example + * ```ts + * import { setDriver } from '@commandkit/tasks'; + * import { HyperCronDriver } from '@commandkit/tasks/hypercron'; + * + * setDriver(new HyperCronDriver()); + * ``` + */ +export function setDriver(driver: TaskDriver): void { + taskDriverManager.setDriver(driver); +} + +/** + * Creates a new scheduled task using the global driver manager. + * + * This is a convenience function that creates a task using the global task driver manager. + * + * @param task - The task data containing name, schedule, and custom data + * @returns A unique identifier for the created task + * + * @example + * ```ts + * import { createTask } from '@commandkit/tasks'; + * + * const taskId = await createTask({ + * name: 'reminder', + * data: { userId: '123', message: 'Hello!' }, + * schedule: { type: 'date', value: Date.now() + 60000 }, + * }); + * ``` + */ +export function createTask(task: TaskData): Promise { + return taskDriverManager.createTask(task); +} + +/** + * Deletes a scheduled task using the global driver manager. + * + * This is a convenience function that deletes a task using the global task driver manager. + * + * @param identifier - The unique identifier of the task to delete + * + * @example + * ```ts + * import { deleteTask } from '@commandkit/tasks'; + * + * await deleteTask('task-123'); + * ``` + */ +export function deleteTask(identifier: string): Promise { + return taskDriverManager.deleteTask(identifier); +} diff --git a/packages/tasks/src/driver.ts b/packages/tasks/src/driver.ts new file mode 100644 index 00000000..b206922e --- /dev/null +++ b/packages/tasks/src/driver.ts @@ -0,0 +1,78 @@ +import { PartialTaskData, TaskData, TaskExecutionData } from './types'; + +/** + * Interface for task persistence and scheduling drivers. + * + * Drivers handle the actual scheduling, persistence, and execution timing of tasks. + * Different drivers can provide different persistence mechanisms (in-memory, database, etc.) + * and scheduling capabilities. + * + * @example + * ```ts + * import { TaskDriver } from '@commandkit/tasks'; + * + * class CustomDriver implements TaskDriver { + * async create(task: TaskData): Promise { + * // Schedule the task in your system + * const id = await this.scheduler.schedule(task); + * return id; + * } + * + * async delete(task: string): Promise { + * // Remove the task from your system + * await this.scheduler.cancel(task); + * } + * + * async setTaskRunner(runner: TaskRunner): Promise { + * // Set up the execution handler + * this.runner = runner; + * } + * } + * ``` + */ +export interface TaskDriver { + /** + * Creates a new scheduled task. + * + * This method should schedule the task according to its schedule configuration + * and return a unique identifier that can be used to delete the task later. + * Multiple tasks may be created with the same name. + * + * @param task - The task data containing name, schedule, and custom data + * @returns A unique identifier for the created task + */ + create(task: TaskData): Promise; + + /** + * Deletes a scheduled task by its identifier. + * + * This method should remove the task from the scheduling system and cancel + * any pending executions. If the task doesn't exist, this method should + * complete successfully without throwing an error. + * + * @param task - The unique identifier of the task to delete + */ + delete(task: string): Promise; + + /** + * Sets the task execution runner function. + * + * This method should store the provided runner function and call it whenever + * a task is due for execution. The runner function receives the task execution + * data and should handle any errors that occur during execution. + * + * @param runner - The function to call when a task should be executed + */ + setTaskRunner(runner: TaskRunner): Promise; +} + +/** + * Function type for executing tasks. + * + * This function is called by the driver when a task is scheduled to run. + * It receives the task execution data and should handle the actual execution + * of the task logic. + * + * @param task - The task execution data containing name, custom data, and timestamp + */ +export type TaskRunner = (task: TaskExecutionData) => Promise; diff --git a/packages/tasks/src/drivers/bullmq.ts b/packages/tasks/src/drivers/bullmq.ts new file mode 100644 index 00000000..cd215315 --- /dev/null +++ b/packages/tasks/src/drivers/bullmq.ts @@ -0,0 +1,126 @@ +import { TaskDriver, TaskRunner } from '../driver'; +import { ConnectionOptions, Queue, Worker } from 'bullmq'; +import { TaskData } from '../types'; + +/** + * BullMQ-based task driver for distributed task scheduling. + * + * This driver uses BullMQ to provide robust, distributed task scheduling with Redis + * as the backend. It supports both cron expressions and date-based scheduling, with + * built-in retry mechanisms and job persistence. + * + * **Requirements**: Requires the `bullmq` package to be installed. + * + * @example + * ```ts + * import { BullMQDriver } from '@commandkit/tasks/bullmq'; + * import { setDriver } from '@commandkit/tasks'; + * + * const driver = new BullMQDriver({ + * host: 'localhost', + * port: 6379, + * }, 'my-tasks-queue'); + * + * setDriver(driver); + * ``` + * + * @example + * ```ts + * // With custom Redis connection options + * const driver = new BullMQDriver({ + * host: 'redis.example.com', + * port: 6379, + * password: 'your-password', + * tls: true, + * }); + * ``` + */ +export class BullMQDriver implements TaskDriver { + private runner: TaskRunner | null = null; + + /** The BullMQ queue instance for managing tasks */ + public readonly queue: Queue; + + /** The BullMQ worker instance for processing tasks */ + public readonly worker: Worker; + + /** + * Creates a new BullMQ driver instance. + * + * @param connection - Redis connection options for BullMQ + * @param queueName - Optional queue name, defaults to 'commandkit-tasks' + */ + public constructor( + connection: ConnectionOptions, + private readonly queueName: string = 'commandkit-tasks', + ) { + this.queue = new Queue(this.queueName, { connection }); + this.worker = new Worker( + this.queueName, + async (job) => { + if (!this.runner) throw new Error('Task runner has not been set'); + + await this.runner({ + name: job.name, + data: job.data, + timestamp: job.timestamp, + }); + }, + { connection }, + ); + } + + /** + * Creates a new scheduled task in BullMQ. + * + * For cron tasks, this creates a repeating job with the specified cron pattern. + * For date tasks, this creates a delayed job that executes at the specified time. + * + * @param task - The task data containing name, schedule, and custom data + * @returns A unique job identifier + */ + public async create(task: TaskData): Promise { + const jobId = crypto.randomUUID(); + const job = await this.queue.add(task.name, task.data, { + jobId, + ...(task.schedule.type === 'cron' + ? { + repeat: { + pattern: task.schedule.value, + tz: task.schedule.timezone, + immediately: !!task.immediate, + }, + } + : { + delay: + (task.schedule.value instanceof Date + ? task.schedule.value.getTime() + : task.schedule.value) - Date.now(), + }), + }); + + return job.id ?? jobId; + } + + /** + * Deletes a scheduled task from BullMQ. + * + * This removes the job and all its children (for repeating jobs) from the queue. + * + * @param identifier - The job identifier to delete + */ + public async delete(identifier: string): Promise { + await this.queue.remove(identifier, { removeChildren: true }); + } + + /** + * Sets the task execution runner function. + * + * This function will be called by the BullMQ worker when a job is due for execution. + * + * @param runner - The function to call when a task should be executed + */ + public async setTaskRunner(runner: TaskRunner): Promise { + this.runner = runner; + } +} diff --git a/packages/tasks/src/drivers/hypercron.ts b/packages/tasks/src/drivers/hypercron.ts new file mode 100644 index 00000000..c693eae0 --- /dev/null +++ b/packages/tasks/src/drivers/hypercron.ts @@ -0,0 +1,96 @@ +import { cronService } from 'hypercron'; +import { TaskDriver, TaskRunner } from '../driver'; +import { TaskData } from '../types'; + +/** + * HyperCron-based task driver for lightweight task scheduling. + * + * This driver uses HyperCron to provide simple, in-memory task scheduling. + * It's ideal for single-instance applications or development environments + * where you don't need distributed task scheduling. + * + * **Requirements**: Requires the `hypercron` package to be installed. + * + * @example + * ```ts + * import { HyperCronDriver } from '@commandkit/tasks/hypercron'; + * import { setDriver } from '@commandkit/tasks'; + * + * const driver = new HyperCronDriver(); + * setDriver(driver); + * ``` + * + * @example + * ```ts + * // With custom cron service + * import { cronService } from 'hypercron'; + * + * const customService = cronService.create({ + * // Custom configuration + * }); + * + * const driver = new HyperCronDriver(customService); + * setDriver(driver); + * ``` + */ +export class HyperCronDriver implements TaskDriver { + private runner: TaskRunner | null = null; + + /** + * Creates a new HyperCron driver instance. + * + * @param service - Optional custom cron service instance, defaults to the global cronService + */ + public constructor(private readonly service = cronService) {} + + /** + * Creates a new scheduled task in HyperCron. + * + * This schedules the task using the HyperCron service and starts the service + * if it's not already running. + * + * @param task - The task data containing name, schedule, and custom data + * @returns A unique schedule identifier + */ + public async create(task: TaskData): Promise { + const id = await this.service.schedule( + task.schedule.value, + task.name, + async () => { + if (!this.runner) throw new Error('Task runner has not been set'); + + await this.runner({ + name: task.name, + data: task.data, + timestamp: Date.now(), + }); + }, + ); + + await this.service.start(); + + return id; + } + + /** + * Deletes a scheduled task from HyperCron. + * + * This cancels the scheduled task and removes it from the cron service. + * + * @param identifier - The schedule identifier to delete + */ + public async delete(identifier: string): Promise { + await this.service.cancel(identifier); + } + + /** + * Sets the task execution runner function. + * + * This function will be called by the HyperCron service when a task is due for execution. + * + * @param runner - The function to call when a task should be executed + */ + public async setTaskRunner(runner: TaskRunner): Promise { + this.runner = runner; + } +} diff --git a/packages/tasks/src/index.ts b/packages/tasks/src/index.ts new file mode 100644 index 00000000..8ebbc55e --- /dev/null +++ b/packages/tasks/src/index.ts @@ -0,0 +1,38 @@ +import { TasksPlugin, TasksPluginOptions } from './plugin'; + +/** + * Creates a tasks plugin instance for CommandKit. + * + * This plugin provides task management capabilities including: + * - Static task definitions with cron and date-based scheduling + * - Dynamic task creation and management + * - Hot module replacement (HMR) support for development + * - Multiple persistence drivers (in-memory, SQLite, BullMQ) + * + * @example + * ```ts + * import { tasks } from '@commandkit/tasks'; + * + * export default { + * plugins: [ + * tasks({ + * tasksPath: 'app/tasks', + * enableHMR: true, + * }), + * ], + * }; + * ``` + * + * @param options - Configuration options for the tasks plugin + * @returns A configured tasks plugin instance + */ +export function tasks(options?: TasksPluginOptions) { + return new TasksPlugin(options ?? {}); +} + +export * from './plugin'; +export * from './task'; +export * from './types'; +export * from './driver'; +export * from './driver-manager'; +export * from './context'; diff --git a/packages/tasks/src/plugin.ts b/packages/tasks/src/plugin.ts new file mode 100644 index 00000000..86ed9f28 --- /dev/null +++ b/packages/tasks/src/plugin.ts @@ -0,0 +1,232 @@ +import { + CommandKit, + CommandKitPluginRuntime, + Logger, + RuntimePlugin, + Collection, + CommandKitHMREvent, + getCurrentDirectory, + toFileURL, +} from 'commandkit'; +import { readdir } from 'node:fs/promises'; +import path, { join } from 'node:path'; +import { Task } from './task'; +import { cwd } from 'node:process'; +import { taskDriverManager } from './driver-manager'; +import { TaskContext } from './context'; +import { existsSync } from 'node:fs'; + +/** + * Configuration options for the tasks plugin. + * + * Currently, the plugin uses default settings for task loading and HMR. + * Future versions may support customizing the tasks directory path and HMR behavior. + */ +export interface TasksPluginOptions { + // Future options may include: + // tasksPath?: string; + // enableHMR?: boolean; +} + +/** + * CommandKit plugin that provides task management capabilities. + * + * This plugin automatically loads task definitions from the `src/app/tasks` directory + * and manages their execution through configured drivers. It supports hot module + * replacement (HMR) for development workflows. + * + * @example + * ```ts + * import { tasks } from '@commandkit/tasks'; + * + * export default { + * plugins: [ + * tasks(), + * ], + * }; + * ``` + */ +export class TasksPlugin extends RuntimePlugin { + /** The plugin name identifier */ + public readonly name = 'tasks'; + + /** Collection of loaded task instances indexed by task name */ + public readonly tasks = new Collection(); + + /** + * Activates the tasks plugin. + * + * This method: + * 1. Sets up the task execution runner + * 2. Loads all task definitions from the tasks directory + * 3. Schedules tasks that have defined schedules + * + * @param ctx - The CommandKit plugin runtime context + */ + public async activate(ctx: CommandKitPluginRuntime): Promise { + taskDriverManager.setTaskRunner(async (task) => { + try { + const taskInstance = this.tasks.get(task.name); + + if (!taskInstance) { + // task does not exist so we delete it + await taskDriverManager.deleteTask(task.name); + return; + } + + const context = new TaskContext({ + task: taskInstance, + data: task.data, + commandkit: ctx.commandkit, + }); + + const prepared = await taskInstance.prepare(context); + if (!prepared) return; + + await taskInstance.execute(context); + } catch (e: any) { + Logger.error(`Error executing task: ${e?.stack ?? e}`); + } + }); + + await this.loadTasks(); + Logger.info('Tasks plugin activated!'); + } + + /** + * Deactivates the tasks plugin. + * + * Clears all loaded tasks from memory. + * + * @param ctx - The CommandKit plugin runtime context + */ + public async deactivate(ctx: CommandKitPluginRuntime): Promise { + this.tasks.clear(); + Logger.info('Tasks plugin deactivated!'); + } + + /** + * Gets the default tasks directory path. + * + * @returns The absolute path to the tasks directory + */ + private getTaskDirectory(): string { + return path.join(getCurrentDirectory(), 'app', 'tasks'); + } + + /** + * Loads all task definitions from the tasks directory. + * + * This method scans the tasks directory for TypeScript/JavaScript files and + * imports them as task definitions. It validates that each export is a valid + * Task instance and schedules tasks that have defined schedules. + * + * @param commandkit - The CommandKit instance + */ + private async loadTasks(): Promise { + const taskDirectory = this.getTaskDirectory(); + + if (!existsSync(taskDirectory)) return; + + const files = await readdir(taskDirectory, { withFileTypes: true }); + + for (const file of files) { + if ( + file.isDirectory() || + file.name.startsWith('_') || + !/\.(c|m)?(j|t)sx?$/.test(file.name) + ) { + continue; + } + + const taskPath = path.join(file.parentPath, file.name); + + const task = await import(toFileURL(taskPath, true)) + .then((m) => m.default || m) + .catch((e) => { + Logger.error(`Error loading task file: ${e?.stack ?? e}`); + return null; + }); + + if (!task || !(task instanceof Task)) { + continue; + } + + if (this.tasks.has(task.name)) { + Logger.error( + `Duplicate task found: ${task.name} at src/app/tasks/${file.name}`, + ); + continue; + } + + if (task.schedule) { + await taskDriverManager.createTask({ + name: task.name, + data: {}, + schedule: task.schedule, + }); + } + + this.tasks.set(task.name, task); + + Logger.info(`Loaded task: ${task.name}`); + } + + Logger.info(`Loaded ${this.tasks.size} tasks`); + } + + /** + * Handles hot module replacement (HMR) for task files. + * + * When a task file is modified during development, this method reloads the + * task definition and updates the scheduler accordingly. + * + * @param ctx - The CommandKit plugin runtime context + * @param event - The HMR event containing file change information + */ + public async performHMR( + ctx: CommandKitPluginRuntime, + event: CommandKitHMREvent, + ): Promise { + if (event.event !== 'unknown') return; + + if (!event.path.startsWith(join(cwd(), 'src', 'app', 'tasks'))) { + return; + } + + event.preventDefault(); + event.accept(); + + const taskData = await import(toFileURL(event.path, true)) + .then((t) => t.default || t) + .catch((e) => { + Logger.error(`Error loading task file: ${e?.stack ?? e}`); + return null; + }); + + if (!taskData || !(taskData instanceof Task)) return; + + if (this.tasks.has(taskData.name)) { + Logger.info(`Reloading task: ${taskData.name}`); + await taskDriverManager.deleteTask(taskData.name); + this.tasks.set(taskData.name, taskData); + if (taskData.schedule) { + await taskDriverManager.createTask({ + name: taskData.name, + data: {}, + schedule: taskData.schedule, + }); + } + } else { + Logger.info(`Loading task: ${taskData.name}`); + this.tasks.set(taskData.name, taskData); + if (taskData.schedule) { + await taskDriverManager.createTask({ + name: taskData.name, + data: {}, + schedule: taskData.schedule, + }); + } + } + } +} diff --git a/packages/tasks/src/task.ts b/packages/tasks/src/task.ts new file mode 100644 index 00000000..cb30f177 --- /dev/null +++ b/packages/tasks/src/task.ts @@ -0,0 +1,134 @@ +import { TaskContext } from './context'; +import { TaskDefinition, TaskSchedule } from './types'; + +/** + * Represents a task instance with execution logic and metadata. + * + * Tasks can be scheduled to run at specific times or intervals using cron expressions + * or date-based scheduling. They support preparation logic to conditionally execute + * and provide a context for accessing CommandKit and Discord.js functionality. + * + * @example + * ```ts + * import { task } from '@commandkit/tasks'; + * + * export const cleanupTask = task({ + * name: 'cleanup-old-data', + * schedule: { type: 'cron', value: '0 2 * * *' }, // Daily at 2 AM + * async prepare(ctx) { + * // Only run if there's old data to clean + * return await hasOldData(); + * }, + * async execute(ctx) { + * await cleanupOldRecords(); + * await ctx.commandkit.client.channels.cache + * .get('log-channel')?.send('Cleanup completed!'); + * }, + * }); + * ``` + */ +export class Task = Record> { + /** + * Creates a new task instance. + * + * @param data - The task definition containing name, schedule, and execution logic + */ + public constructor(private data: TaskDefinition) {} + + /** + * Whether this task should run immediately when created. + * Only applicable to cron tasks, defaults to false. + */ + public get immediate(): boolean { + return this.data.immediate ?? false; + } + + /** + * The unique identifier for this task. + */ + public get name(): string { + return this.data.name; + } + + /** + * The schedule configuration for this task. + * Returns null if no schedule is defined (manual execution only). + */ + public get schedule(): TaskSchedule | null { + return this.data.schedule ?? null; + } + + /** + * Checks if this task uses cron-based scheduling. + * + * @returns true if the task has a cron schedule + */ + public isCron(): boolean { + return this.schedule?.type === 'cron'; + } + + /** + * Checks if this task uses date-based scheduling. + * + * @returns true if the task has a date schedule + */ + public isDate(): boolean { + return this.schedule?.type === 'date'; + } + + /** + * Determines if the task is ready to be executed. + * + * This method calls the optional prepare function if defined. If no prepare + * function is provided, the task is always ready to execute. + * + * @param ctx - The task execution context + * @returns true if the task should execute, false to skip this run + */ + public async prepare(ctx: TaskContext): Promise { + return this.data.prepare?.(ctx) ?? true; + } + + /** + * Executes the task's main logic. + * + * This method calls the execute function defined in the task definition. + * It provides access to the CommandKit instance, Discord.js client, and + * any custom data passed to the task. + * + * @param ctx - The task execution context containing CommandKit, client, and data + */ + public async execute(ctx: TaskContext): Promise { + await this.data.execute(ctx); + } +} + +/** + * Creates a new task definition. + * + * This is the main function for defining tasks in your application. Tasks can be + * scheduled using cron expressions or specific dates, and can include preparation + * logic to conditionally execute based on runtime conditions. + * + * @example + * ```ts + * import { task } from '@commandkit/tasks'; + * + * // Simple scheduled task + * export const dailyBackup = task({ + * name: 'daily-backup', + * schedule: { type: 'cron', value: '0 0 * * *' }, + * async execute(ctx) { + * await performBackup(); + * }, + * }); + * ``` + * + * @param data - The task definition containing name, schedule, and execution logic + * @returns A configured Task instance + */ +export function task = Record>( + data: TaskDefinition, +): Task { + return new Task(data); +} diff --git a/packages/tasks/src/types.ts b/packages/tasks/src/types.ts new file mode 100644 index 00000000..77560880 --- /dev/null +++ b/packages/tasks/src/types.ts @@ -0,0 +1,88 @@ +import { TaskContext } from './context'; + +/** + * Represents a task schedule configuration. + * + * Tasks can be scheduled using either cron expressions or specific dates/timestamps. + * The timezone is optional and defaults to the system timezone. + */ +export type TaskSchedule = + | { + /** Schedule type using cron expressions */ + type: 'cron'; + /** Optional timezone for the cron schedule (e.g., 'UTC', 'America/New_York') */ + timezone?: string; + /** Cron expression (e.g., '0 0 * * *' for daily at midnight) */ + value: string; + } + | { + /** Schedule type using a specific date or timestamp */ + type: 'date'; + /** Optional timezone for the date schedule */ + timezone?: string; + /** Date object or Unix timestamp in milliseconds */ + value: Date | number; + }; + +/** + * Defines a task with its execution logic and scheduling. + * + * Tasks can have optional preparation logic that determines whether the task should run, + * and required execution logic that performs the actual task work. + */ +export interface TaskDefinition< + T extends Record = Record, +> { + /** Unique identifier for the task */ + name: string; + /** Optional schedule configuration for recurring or delayed execution */ + schedule?: TaskSchedule; + /** Whether the task should run immediately when created (only for cron tasks) */ + immediate?: boolean; + /** + * Optional preparation function that determines if the task should execute. + * Return false to skip execution for this run. + */ + prepare?: (ctx: TaskContext) => Promise; + /** + * The main execution function that performs the task work. + * This is called when the task is scheduled to run. + */ + execute: (ctx: TaskContext) => Promise; +} + +/** + * Represents the data structure for a task instance. + * + * This includes the task metadata and any custom data passed to the task. + */ +export interface TaskData = Record> { + /** The name of the task definition to execute */ + name: string; + /** Custom data passed to the task execution context */ + data: T; + /** Schedule configuration for when the task should run */ + schedule: TaskSchedule; + /** Whether the task should run immediately when created */ + immediate?: boolean; +} + +/** + * Partial task data for creating tasks with optional fields. + * + * Useful when you want to create a task with only some fields specified. + */ +export type PartialTaskData< + T extends Record = Record, +> = Partial>; + +/** + * Data structure passed to task execution handlers. + * + * This includes the task metadata and execution timestamp, but excludes + * scheduling information since the task is already being executed. + */ +export type TaskExecutionData = Omit & { + /** Unix timestamp when the task execution started */ + timestamp: number; +}; diff --git a/packages/tasks/tsconfig.json b/packages/tasks/tsconfig.json new file mode 100644 index 00000000..c1f527cd --- /dev/null +++ b/packages/tasks/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "tsconfig/base.json", + "compilerOptions": { + "outDir": "dist", + "skipLibCheck": true, + "skipDefaultLibCheck": true, + "declaration": true, + "inlineSourceMap": true, + "target": "ES2020", + "module": "node16", + "moduleResolution": "node16", + "noEmit": false + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4e95ea45..a10389cf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -90,6 +90,9 @@ importers: '@commandkit/legacy': specifier: workspace:* version: link:../../packages/legacy + '@commandkit/tasks': + specifier: workspace:* + version: link:../../packages/tasks commandkit: specifier: workspace:* version: link:../../packages/commandkit @@ -99,10 +102,16 @@ importers: dotenv: specifier: ^16.4.7 version: 16.6.1 + ms: + specifier: ^2.1.3 + version: 2.1.3 zod: specifier: ^3.25.56 version: 3.25.76 devDependencies: + '@types/ms': + specifier: ^2.1.0 + version: 2.1.0 tsx: specifier: ^4.7.0 version: 4.20.3 @@ -605,6 +614,28 @@ importers: specifier: ^5.7.3 version: 5.8.3 + packages/tasks: + dependencies: + cron-parser: + specifier: ^4.9.0 + version: 4.9.0 + devDependencies: + bullmq: + specifier: ^5.56.5 + version: 5.56.5 + commandkit: + specifier: workspace:* + version: link:../commandkit + hypercron: + specifier: ^0.1.0 + version: 0.1.0 + tsconfig: + specifier: workspace:* + version: link:../tsconfig + typescript: + specifier: catalog:build + version: 5.8.3 + packages/tsconfig: {} packages: @@ -2224,6 +2255,36 @@ packages: resolution: {integrity: sha512-JEW4DEtBzfe8HvUYecLU9e6+XJnKDlUAIve8FvPzF3Kzs6Xo/KuZkZJsDH0wJXl/qEZbeeE7edxDNY3kMs39hQ==} engines: {node: '>= 18'} + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': + resolution: {integrity: sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==} + cpu: [arm64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3': + resolution: {integrity: sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==} + cpu: [x64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3': + resolution: {integrity: sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==} + cpu: [arm64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3': + resolution: {integrity: sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==} + cpu: [arm] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3': + resolution: {integrity: sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==} + cpu: [x64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': + resolution: {integrity: sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==} + cpu: [x64] + os: [win32] + '@napi-rs/wasm-runtime@1.0.1': resolution: {integrity: sha512-KVlQ/jgywZpixGCKMNwxStmmbYEMyokZpCf2YuIChhfJA2uqfAKNEM8INz7zzTo55iEXfBhIIs3VqYyqzDLj8g==} @@ -4213,6 +4274,9 @@ packages: buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + bullmq@5.56.5: + resolution: {integrity: sha512-nhcVxoE9Y0YUuNYtvaD+N0Bk2kqcU+rXzJwdQIr8i8qC/fxoghwUYb9a+CidTv24pi1eqstLnBoa8xkR/P7Mdw==} + bundle-name@4.1.0: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} engines: {node: '>=18'} @@ -4594,6 +4658,14 @@ packages: create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + cron-parser@4.9.0: + resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==} + engines: {node: '>=12.0.0'} + + cron-parser@5.3.0: + resolution: {integrity: sha512-IS4mnFu6n3CFgEmXjr+B2zzGHsjJmHEdN+BViKvYSiEn3KWss9ICRDETDX/VZldiW82B94OyAZm4LIT4vcKK0g==} + engines: {node: '>=18'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -5829,6 +5901,9 @@ packages: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} + hypercron@0.1.0: + resolution: {integrity: sha512-HVed7qejcrdrpIiHH8RaO2r7ZQcgWSHii3EUvplV7DsKAvoMwoJze/wZqlb7mT++eyzZE52TMA0Y1i/1YXya8A==} + hyperdyperid@1.2.0: resolution: {integrity: sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==} engines: {node: '>=10.18'} @@ -6470,6 +6545,10 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + luxon@3.7.1: + resolution: {integrity: sha512-RkRWjA926cTvz5rAb1BqyWkKbbjzCGchDUIKMCUvNi17j6f6j8uHGDV82Aqcqtzd+icoYpELmG3ksgGiFNNcNg==} + engines: {node: '>=12'} + magic-bytes.js@1.10.0: resolution: {integrity: sha512-/k20Lg2q8LE5xiaaSkMXk4sfvI+9EGEykFS4b0CHHGWqDYU0bGUFSwchNOMA56D7TCs9GwVTkqe9als1/ns8UQ==} @@ -6816,6 +6895,13 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + msgpackr-extract@3.0.3: + resolution: {integrity: sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==} + hasBin: true + + msgpackr@1.11.5: + resolution: {integrity: sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==} + multicast-dns@7.2.5: resolution: {integrity: sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==} hasBin: true @@ -6865,6 +6951,9 @@ packages: no-case@3.0.4: resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} + node-abort-controller@3.1.1: + resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} + node-emoji@2.2.0: resolution: {integrity: sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==} engines: {node: '>=18'} @@ -6873,6 +6962,10 @@ packages: resolution: {integrity: sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==} engines: {node: '>= 6.13.0'} + node-gyp-build-optional-packages@5.2.2: + resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} + hasBin: true + node-plop@0.26.3: resolution: {integrity: sha512-Cov028YhBZ5aB7MdMWJEmwyBig43aGL5WT4vdoB28Oitau1zZAcHUn8Sgfk9HM33TqhtLJ9PlM/O0Mv+QpV/4Q==} engines: {node: '>=8.9.4'} @@ -8870,6 +8963,10 @@ packages: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} hasBin: true + uuid@9.0.1: + resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + hasBin: true + v8-compile-cache-lib@3.0.1: resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} @@ -12323,6 +12420,24 @@ snapshots: '@msgpack/msgpack@3.1.2': {} + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': + optional: true + '@napi-rs/wasm-runtime@1.0.1': dependencies: '@emnapi/core': 1.4.5 @@ -13793,7 +13908,7 @@ snapshots: '@types/react-router@5.1.20': dependencies: '@types/history': 4.7.11 - '@types/react': 19.1.6 + '@types/react': 19.1.8 '@types/react@19.1.6': dependencies: @@ -14446,6 +14561,18 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + bullmq@5.56.5: + dependencies: + cron-parser: 4.9.0 + ioredis: 5.6.1 + msgpackr: 1.11.5 + node-abort-controller: 3.1.1 + semver: 7.7.2 + tslib: 2.8.1 + uuid: 9.0.1 + transitivePeerDependencies: + - supports-color + bundle-name@4.1.0: dependencies: run-applescript: 7.0.0 @@ -14833,6 +14960,14 @@ snapshots: create-require@1.1.1: {} + cron-parser@4.9.0: + dependencies: + luxon: 3.7.1 + + cron-parser@5.3.0: + dependencies: + luxon: 3.7.1 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -16357,6 +16492,10 @@ snapshots: human-signals@2.1.0: {} + hypercron@0.1.0: + dependencies: + cron-parser: 5.3.0 + hyperdyperid@1.2.0: {} i18next-fs-backend@2.6.0: {} @@ -16916,6 +17055,8 @@ snapshots: dependencies: react: 19.1.0 + luxon@3.7.1: {} + magic-bytes.js@1.10.0: {} magic-string@0.30.17: @@ -17551,6 +17692,22 @@ snapshots: ms@2.1.3: {} + msgpackr-extract@3.0.3: + dependencies: + node-gyp-build-optional-packages: 5.2.2 + optionalDependencies: + '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.3 + optional: true + + msgpackr@1.11.5: + optionalDependencies: + msgpackr-extract: 3.0.3 + multicast-dns@7.2.5: dependencies: dns-packet: 5.6.1 @@ -17592,6 +17749,8 @@ snapshots: lower-case: 2.0.2 tslib: 2.8.1 + node-abort-controller@3.1.1: {} + node-emoji@2.2.0: dependencies: '@sindresorhus/is': 4.6.0 @@ -17601,6 +17760,11 @@ snapshots: node-forge@1.3.1: {} + node-gyp-build-optional-packages@5.2.2: + dependencies: + detect-libc: 2.0.4 + optional: true + node-plop@0.26.3: dependencies: '@babel/runtime-corejs3': 7.28.0 @@ -19911,6 +20075,8 @@ snapshots: uuid@8.3.2: {} + uuid@9.0.1: {} + v8-compile-cache-lib@3.0.1: {} validate-npm-package-name@5.0.1: {}