cordsmith 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +43 -0
- package/package.json +72 -0
- package/src/handler/@types/command.ts +79 -0
- package/src/handler/@types/contextMenu.ts +80 -0
- package/src/handler/@types/event.ts +37 -0
- package/src/handler/@types/precondition.ts +28 -0
- package/src/handler/@types/task.ts +69 -0
- package/src/handler/command/CommandHandler.ts +192 -0
- package/src/handler/command/functions/UserError.ts +13 -0
- package/src/handler/command/functions/attachInteractionListener.ts +271 -0
- package/src/handler/command/functions/commandCache.ts +97 -0
- package/src/handler/command/functions/cooldowns.ts +93 -0
- package/src/handler/command/functions/customId.ts +78 -0
- package/src/handler/command/functions/loadCommands.ts +62 -0
- package/src/handler/command/functions/owners.ts +10 -0
- package/src/handler/command/functions/preconditions/BotPermissions.ts +34 -0
- package/src/handler/command/functions/preconditions/Cooldown.ts +37 -0
- package/src/handler/command/functions/preconditions/GuildOnly.ts +18 -0
- package/src/handler/command/functions/preconditions/OwnerOnly.ts +18 -0
- package/src/handler/command/functions/preconditions/UserPermissions.ts +33 -0
- package/src/handler/command/functions/registerCommands.ts +140 -0
- package/src/handler/command/functions/runPreconditions.ts +103 -0
- package/src/handler/command/index.ts +20 -0
- package/src/handler/context/ContextMenuHandler.ts +172 -0
- package/src/handler/context/functions/attachContextMenuListener.ts +174 -0
- package/src/handler/context/functions/loadContextMenus.ts +59 -0
- package/src/handler/context/functions/runContextMenuPreconditions.ts +114 -0
- package/src/handler/context/index.ts +12 -0
- package/src/handler/events/EventHandler.ts +45 -0
- package/src/handler/events/functions/attachEvents.ts +95 -0
- package/src/handler/events/functions/loadEvents.ts +93 -0
- package/src/handler/events/index.ts +2 -0
- package/src/handler/manager/HandlerManager.ts +225 -0
- package/src/handler/manager/index.ts +2 -0
- package/src/handler/manager/registrationPlans.ts +58 -0
- package/src/handler/tasks/TaskHandler.ts +73 -0
- package/src/handler/tasks/functions/loadTasks.ts +75 -0
- package/src/handler/tasks/functions/parseCron.ts +187 -0
- package/src/handler/tasks/functions/scheduleTask.ts +106 -0
- package/src/handler/tasks/index.ts +4 -0
- package/src/handler/utils/env.ts +7 -0
- package/src/handler/utils/files.ts +74 -0
- package/src/index.ts +39 -0
- package/src/structure/Client.ts +8 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/logger.ts +63 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { RegisterMode } from "../command/functions/registerCommands";
|
|
2
|
+
|
|
3
|
+
export type HandlerRegistrationPlan = {
|
|
4
|
+
register: {
|
|
5
|
+
token: string;
|
|
6
|
+
applicationId: string;
|
|
7
|
+
where: RegisterMode;
|
|
8
|
+
};
|
|
9
|
+
commandJson: unknown[];
|
|
10
|
+
cache: boolean;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type CombinedRegistrationPlan = HandlerRegistrationPlan;
|
|
14
|
+
|
|
15
|
+
function whereKey(where: RegisterMode): string {
|
|
16
|
+
switch (where.mode) {
|
|
17
|
+
case "none":
|
|
18
|
+
return "none";
|
|
19
|
+
case "global":
|
|
20
|
+
return "global";
|
|
21
|
+
case "guild":
|
|
22
|
+
return `guild:${where.guildId}`;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function registrationKey(plan: HandlerRegistrationPlan): string {
|
|
27
|
+
const { register } = plan;
|
|
28
|
+
return [
|
|
29
|
+
register.token,
|
|
30
|
+
register.applicationId,
|
|
31
|
+
whereKey(register.where),
|
|
32
|
+
].join("\0");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function combineRegistrationPlans(
|
|
36
|
+
plans: HandlerRegistrationPlan[],
|
|
37
|
+
): CombinedRegistrationPlan[] {
|
|
38
|
+
const grouped = new Map<string, CombinedRegistrationPlan>();
|
|
39
|
+
|
|
40
|
+
for (const plan of plans) {
|
|
41
|
+
const key = registrationKey(plan);
|
|
42
|
+
const existing = grouped.get(key);
|
|
43
|
+
|
|
44
|
+
if (!existing) {
|
|
45
|
+
grouped.set(key, {
|
|
46
|
+
register: plan.register,
|
|
47
|
+
commandJson: [...plan.commandJson],
|
|
48
|
+
cache: plan.cache,
|
|
49
|
+
});
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
existing.commandJson.push(...plan.commandJson);
|
|
54
|
+
existing.cache = existing.cache && plan.cache;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return [...grouped.values()];
|
|
58
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { ClientClass } from "../../structure/Client";
|
|
2
|
+
import { logger } from "../../utils";
|
|
3
|
+
import type { LoadedTask } from "../@types/task";
|
|
4
|
+
import { loadTasksFromDisk } from "./functions/loadTasks";
|
|
5
|
+
import { scheduleTask, type TaskHandle } from "./functions/scheduleTask";
|
|
6
|
+
|
|
7
|
+
export type TaskHandlerOptions = {
|
|
8
|
+
client: ClientClass;
|
|
9
|
+
tasksDir: string;
|
|
10
|
+
extensions?: string[];
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export class TaskHandler {
|
|
14
|
+
private readonly client: ClientClass;
|
|
15
|
+
private readonly tasksDir: string;
|
|
16
|
+
private readonly extensions: string[];
|
|
17
|
+
|
|
18
|
+
public readonly tasks: LoadedTask[] = [];
|
|
19
|
+
private readonly handles: TaskHandle[] = [];
|
|
20
|
+
|
|
21
|
+
private initialized = false;
|
|
22
|
+
|
|
23
|
+
constructor(options: TaskHandlerOptions) {
|
|
24
|
+
this.client = options.client;
|
|
25
|
+
this.tasksDir = options.tasksDir;
|
|
26
|
+
this.extensions = options.extensions ?? [".ts"];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
public async init(): Promise<void> {
|
|
30
|
+
if (this.initialized) {
|
|
31
|
+
throw new Error("TaskHandler.init() was called more than once.");
|
|
32
|
+
}
|
|
33
|
+
this.initialized = true;
|
|
34
|
+
|
|
35
|
+
const loaded = await loadTasksFromDisk({
|
|
36
|
+
tasksDir: this.tasksDir,
|
|
37
|
+
extensions: this.extensions,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
for (const task of loaded) {
|
|
41
|
+
this.tasks.push(task);
|
|
42
|
+
|
|
43
|
+
const handle = scheduleTask(task, { client: this.client });
|
|
44
|
+
this.handles.push(handle);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Cancel all running tasks and clear their timers.
|
|
50
|
+
* Useful for graceful shutdown or hot-reload scenarios.
|
|
51
|
+
*/
|
|
52
|
+
public cancelAll(): void {
|
|
53
|
+
for (const handle of this.handles) {
|
|
54
|
+
handle.cancel();
|
|
55
|
+
}
|
|
56
|
+
this.handles.length = 0;
|
|
57
|
+
logger.info("All tasks cancelled.");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Cancel a single task by name.
|
|
62
|
+
* Returns true if found and cancelled, false otherwise.
|
|
63
|
+
*/
|
|
64
|
+
public cancel(name: string): boolean {
|
|
65
|
+
const handle = this.handles.find((h) => h.name === name);
|
|
66
|
+
|
|
67
|
+
if (!handle) return false;
|
|
68
|
+
|
|
69
|
+
handle.cancel();
|
|
70
|
+
this.handles.splice(this.handles.indexOf(handle), 1);
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
import { logger } from "../../../utils";
|
|
4
|
+
import type { LoadedTask, TaskModule } from "../../@types/task";
|
|
5
|
+
import { discoverModuleFiles } from "../../utils/files";
|
|
6
|
+
|
|
7
|
+
function isTaskModule(mod: unknown): mod is TaskModule {
|
|
8
|
+
if (!mod || typeof mod !== "object") return false;
|
|
9
|
+
|
|
10
|
+
const m = mod as Partial<TaskModule>;
|
|
11
|
+
|
|
12
|
+
if (typeof m.execute !== "function") return false;
|
|
13
|
+
|
|
14
|
+
const hasInterval = typeof m.intervalMs === "number" && m.intervalMs > 0;
|
|
15
|
+
const hasCron = typeof m.cron === "string" && m.cron.trim().length > 0;
|
|
16
|
+
|
|
17
|
+
// Must have exactly one scheduling strategy
|
|
18
|
+
if (!hasInterval && !hasCron) return false;
|
|
19
|
+
if (hasInterval && hasCron) return false;
|
|
20
|
+
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function nameFromFilePath(tasksDir: string, filePath: string): string {
|
|
25
|
+
const rel = path.relative(tasksDir, filePath).replace(/\\/g, "/");
|
|
26
|
+
// Strip extension: "cleanup/oldMessages.ts" → "cleanup/oldMessages"
|
|
27
|
+
return rel.replace(/\.[^/.]+$/, "");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function loadTasksFromDisk(options: {
|
|
31
|
+
tasksDir: string;
|
|
32
|
+
extensions: string[];
|
|
33
|
+
}): Promise<LoadedTask[]> {
|
|
34
|
+
const { rootDir, files } = await discoverModuleFiles({
|
|
35
|
+
dir: options.tasksDir,
|
|
36
|
+
extensions: options.extensions,
|
|
37
|
+
});
|
|
38
|
+
const tasks: LoadedTask[] = [];
|
|
39
|
+
let loaded = 0;
|
|
40
|
+
|
|
41
|
+
for (const filePath of files) {
|
|
42
|
+
const fileUrl = pathToFileURL(filePath).href;
|
|
43
|
+
const imported = await import(fileUrl);
|
|
44
|
+
const mod = imported.default;
|
|
45
|
+
|
|
46
|
+
if (!isTaskModule(mod)) {
|
|
47
|
+
logger.warn(`Skipping invalid task module: ${filePath}`);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (mod.enabled === false) continue;
|
|
52
|
+
|
|
53
|
+
const name = mod.name ?? nameFromFilePath(rootDir, filePath);
|
|
54
|
+
|
|
55
|
+
// Bug fix: duplicate names caused TaskHandler.cancel(name) to only
|
|
56
|
+
// ever find the first match, leaving the second task running silently.
|
|
57
|
+
if (tasks.some((t) => t.name === name)) {
|
|
58
|
+
throw new Error(`Duplicate task name "${name}" detected: ${filePath}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
tasks.push({
|
|
62
|
+
name,
|
|
63
|
+
filePath,
|
|
64
|
+
intervalMs: mod.intervalMs,
|
|
65
|
+
cron: mod.cron,
|
|
66
|
+
runOnStart: mod.runOnStart ?? false,
|
|
67
|
+
execute: mod.execute,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
loaded += 1;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
logger.info(`Loaded ${loaded} task(s) from ${rootDir}`);
|
|
74
|
+
return tasks;
|
|
75
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight cron expression parser.
|
|
3
|
+
*
|
|
4
|
+
* Supports standard 5-field cron: "min hour dom mon dow"
|
|
5
|
+
* Fields support: "*", numbers, ranges (1-5), steps (* /5, 1-5/2), and lists (1,3,5).
|
|
6
|
+
*
|
|
7
|
+
* Does NOT support @yearly/@monthly/@weekly/@daily/@hourly aliases or
|
|
8
|
+
* 6-field (seconds) expressions. Use intervalMs for sub-minute precision.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
type CronField = {
|
|
12
|
+
values: Set<number>;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function parseField(field: string, min: number, max: number): CronField {
|
|
16
|
+
const values = new Set<number>();
|
|
17
|
+
|
|
18
|
+
for (const rawPart of field.split(",")) {
|
|
19
|
+
const part = rawPart.trim();
|
|
20
|
+
|
|
21
|
+
if (part.length === 0) {
|
|
22
|
+
throw new Error(`Empty value in cron field "${field}".`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (part === "*") {
|
|
26
|
+
for (let i = min; i <= max; i++) values.add(i);
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const stepMatch = part.match(/^(\*|\d+(?:-\d+)?)\/(\d+)$/);
|
|
31
|
+
if (stepMatch) {
|
|
32
|
+
const [, range, stepStr] = stepMatch;
|
|
33
|
+
const step = Number(stepStr);
|
|
34
|
+
|
|
35
|
+
// Bug fix: step=0 would cause an infinite loop in the for-loop below
|
|
36
|
+
if (step <= 0) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
`Invalid step value "${stepStr}" in cron field "${field}": step must be >= 1.`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const rangePart = range ?? "*";
|
|
43
|
+
const [start, end] =
|
|
44
|
+
rangePart === "*"
|
|
45
|
+
? [min, max]
|
|
46
|
+
: rangePart.includes("-")
|
|
47
|
+
? (rangePart.split("-").map(Number) as [number, number])
|
|
48
|
+
: [Number(rangePart), Number(rangePart)];
|
|
49
|
+
|
|
50
|
+
if (
|
|
51
|
+
!Number.isInteger(start) ||
|
|
52
|
+
!Number.isInteger(end) ||
|
|
53
|
+
start < min ||
|
|
54
|
+
end > max ||
|
|
55
|
+
start > end
|
|
56
|
+
) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
`Invalid stepped range "${rangePart}" in cron field "${field}": must be within [${min}-${max}].`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
for (let i = start; i <= end; i += step) {
|
|
63
|
+
values.add(i);
|
|
64
|
+
}
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const rangeMatch = part.match(/^(\d+)-(\d+)$/);
|
|
69
|
+
if (rangeMatch) {
|
|
70
|
+
const start = Number(rangeMatch[1]);
|
|
71
|
+
const end = Number(rangeMatch[2]);
|
|
72
|
+
|
|
73
|
+
// Bug fix: out-of-range ranges were silently ignored, task would never fire
|
|
74
|
+
if (start < min || end > max || start > end) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
`Invalid range "${part}" in cron field "${field}": must be within [${min}-${max}].`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
for (let i = start; i <= end; i++) values.add(i);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (/^\d+$/.test(part)) {
|
|
85
|
+
const num = Number(part);
|
|
86
|
+
|
|
87
|
+
if (!Number.isInteger(num) || num < min || num > max) {
|
|
88
|
+
throw new Error(
|
|
89
|
+
`Value ${num} is out of range [${min}-${max}] in cron field "${field}".`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
values.add(num);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
throw new Error(`Invalid token "${part}" in cron field "${field}".`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return { values };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export type ParsedCron = {
|
|
104
|
+
minute: CronField;
|
|
105
|
+
hour: CronField;
|
|
106
|
+
dom: CronField; // day of month
|
|
107
|
+
month: CronField;
|
|
108
|
+
dow: CronField; // day of week
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export function parseCron(expression: string): ParsedCron {
|
|
112
|
+
const parts = expression.trim().split(/\s+/);
|
|
113
|
+
|
|
114
|
+
if (parts.length !== 5) {
|
|
115
|
+
throw new Error(
|
|
116
|
+
`Invalid cron expression "${expression}": expected 5 fields, got ${parts.length}.`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const [minute, hour, dom, month, dow] = parts as [
|
|
121
|
+
string,
|
|
122
|
+
string,
|
|
123
|
+
string,
|
|
124
|
+
string,
|
|
125
|
+
string,
|
|
126
|
+
];
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
minute: parseField(minute, 0, 59),
|
|
130
|
+
hour: parseField(hour, 0, 23),
|
|
131
|
+
dom: parseField(dom, 1, 31),
|
|
132
|
+
month: parseField(month, 1, 12),
|
|
133
|
+
dow: parseField(dow, 0, 6),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Returns the number of milliseconds until the next tick of a cron expression.
|
|
139
|
+
*
|
|
140
|
+
* Uses a skip-ahead strategy instead of iterating minute-by-minute.
|
|
141
|
+
* For sparse expressions like "0 0 1 1 *" (once a year), the old approach
|
|
142
|
+
* iterated up to 525,960 times per reschedule. Now:
|
|
143
|
+
* - month/dom/dow mismatch → jump to midnight of the next day (O(366) worst case)
|
|
144
|
+
* - hour mismatch → jump to the next hour (O(24) per day)
|
|
145
|
+
* - minute mismatch → advance one minute (O(60) per hour)
|
|
146
|
+
*/
|
|
147
|
+
export function msUntilNextCronTick(
|
|
148
|
+
parsed: ParsedCron,
|
|
149
|
+
from: Date = new Date(),
|
|
150
|
+
): number {
|
|
151
|
+
// Start from the next whole minute
|
|
152
|
+
const candidate = new Date(from);
|
|
153
|
+
candidate.setSeconds(0, 0);
|
|
154
|
+
candidate.setMinutes(candidate.getMinutes() + 1);
|
|
155
|
+
|
|
156
|
+
const limit = new Date(from.getTime() + 366 * 24 * 60 * 60 * 1000);
|
|
157
|
+
|
|
158
|
+
while (candidate < limit) {
|
|
159
|
+
// Month, dom, or dow mismatch — jump to midnight of the next day
|
|
160
|
+
if (
|
|
161
|
+
!parsed.month.values.has(candidate.getMonth() + 1) ||
|
|
162
|
+
!parsed.dom.values.has(candidate.getDate()) ||
|
|
163
|
+
!parsed.dow.values.has(candidate.getDay())
|
|
164
|
+
) {
|
|
165
|
+
candidate.setDate(candidate.getDate() + 1);
|
|
166
|
+
candidate.setHours(0, 0, 0, 0);
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Hour mismatch — jump to the next hour
|
|
171
|
+
if (!parsed.hour.values.has(candidate.getHours())) {
|
|
172
|
+
candidate.setHours(candidate.getHours() + 1, 0, 0, 0);
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Matching day + hour — check minute
|
|
177
|
+
if (parsed.minute.values.has(candidate.getMinutes())) {
|
|
178
|
+
return candidate.getTime() - from.getTime();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
candidate.setMinutes(candidate.getMinutes() + 1);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
throw new Error(
|
|
185
|
+
`Could not find next tick for cron expression within 366 days.`,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { logger } from "../../../utils";
|
|
2
|
+
import type { LoadedTask, TaskContext } from "../../@types/task";
|
|
3
|
+
import { msUntilNextCronTick, parseCron } from "./parseCron";
|
|
4
|
+
|
|
5
|
+
export type TaskHandle = {
|
|
6
|
+
name: string;
|
|
7
|
+
cancel: () => void;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Schedules a single task and returns a handle to cancel it.
|
|
12
|
+
*
|
|
13
|
+
* Interval tasks use a self-rescheduling setTimeout rather than setInterval
|
|
14
|
+
* so a slow execution cannot overlap with the next run — the next tick is only
|
|
15
|
+
* scheduled after the current one completes.
|
|
16
|
+
*
|
|
17
|
+
* Cron tasks use a chain of setTimeout calls, recalculating the next tick
|
|
18
|
+
* each time so the schedule stays accurate across DST changes and variable
|
|
19
|
+
* month lengths.
|
|
20
|
+
*/
|
|
21
|
+
export function scheduleTask(task: LoadedTask, ctx: TaskContext): TaskHandle {
|
|
22
|
+
let cancelled = false;
|
|
23
|
+
// Track the active timer and its type separately so we call the correct
|
|
24
|
+
// clear function — calling clearInterval on a setTimeout id is a no-op in
|
|
25
|
+
// most runtimes but is semantically wrong and confusing.
|
|
26
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
27
|
+
let timerType: "timeout" | "interval" | null = null;
|
|
28
|
+
let running = false;
|
|
29
|
+
|
|
30
|
+
async function run(): Promise<void> {
|
|
31
|
+
if (cancelled) return;
|
|
32
|
+
if (running) return;
|
|
33
|
+
|
|
34
|
+
running = true;
|
|
35
|
+
try {
|
|
36
|
+
await task.execute(ctx);
|
|
37
|
+
} catch (err) {
|
|
38
|
+
logger.error(`Error in task "${task.name}"`, err);
|
|
39
|
+
} finally {
|
|
40
|
+
running = false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function cancelTimer(): void {
|
|
45
|
+
if (timer === null) return;
|
|
46
|
+
if (timerType === "interval") {
|
|
47
|
+
clearInterval(timer);
|
|
48
|
+
} else {
|
|
49
|
+
clearTimeout(timer);
|
|
50
|
+
}
|
|
51
|
+
timer = null;
|
|
52
|
+
timerType = null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (task.intervalMs) {
|
|
56
|
+
const intervalMs = task.intervalMs;
|
|
57
|
+
|
|
58
|
+
async function scheduleNextInterval(): Promise<void> {
|
|
59
|
+
if (cancelled) return;
|
|
60
|
+
const start = Date.now();
|
|
61
|
+
await run();
|
|
62
|
+
if (cancelled) return;
|
|
63
|
+
const elapsed = Date.now() - start;
|
|
64
|
+
const delay = Math.max(0, intervalMs - elapsed);
|
|
65
|
+
timer = setTimeout(scheduleNextInterval, delay);
|
|
66
|
+
timerType = "timeout";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
timer = setTimeout(
|
|
70
|
+
scheduleNextInterval,
|
|
71
|
+
task.runOnStart ? 0 : intervalMs,
|
|
72
|
+
);
|
|
73
|
+
timerType = "timeout";
|
|
74
|
+
|
|
75
|
+
logger.info(`Scheduled task "${task.name}" every ${intervalMs}ms.`);
|
|
76
|
+
} else if (task.cron) {
|
|
77
|
+
const parsed = parseCron(task.cron);
|
|
78
|
+
|
|
79
|
+
if (task.runOnStart) {
|
|
80
|
+
void run();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function scheduleNextCronTick(): void {
|
|
84
|
+
if (cancelled) return;
|
|
85
|
+
const delay = msUntilNextCronTick(parsed);
|
|
86
|
+
timer = setTimeout(async () => {
|
|
87
|
+
await run();
|
|
88
|
+
scheduleNextCronTick();
|
|
89
|
+
}, delay);
|
|
90
|
+
timerType = "timeout";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
scheduleNextCronTick();
|
|
94
|
+
|
|
95
|
+
logger.info(`Scheduled task "${task.name}" with cron "${task.cron}".`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
name: task.name,
|
|
100
|
+
cancel(): void {
|
|
101
|
+
cancelled = true;
|
|
102
|
+
cancelTimer();
|
|
103
|
+
logger.info(`Cancelled task "${task.name}".`);
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { lstat, realpath } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export type DiscoveredModuleFiles = {
|
|
5
|
+
rootDir: string;
|
|
6
|
+
files: string[];
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const EXTENSION_PATTERN = /^\.[A-Za-z0-9]+$/;
|
|
10
|
+
|
|
11
|
+
function resolveDir(dir: string): string {
|
|
12
|
+
return path.isAbsolute(dir) ? dir : path.resolve(process.cwd(), dir);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function isInsideDir(rootDir: string, filePath: string): boolean {
|
|
16
|
+
const rel = path.relative(rootDir, filePath);
|
|
17
|
+
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function normalizeExtensions(extensions: string[]): string[] {
|
|
21
|
+
const normalized = new Set<string>();
|
|
22
|
+
|
|
23
|
+
for (const ext of extensions) {
|
|
24
|
+
if (!EXTENSION_PATTERN.test(ext)) {
|
|
25
|
+
throw new Error(
|
|
26
|
+
`Invalid module extension "${ext}". Use simple extensions like ".ts", ".js", or ".mjs".`,
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
normalized.add(ext);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (normalized.size === 0) {
|
|
34
|
+
throw new Error("At least one module extension is required.");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return [...normalized].sort();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function discoverModuleFiles(options: {
|
|
41
|
+
dir: string;
|
|
42
|
+
extensions: string[];
|
|
43
|
+
}): Promise<DiscoveredModuleFiles> {
|
|
44
|
+
const rootDir = await realpath(resolveDir(options.dir));
|
|
45
|
+
const rootStats = await lstat(rootDir);
|
|
46
|
+
|
|
47
|
+
if (!rootStats.isDirectory()) {
|
|
48
|
+
throw new Error(`Module directory is not a directory: ${rootDir}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const extensions = normalizeExtensions(options.extensions);
|
|
52
|
+
const files = new Set<string>();
|
|
53
|
+
|
|
54
|
+
for (const ext of extensions) {
|
|
55
|
+
const pattern = path.join(rootDir, `**/*${ext}`);
|
|
56
|
+
|
|
57
|
+
for await (const filePath of new Bun.Glob(pattern).scan()) {
|
|
58
|
+
if (filePath.endsWith(".d.ts")) continue;
|
|
59
|
+
|
|
60
|
+
const realFilePath = await realpath(filePath);
|
|
61
|
+
if (!isInsideDir(rootDir, realFilePath)) continue;
|
|
62
|
+
|
|
63
|
+
const stats = await lstat(realFilePath);
|
|
64
|
+
if (!stats.isFile()) continue;
|
|
65
|
+
|
|
66
|
+
files.add(realFilePath);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
rootDir,
|
|
72
|
+
files: [...files].sort((a, b) => a.localeCompare(b)),
|
|
73
|
+
};
|
|
74
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export * from "./handler/command";
|
|
2
|
+
export * from "./handler/context";
|
|
3
|
+
export * from "./handler/events";
|
|
4
|
+
export * from "./handler/manager";
|
|
5
|
+
export * from "./handler/tasks";
|
|
6
|
+
export type {
|
|
7
|
+
CommandContext,
|
|
8
|
+
CommandMeta,
|
|
9
|
+
ComponentHandler,
|
|
10
|
+
SlashCommand,
|
|
11
|
+
SlashCommandData,
|
|
12
|
+
} from "./handler/@types/command";
|
|
13
|
+
export type {
|
|
14
|
+
ContextMenuCommand,
|
|
15
|
+
ContextMenuMeta,
|
|
16
|
+
MessageContextMenuCommand,
|
|
17
|
+
UserContextMenuCommand,
|
|
18
|
+
} from "./handler/@types/contextMenu";
|
|
19
|
+
export {
|
|
20
|
+
defineMessageContextMenu,
|
|
21
|
+
defineUserContextMenu,
|
|
22
|
+
} from "./handler/@types/contextMenu";
|
|
23
|
+
export type {
|
|
24
|
+
EventListenerModule,
|
|
25
|
+
EventName,
|
|
26
|
+
} from "./handler/@types/event";
|
|
27
|
+
export { defineEvent } from "./handler/@types/event";
|
|
28
|
+
export type {
|
|
29
|
+
Precondition,
|
|
30
|
+
PreconditionContext,
|
|
31
|
+
PreconditionResult,
|
|
32
|
+
} from "./handler/@types/precondition";
|
|
33
|
+
export type {
|
|
34
|
+
LoadedTask,
|
|
35
|
+
TaskContext,
|
|
36
|
+
TaskModule,
|
|
37
|
+
} from "./handler/@types/task";
|
|
38
|
+
export { defineTask } from "./handler/@types/task";
|
|
39
|
+
export type { ClientClass, HandlerClient } from "./structure/Client";
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export type HandlerClient = {
|
|
2
|
+
on: (eventName: any, listener: (...args: any[]) => unknown) => unknown;
|
|
3
|
+
once: (eventName: any, listener: (...args: any[]) => unknown) => unknown;
|
|
4
|
+
off: (eventName: any, listener: (...args: any[]) => unknown) => unknown;
|
|
5
|
+
db?: any;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export type ClientClass = HandlerClient;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./logger";
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
type LogContext =
|
|
2
|
+
| Record<string, string | number | boolean | null | undefined>
|
|
3
|
+
| undefined;
|
|
4
|
+
|
|
5
|
+
export type Logger = {
|
|
6
|
+
info: (message: string, context?: LogContext) => void;
|
|
7
|
+
warn: (message: string, context?: LogContext) => void;
|
|
8
|
+
error: (message: string, error?: unknown, context?: LogContext) => void;
|
|
9
|
+
debug: (message: string, context?: LogContext) => void;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
function timestamp(): string {
|
|
13
|
+
return new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function formatContext(context: LogContext): string {
|
|
17
|
+
if (!context) return "";
|
|
18
|
+
|
|
19
|
+
const entries = Object.entries(context).filter(([, value]) => value !== undefined);
|
|
20
|
+
if (entries.length === 0) return "";
|
|
21
|
+
|
|
22
|
+
return ` ${entries.map(([key, value]) => `${key}=${String(value)}`).join(" ")}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function normalizeError(error: unknown): { message: string; stack?: string } {
|
|
26
|
+
if (error instanceof Error) {
|
|
27
|
+
return { message: error.message, stack: error.stack };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
return { message: JSON.stringify(error) };
|
|
32
|
+
} catch {
|
|
33
|
+
return { message: String(error) };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const debugEnabled = (): boolean =>
|
|
38
|
+
String(Bun.env.LOG_LEVEL ?? "").toLowerCase() === "debug";
|
|
39
|
+
|
|
40
|
+
export const logger: Logger = {
|
|
41
|
+
info(message, context) {
|
|
42
|
+
console.log(`${timestamp()} [INFO] ${message}${formatContext(context)}`);
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
warn(message, context) {
|
|
46
|
+
console.warn(`${timestamp()} [WARN] ${message}${formatContext(context)}`);
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
error(message, error, context) {
|
|
50
|
+
console.error(`${timestamp()} [ERROR] ${message}${formatContext(context)}`);
|
|
51
|
+
|
|
52
|
+
if (error === undefined) return;
|
|
53
|
+
|
|
54
|
+
const normalized = normalizeError(error);
|
|
55
|
+
console.error(normalized.message);
|
|
56
|
+
if (normalized.stack) console.error(normalized.stack);
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
debug(message, context) {
|
|
60
|
+
if (!debugEnabled()) return;
|
|
61
|
+
console.log(`${timestamp()} [DEBUG] ${message}${formatContext(context)}`);
|
|
62
|
+
},
|
|
63
|
+
};
|