spearkit 0.1.0 → 0.2.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/README.md +11 -0
- package/dist/index.cjs +974 -12
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +753 -206
- package/dist/index.d.ts +753 -206
- package/dist/index.js +955 -14
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -1,11 +1,805 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
var discord_js = require('discord.js');
|
|
4
|
-
var
|
|
4
|
+
var fs = require('fs');
|
|
5
5
|
var path = require('path');
|
|
6
|
+
var promises = require('fs/promises');
|
|
6
7
|
var url = require('url');
|
|
7
8
|
|
|
8
9
|
// src/index.ts
|
|
10
|
+
|
|
11
|
+
// src/logger.ts
|
|
12
|
+
var RANK = {
|
|
13
|
+
debug: 10,
|
|
14
|
+
info: 20,
|
|
15
|
+
warn: 30,
|
|
16
|
+
error: 40,
|
|
17
|
+
silent: Number.POSITIVE_INFINITY
|
|
18
|
+
};
|
|
19
|
+
function formatValue(value) {
|
|
20
|
+
return typeof value === "string" ? value : String(value);
|
|
21
|
+
}
|
|
22
|
+
function consoleSink(entry) {
|
|
23
|
+
const scope = entry.scope !== void 0 ? ` [${entry.scope}]` : "";
|
|
24
|
+
let suffix = "";
|
|
25
|
+
if (entry.data !== void 0) {
|
|
26
|
+
const parts = Object.entries(entry.data).map(([k, v]) => `${k}=${formatValue(v)}`);
|
|
27
|
+
if (parts.length > 0) suffix = ` ${parts.join(" ")}`;
|
|
28
|
+
}
|
|
29
|
+
const line = `${entry.timestamp.toISOString()} ${entry.level.toUpperCase()}${scope} ${entry.message}${suffix}`;
|
|
30
|
+
const write = entry.level === "warn" || entry.level === "error" ? console.error : console.log;
|
|
31
|
+
write(line);
|
|
32
|
+
if (entry.error !== void 0) write(entry.error.stack ?? String(entry.error));
|
|
33
|
+
}
|
|
34
|
+
var Logger = class _Logger {
|
|
35
|
+
state;
|
|
36
|
+
/** The scope prefix applied to every entry, if any. */
|
|
37
|
+
scope;
|
|
38
|
+
constructor(options = {}) {
|
|
39
|
+
this.state = {
|
|
40
|
+
threshold: options.level ?? "info",
|
|
41
|
+
sink: options.sink ?? consoleSink
|
|
42
|
+
};
|
|
43
|
+
this.scope = options.scope;
|
|
44
|
+
}
|
|
45
|
+
/** The current minimum threshold. */
|
|
46
|
+
get level() {
|
|
47
|
+
return this.state.threshold;
|
|
48
|
+
}
|
|
49
|
+
/** Change the threshold for this logger and every child sharing its state. */
|
|
50
|
+
setLevel(level) {
|
|
51
|
+
this.state.threshold = level;
|
|
52
|
+
return this;
|
|
53
|
+
}
|
|
54
|
+
/** Whether an entry of `level` would currently be emitted. */
|
|
55
|
+
enabled(level) {
|
|
56
|
+
return RANK[level] >= RANK[this.state.threshold];
|
|
57
|
+
}
|
|
58
|
+
/** A child logger with an extra scope segment, sharing this logger's state. */
|
|
59
|
+
child(scope) {
|
|
60
|
+
const combined = this.scope !== void 0 ? `${this.scope}:${scope}` : scope;
|
|
61
|
+
const child = new _Logger({ scope: combined });
|
|
62
|
+
child.state = this.state;
|
|
63
|
+
return child;
|
|
64
|
+
}
|
|
65
|
+
/** Emit an entry at an explicit level. */
|
|
66
|
+
log(level, message, options) {
|
|
67
|
+
if (!this.enabled(level)) return;
|
|
68
|
+
this.state.sink({
|
|
69
|
+
level,
|
|
70
|
+
message,
|
|
71
|
+
scope: this.scope,
|
|
72
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
73
|
+
error: options?.error,
|
|
74
|
+
data: options?.data
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
/** Verbose diagnostics, off by default. */
|
|
78
|
+
debug(message, options) {
|
|
79
|
+
this.log("debug", message, options);
|
|
80
|
+
}
|
|
81
|
+
/** Normal operational messages. */
|
|
82
|
+
info(message, options) {
|
|
83
|
+
this.log("info", message, options);
|
|
84
|
+
}
|
|
85
|
+
/** Recoverable problems worth attention. */
|
|
86
|
+
warn(message, options) {
|
|
87
|
+
this.log("warn", message, options);
|
|
88
|
+
}
|
|
89
|
+
/** Failures. Attach the cause via `{ error }`. */
|
|
90
|
+
error(message, options) {
|
|
91
|
+
this.log("error", message, options);
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
function toError(value) {
|
|
95
|
+
return value instanceof Error ? value : new Error(String(value));
|
|
96
|
+
}
|
|
97
|
+
function stripInlineComment(value) {
|
|
98
|
+
const match = /\s#/.exec(value);
|
|
99
|
+
return match !== null ? value.slice(0, match.index).trimEnd() : value;
|
|
100
|
+
}
|
|
101
|
+
function unquote(raw) {
|
|
102
|
+
if (raw.length >= 2) {
|
|
103
|
+
const quote = raw[0];
|
|
104
|
+
if ((quote === '"' || quote === "'") && raw.endsWith(quote)) {
|
|
105
|
+
const inner = raw.slice(1, -1);
|
|
106
|
+
return quote === '"' ? inner.replace(/\\n/g, "\n").replace(/\\r/g, "\r").replace(/\\t/g, " ") : inner;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return stripInlineComment(raw);
|
|
110
|
+
}
|
|
111
|
+
function parseEnv(content) {
|
|
112
|
+
const out = {};
|
|
113
|
+
for (const rawLine of content.split(/\r?\n/)) {
|
|
114
|
+
const line = rawLine.trim();
|
|
115
|
+
if (line.length === 0 || line.startsWith("#")) continue;
|
|
116
|
+
const body = line.startsWith("export ") ? line.slice(7).trimStart() : line;
|
|
117
|
+
const eq = body.indexOf("=");
|
|
118
|
+
if (eq <= 0) continue;
|
|
119
|
+
const key = body.slice(0, eq).trim();
|
|
120
|
+
if (key.length === 0) continue;
|
|
121
|
+
out[key] = unquote(body.slice(eq + 1).trim());
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
function loadEnv(options = {}) {
|
|
126
|
+
const path$1 = options.path ?? path.join(process.cwd(), ".env");
|
|
127
|
+
let content;
|
|
128
|
+
try {
|
|
129
|
+
content = fs.readFileSync(path$1, "utf8");
|
|
130
|
+
} catch {
|
|
131
|
+
return {};
|
|
132
|
+
}
|
|
133
|
+
const parsed = parseEnv(content);
|
|
134
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
135
|
+
if (options.override === true || process.env[key] === void 0) {
|
|
136
|
+
process.env[key] = value;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return parsed;
|
|
140
|
+
}
|
|
141
|
+
var TRUTHY = /* @__PURE__ */ new Set(["true", "1", "yes", "on"]);
|
|
142
|
+
var FALSY = /* @__PURE__ */ new Set(["false", "0", "no", "off"]);
|
|
143
|
+
function read(key) {
|
|
144
|
+
const value = process.env[key];
|
|
145
|
+
return value !== void 0 && value !== "" ? value : void 0;
|
|
146
|
+
}
|
|
147
|
+
function envString(key, fallback) {
|
|
148
|
+
return read(key) ?? fallback;
|
|
149
|
+
}
|
|
150
|
+
function envNumber(key, fallback) {
|
|
151
|
+
const value = read(key);
|
|
152
|
+
if (value === void 0) return fallback;
|
|
153
|
+
const parsed = Number(value);
|
|
154
|
+
return Number.isNaN(parsed) ? fallback : parsed;
|
|
155
|
+
}
|
|
156
|
+
function envBoolean(key, fallback) {
|
|
157
|
+
const value = read(key)?.toLowerCase();
|
|
158
|
+
if (value === void 0) return fallback;
|
|
159
|
+
if (TRUTHY.has(value)) return true;
|
|
160
|
+
if (FALSY.has(value)) return false;
|
|
161
|
+
return fallback;
|
|
162
|
+
}
|
|
163
|
+
function envRequire(key) {
|
|
164
|
+
const value = read(key);
|
|
165
|
+
if (value === void 0) {
|
|
166
|
+
throw new Error(`spearkit: required environment variable "${key}" is missing or empty`);
|
|
167
|
+
}
|
|
168
|
+
return value;
|
|
169
|
+
}
|
|
170
|
+
var env = {
|
|
171
|
+
string: envString,
|
|
172
|
+
number: envNumber,
|
|
173
|
+
boolean: envBoolean,
|
|
174
|
+
require: envRequire
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
// src/cooldown.ts
|
|
178
|
+
function normalizeCooldown(input) {
|
|
179
|
+
return typeof input === "number" ? { duration: input } : input;
|
|
180
|
+
}
|
|
181
|
+
function scopeKey(scope, actor) {
|
|
182
|
+
switch (scope) {
|
|
183
|
+
case "guild":
|
|
184
|
+
return `g:${actor.guildId ?? "dm"}`;
|
|
185
|
+
case "channel":
|
|
186
|
+
return `c:${actor.channelId ?? "dm"}`;
|
|
187
|
+
case "global":
|
|
188
|
+
return "global";
|
|
189
|
+
case "user":
|
|
190
|
+
return `u:${actor.userId}`;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
function effectiveDuration(config, actor) {
|
|
194
|
+
if (config.exempt?.users?.includes(actor.userId) === true) return null;
|
|
195
|
+
if (config.exempt?.roles?.some((roleId) => actor.roleIds.includes(roleId)) === true) return null;
|
|
196
|
+
const userOverride = config.overrides?.users?.[actor.userId];
|
|
197
|
+
if (userOverride !== void 0) return userOverride;
|
|
198
|
+
const roleOverrides = config.overrides?.roles;
|
|
199
|
+
if (roleOverrides !== void 0) {
|
|
200
|
+
let best;
|
|
201
|
+
for (const roleId of actor.roleIds) {
|
|
202
|
+
const candidate = roleOverrides[roleId];
|
|
203
|
+
if (candidate !== void 0) best = best === void 0 ? candidate : Math.min(best, candidate);
|
|
204
|
+
}
|
|
205
|
+
if (best !== void 0) return best;
|
|
206
|
+
}
|
|
207
|
+
return config.duration;
|
|
208
|
+
}
|
|
209
|
+
function keyFor(bucket, config, actor) {
|
|
210
|
+
return `${bucket}|${scopeKey(config.scope ?? "user", actor)}`;
|
|
211
|
+
}
|
|
212
|
+
var CooldownManager = class {
|
|
213
|
+
hits = /* @__PURE__ */ new Map();
|
|
214
|
+
/** Number of tracked buckets. */
|
|
215
|
+
get size() {
|
|
216
|
+
return this.hits.size;
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Check whether `actor` may use `bucket`, recording the use when allowed.
|
|
220
|
+
* Exempt actors and non-positive durations are always allowed (no record).
|
|
221
|
+
*/
|
|
222
|
+
consume(bucket, input, actor, now = Date.now()) {
|
|
223
|
+
const config = normalizeCooldown(input);
|
|
224
|
+
const duration = effectiveDuration(config, actor);
|
|
225
|
+
if (duration === null || duration <= 0) return { allowed: true };
|
|
226
|
+
const key = keyFor(bucket, config, actor);
|
|
227
|
+
const last = this.hits.get(key);
|
|
228
|
+
if (last !== void 0 && now - last < duration) {
|
|
229
|
+
return { allowed: false, remaining: duration - (now - last) };
|
|
230
|
+
}
|
|
231
|
+
this.hits.set(key, now);
|
|
232
|
+
return { allowed: true };
|
|
233
|
+
}
|
|
234
|
+
/** Like {@link consume} but never records — a read-only check. */
|
|
235
|
+
peek(bucket, input, actor, now = Date.now()) {
|
|
236
|
+
const config = normalizeCooldown(input);
|
|
237
|
+
const duration = effectiveDuration(config, actor);
|
|
238
|
+
if (duration === null || duration <= 0) return { allowed: true };
|
|
239
|
+
const last = this.hits.get(keyFor(bucket, config, actor));
|
|
240
|
+
if (last !== void 0 && now - last < duration) {
|
|
241
|
+
return { allowed: false, remaining: duration - (now - last) };
|
|
242
|
+
}
|
|
243
|
+
return { allowed: true };
|
|
244
|
+
}
|
|
245
|
+
/** Clear a single actor's cooldown for a bucket. Returns whether one existed. */
|
|
246
|
+
reset(bucket, actor, scope = "user") {
|
|
247
|
+
return this.hits.delete(`${bucket}|${scopeKey(scope, actor)}`);
|
|
248
|
+
}
|
|
249
|
+
/** Drop every tracked cooldown. */
|
|
250
|
+
clear() {
|
|
251
|
+
this.hits.clear();
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
function formatCooldownMessage(config, remainingMs) {
|
|
255
|
+
if (typeof config.message === "function") return config.message(remainingMs);
|
|
256
|
+
if (typeof config.message === "string") return config.message;
|
|
257
|
+
const seconds = Math.max(1, Math.ceil(remainingMs / 1e3));
|
|
258
|
+
return `You're on cooldown \u2014 try again in ${seconds}s.`;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// src/scheduler.ts
|
|
262
|
+
var ALIASES = {
|
|
263
|
+
"@yearly": "0 0 1 1 *",
|
|
264
|
+
"@annually": "0 0 1 1 *",
|
|
265
|
+
"@monthly": "0 0 1 * *",
|
|
266
|
+
"@weekly": "0 0 * * 0",
|
|
267
|
+
"@daily": "0 0 * * *",
|
|
268
|
+
"@midnight": "0 0 * * *",
|
|
269
|
+
"@hourly": "0 * * * *"
|
|
270
|
+
};
|
|
271
|
+
function parseField(spec, min, max, label) {
|
|
272
|
+
const set = /* @__PURE__ */ new Set();
|
|
273
|
+
for (const part of spec.split(",")) {
|
|
274
|
+
let range = part;
|
|
275
|
+
let step = 1;
|
|
276
|
+
const slash = part.indexOf("/");
|
|
277
|
+
if (slash >= 0) {
|
|
278
|
+
step = Number(part.slice(slash + 1));
|
|
279
|
+
range = part.slice(0, slash);
|
|
280
|
+
if (!Number.isInteger(step) || step <= 0) {
|
|
281
|
+
throw new Error(`spearkit: invalid step in cron ${label} field "${part}"`);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
let lo;
|
|
285
|
+
let hi;
|
|
286
|
+
if (range === "*") {
|
|
287
|
+
lo = min;
|
|
288
|
+
hi = max;
|
|
289
|
+
} else if (range.includes("-")) {
|
|
290
|
+
const dash = range.indexOf("-");
|
|
291
|
+
lo = Number(range.slice(0, dash));
|
|
292
|
+
hi = Number(range.slice(dash + 1));
|
|
293
|
+
} else {
|
|
294
|
+
lo = Number(range);
|
|
295
|
+
hi = lo;
|
|
296
|
+
}
|
|
297
|
+
if (!Number.isInteger(lo) || !Number.isInteger(hi) || lo < min || hi > max || lo > hi) {
|
|
298
|
+
throw new Error(`spearkit: cron ${label} field out of range ${min}-${max}: "${part}"`);
|
|
299
|
+
}
|
|
300
|
+
for (let value = lo; value <= hi; value += step) set.add(value);
|
|
301
|
+
}
|
|
302
|
+
return set;
|
|
303
|
+
}
|
|
304
|
+
var CronExpression = class {
|
|
305
|
+
/** The original expression string. */
|
|
306
|
+
source;
|
|
307
|
+
minutes;
|
|
308
|
+
hours;
|
|
309
|
+
daysOfMonth;
|
|
310
|
+
months;
|
|
311
|
+
daysOfWeek;
|
|
312
|
+
domRestricted;
|
|
313
|
+
dowRestricted;
|
|
314
|
+
constructor(expression) {
|
|
315
|
+
const trimmed = expression.trim();
|
|
316
|
+
const normalized = ALIASES[trimmed] ?? trimmed;
|
|
317
|
+
const parts = normalized.split(/\s+/);
|
|
318
|
+
if (parts.length !== 5) {
|
|
319
|
+
throw new Error(`spearkit: cron expression must have 5 fields, got "${expression}"`);
|
|
320
|
+
}
|
|
321
|
+
const [minute, hour, dom, month, dow] = parts;
|
|
322
|
+
if (minute === void 0 || hour === void 0 || dom === void 0 || month === void 0 || dow === void 0) {
|
|
323
|
+
throw new Error(`spearkit: invalid cron expression "${expression}"`);
|
|
324
|
+
}
|
|
325
|
+
this.source = expression;
|
|
326
|
+
this.minutes = parseField(minute, 0, 59, "minute");
|
|
327
|
+
this.hours = parseField(hour, 0, 23, "hour");
|
|
328
|
+
this.daysOfMonth = parseField(dom, 1, 31, "day-of-month");
|
|
329
|
+
this.months = parseField(month, 1, 12, "month");
|
|
330
|
+
const weekdays = parseField(dow, 0, 7, "day-of-week");
|
|
331
|
+
if (weekdays.has(7)) {
|
|
332
|
+
weekdays.delete(7);
|
|
333
|
+
weekdays.add(0);
|
|
334
|
+
}
|
|
335
|
+
this.daysOfWeek = weekdays;
|
|
336
|
+
this.domRestricted = dom !== "*";
|
|
337
|
+
this.dowRestricted = dow !== "*";
|
|
338
|
+
}
|
|
339
|
+
dayMatches(date) {
|
|
340
|
+
const dom = this.daysOfMonth.has(date.getDate());
|
|
341
|
+
const dow = this.daysOfWeek.has(date.getDay());
|
|
342
|
+
if (this.domRestricted && this.dowRestricted) return dom || dow;
|
|
343
|
+
if (this.domRestricted) return dom;
|
|
344
|
+
if (this.dowRestricted) return dow;
|
|
345
|
+
return true;
|
|
346
|
+
}
|
|
347
|
+
/** The next time strictly after `from` (default now) that matches. */
|
|
348
|
+
next(from = /* @__PURE__ */ new Date()) {
|
|
349
|
+
const date = new Date(from.getTime());
|
|
350
|
+
date.setSeconds(0, 0);
|
|
351
|
+
date.setMinutes(date.getMinutes() + 1);
|
|
352
|
+
for (let guard = 0; guard < 1e5; guard++) {
|
|
353
|
+
if (!this.months.has(date.getMonth() + 1)) {
|
|
354
|
+
date.setMonth(date.getMonth() + 1, 1);
|
|
355
|
+
date.setHours(0, 0, 0, 0);
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
if (!this.dayMatches(date)) {
|
|
359
|
+
date.setDate(date.getDate() + 1);
|
|
360
|
+
date.setHours(0, 0, 0, 0);
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
if (!this.hours.has(date.getHours())) {
|
|
364
|
+
date.setHours(date.getHours() + 1, 0, 0, 0);
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
if (!this.minutes.has(date.getMinutes())) {
|
|
368
|
+
date.setMinutes(date.getMinutes() + 1, 0, 0);
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
return new Date(date.getTime());
|
|
372
|
+
}
|
|
373
|
+
throw new Error(`spearkit: cron expression "${this.source}" has no upcoming match`);
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
function cron(expression) {
|
|
377
|
+
return new CronExpression(expression);
|
|
378
|
+
}
|
|
379
|
+
function task(config) {
|
|
380
|
+
if (config.cron === void 0 && config.interval === void 0) {
|
|
381
|
+
throw new Error(`spearkit: task "${config.name}" needs a cron expression or an interval`);
|
|
382
|
+
}
|
|
383
|
+
if (config.interval !== void 0 && config.interval <= 0) {
|
|
384
|
+
throw new Error(`spearkit: task "${config.name}" interval must be positive`);
|
|
385
|
+
}
|
|
386
|
+
return {
|
|
387
|
+
kind: "task",
|
|
388
|
+
name: config.name,
|
|
389
|
+
interval: config.interval,
|
|
390
|
+
cron: config.cron !== void 0 ? new CronExpression(config.cron) : void 0,
|
|
391
|
+
runOnStart: config.runOnStart ?? false,
|
|
392
|
+
run: config.run
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
var MAX_TIMEOUT = 2147483647;
|
|
396
|
+
var TaskScheduler = class {
|
|
397
|
+
tasks = /* @__PURE__ */ new Map();
|
|
398
|
+
timers = /* @__PURE__ */ new Map();
|
|
399
|
+
running = false;
|
|
400
|
+
client;
|
|
401
|
+
logger;
|
|
402
|
+
/** Number of registered tasks. */
|
|
403
|
+
get size() {
|
|
404
|
+
return this.tasks.size;
|
|
405
|
+
}
|
|
406
|
+
/** Whether the scheduler is currently running. */
|
|
407
|
+
get active() {
|
|
408
|
+
return this.running;
|
|
409
|
+
}
|
|
410
|
+
/** Every registered task. */
|
|
411
|
+
list() {
|
|
412
|
+
return [...this.tasks.values()];
|
|
413
|
+
}
|
|
414
|
+
/** Attach a logger for task error reporting. */
|
|
415
|
+
setLogger(logger) {
|
|
416
|
+
this.logger = logger;
|
|
417
|
+
return this;
|
|
418
|
+
}
|
|
419
|
+
/** Register one or more tasks. If already running, they are scheduled now. */
|
|
420
|
+
add(...tasks) {
|
|
421
|
+
for (const task2 of tasks) {
|
|
422
|
+
this.tasks.set(task2.name, task2);
|
|
423
|
+
if (this.running) this.begin(task2);
|
|
424
|
+
}
|
|
425
|
+
return this;
|
|
426
|
+
}
|
|
427
|
+
/** Remove a task and cancel its timer. */
|
|
428
|
+
remove(name) {
|
|
429
|
+
this.cancel(name);
|
|
430
|
+
return this.tasks.delete(name);
|
|
431
|
+
}
|
|
432
|
+
/** Start every task. Safe to call once; later calls are ignored. */
|
|
433
|
+
start(client) {
|
|
434
|
+
if (this.running) return;
|
|
435
|
+
this.client = client;
|
|
436
|
+
this.running = true;
|
|
437
|
+
for (const task2 of this.tasks.values()) this.begin(task2);
|
|
438
|
+
}
|
|
439
|
+
/** Stop the scheduler and cancel every pending timer. */
|
|
440
|
+
stop() {
|
|
441
|
+
this.running = false;
|
|
442
|
+
for (const name of [...this.timers.keys()]) this.cancel(name);
|
|
443
|
+
}
|
|
444
|
+
cancel(name) {
|
|
445
|
+
const timer = this.timers.get(name);
|
|
446
|
+
if (timer !== void 0) {
|
|
447
|
+
clearTimeout(timer);
|
|
448
|
+
this.timers.delete(name);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
begin(task2) {
|
|
452
|
+
if (task2.runOnStart) void this.runTask(task2);
|
|
453
|
+
this.scheduleNext(task2);
|
|
454
|
+
}
|
|
455
|
+
delayFor(task2) {
|
|
456
|
+
if (task2.interval !== void 0) return task2.interval;
|
|
457
|
+
if (task2.cron !== void 0) return Math.max(0, task2.cron.next().getTime() - Date.now());
|
|
458
|
+
return MAX_TIMEOUT;
|
|
459
|
+
}
|
|
460
|
+
scheduleNext(task2) {
|
|
461
|
+
if (!this.running) return;
|
|
462
|
+
this.arm(task2.name, this.delayFor(task2), () => {
|
|
463
|
+
void this.runTask(task2);
|
|
464
|
+
this.scheduleNext(task2);
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
arm(name, delay, fire) {
|
|
468
|
+
if (delay > MAX_TIMEOUT) {
|
|
469
|
+
const timer2 = setTimeout(() => this.arm(name, delay - MAX_TIMEOUT, fire), MAX_TIMEOUT);
|
|
470
|
+
if (typeof timer2.unref === "function") timer2.unref();
|
|
471
|
+
this.timers.set(name, timer2);
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
const timer = setTimeout(fire, Math.max(0, delay));
|
|
475
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
476
|
+
this.timers.set(name, timer);
|
|
477
|
+
}
|
|
478
|
+
async runTask(task2) {
|
|
479
|
+
if (this.client === void 0) return;
|
|
480
|
+
this.logger?.debug("task", { data: { task: task2.name } });
|
|
481
|
+
try {
|
|
482
|
+
await task2.run(this.client);
|
|
483
|
+
} catch (error) {
|
|
484
|
+
this.logger?.error(`task "${task2.name}" failed`, { error: toError(error) });
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
};
|
|
488
|
+
|
|
489
|
+
// src/prefix.ts
|
|
490
|
+
function prefixCommand(config) {
|
|
491
|
+
return {
|
|
492
|
+
kind: "prefixCommand",
|
|
493
|
+
name: config.name,
|
|
494
|
+
aliases: config.aliases ?? [],
|
|
495
|
+
description: config.description,
|
|
496
|
+
cooldown: config.cooldown !== void 0 ? normalizeCooldown(config.cooldown) : void 0,
|
|
497
|
+
run: async (ctx) => {
|
|
498
|
+
await config.run(ctx);
|
|
499
|
+
}
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
var PrefixContext = class {
|
|
503
|
+
constructor(message, commandName, args, rest) {
|
|
504
|
+
this.message = message;
|
|
505
|
+
this.commandName = commandName;
|
|
506
|
+
this.args = args;
|
|
507
|
+
this.rest = rest;
|
|
508
|
+
}
|
|
509
|
+
message;
|
|
510
|
+
commandName;
|
|
511
|
+
args;
|
|
512
|
+
rest;
|
|
513
|
+
get client() {
|
|
514
|
+
return this.message.client;
|
|
515
|
+
}
|
|
516
|
+
get author() {
|
|
517
|
+
return this.message.author;
|
|
518
|
+
}
|
|
519
|
+
get member() {
|
|
520
|
+
return this.message.member;
|
|
521
|
+
}
|
|
522
|
+
get guild() {
|
|
523
|
+
return this.message.guild;
|
|
524
|
+
}
|
|
525
|
+
get guildId() {
|
|
526
|
+
return this.message.guildId;
|
|
527
|
+
}
|
|
528
|
+
get channel() {
|
|
529
|
+
return this.message.channel;
|
|
530
|
+
}
|
|
531
|
+
get channelId() {
|
|
532
|
+
return this.message.channelId;
|
|
533
|
+
}
|
|
534
|
+
/** Reply to the triggering message. */
|
|
535
|
+
reply(content) {
|
|
536
|
+
return this.message.reply(content);
|
|
537
|
+
}
|
|
538
|
+
/** Send a message to the same channel (no reply reference). */
|
|
539
|
+
async send(content) {
|
|
540
|
+
const channel = this.message.channel;
|
|
541
|
+
if ("send" in channel) return channel.send(content);
|
|
542
|
+
return void 0;
|
|
543
|
+
}
|
|
544
|
+
};
|
|
545
|
+
function resolveOptions(input) {
|
|
546
|
+
if (typeof input === "string") return { prefixes: [input], mention: true, ignoreBots: true, caseInsensitive: true };
|
|
547
|
+
if (Array.isArray(input)) {
|
|
548
|
+
return { prefixes: [...input], mention: true, ignoreBots: true, caseInsensitive: true };
|
|
549
|
+
}
|
|
550
|
+
const options = input;
|
|
551
|
+
const prefix = options.prefix ?? [];
|
|
552
|
+
return {
|
|
553
|
+
prefixes: typeof prefix === "string" ? [prefix] : [...prefix],
|
|
554
|
+
mention: options.mention ?? true,
|
|
555
|
+
ignoreBots: options.ignoreBots ?? true,
|
|
556
|
+
caseInsensitive: options.caseInsensitive ?? true
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
function actorFromMessage(message) {
|
|
560
|
+
const member = message.member;
|
|
561
|
+
const roleIds = member !== null ? [...member.roles.cache.keys()] : [];
|
|
562
|
+
return {
|
|
563
|
+
userId: message.author.id,
|
|
564
|
+
roleIds,
|
|
565
|
+
guildId: message.guildId,
|
|
566
|
+
channelId: message.channelId
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
var PrefixRegistry = class {
|
|
570
|
+
commands = /* @__PURE__ */ new Map();
|
|
571
|
+
lookup = /* @__PURE__ */ new Map();
|
|
572
|
+
options = {
|
|
573
|
+
prefixes: [],
|
|
574
|
+
mention: true,
|
|
575
|
+
ignoreBots: true,
|
|
576
|
+
caseInsensitive: true
|
|
577
|
+
};
|
|
578
|
+
logger;
|
|
579
|
+
cooldowns;
|
|
580
|
+
defaultCooldown;
|
|
581
|
+
errorHandler;
|
|
582
|
+
onUsage;
|
|
583
|
+
/** Configure prefixes and matching behaviour. */
|
|
584
|
+
setOptions(input) {
|
|
585
|
+
this.options = resolveOptions(input);
|
|
586
|
+
return this;
|
|
587
|
+
}
|
|
588
|
+
/** Attach a logger for dispatch tracing and error reporting. */
|
|
589
|
+
setLogger(logger) {
|
|
590
|
+
this.logger = logger;
|
|
591
|
+
return this;
|
|
592
|
+
}
|
|
593
|
+
/** Attach a hook called after each successful prefix command run. */
|
|
594
|
+
setUsageHook(hook) {
|
|
595
|
+
this.onUsage = hook;
|
|
596
|
+
return this;
|
|
597
|
+
}
|
|
598
|
+
/** Share a cooldown manager and an optional default cooldown. */
|
|
599
|
+
setCooldowns(manager, defaultCooldown) {
|
|
600
|
+
this.cooldowns = manager;
|
|
601
|
+
this.defaultCooldown = defaultCooldown;
|
|
602
|
+
return this;
|
|
603
|
+
}
|
|
604
|
+
/** Set the handler used when a prefix command throws. */
|
|
605
|
+
onError(handler) {
|
|
606
|
+
this.errorHandler = handler;
|
|
607
|
+
return this;
|
|
608
|
+
}
|
|
609
|
+
/** Register one or more prefix commands (and their aliases). */
|
|
610
|
+
add(...commands) {
|
|
611
|
+
for (const command2 of commands) {
|
|
612
|
+
this.commands.set(command2.name, command2);
|
|
613
|
+
this.index(command2.name, command2);
|
|
614
|
+
for (const alias of command2.aliases) this.index(alias, command2);
|
|
615
|
+
}
|
|
616
|
+
return this;
|
|
617
|
+
}
|
|
618
|
+
index(key, command2) {
|
|
619
|
+
this.lookup.set(this.options.caseInsensitive ? key.toLowerCase() : key, command2);
|
|
620
|
+
}
|
|
621
|
+
/** Look up a command by name or alias. */
|
|
622
|
+
get(nameOrAlias) {
|
|
623
|
+
return this.lookup.get(this.options.caseInsensitive ? nameOrAlias.toLowerCase() : nameOrAlias);
|
|
624
|
+
}
|
|
625
|
+
/** Number of registered commands (excluding aliases). */
|
|
626
|
+
get size() {
|
|
627
|
+
return this.commands.size;
|
|
628
|
+
}
|
|
629
|
+
/** Every registered command. */
|
|
630
|
+
list() {
|
|
631
|
+
return [...this.commands.values()];
|
|
632
|
+
}
|
|
633
|
+
/** Strip a matching prefix (or bot mention) from `content`, or return `null`. */
|
|
634
|
+
stripPrefix(content, botId) {
|
|
635
|
+
for (const prefix of this.options.prefixes) {
|
|
636
|
+
if (prefix.length > 0 && content.startsWith(prefix)) return content.slice(prefix.length);
|
|
637
|
+
}
|
|
638
|
+
if (this.options.mention && botId !== void 0) {
|
|
639
|
+
const match = /^<@!?(\d+)>\s*/.exec(content);
|
|
640
|
+
if (match !== null && match[1] === botId) return content.slice(match[0].length);
|
|
641
|
+
}
|
|
642
|
+
return null;
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* Parse and dispatch a message. Returns `true` when a command ran (or was
|
|
646
|
+
* blocked by a cooldown), `false` when the message was not a prefix command.
|
|
647
|
+
*/
|
|
648
|
+
async handle(message) {
|
|
649
|
+
if (this.options.prefixes.length === 0 && !this.options.mention) return false;
|
|
650
|
+
if (this.options.ignoreBots && message.author.bot) return false;
|
|
651
|
+
const stripped = this.stripPrefix(message.content, message.client.user?.id);
|
|
652
|
+
if (stripped === null) return false;
|
|
653
|
+
const trimmed = stripped.trimStart();
|
|
654
|
+
const match = /^(\S+)\s*([\s\S]*)$/.exec(trimmed);
|
|
655
|
+
if (match === null) return false;
|
|
656
|
+
const name = match[1] ?? "";
|
|
657
|
+
const rest = match[2] ?? "";
|
|
658
|
+
const command2 = this.get(name);
|
|
659
|
+
if (command2 === void 0) return false;
|
|
660
|
+
this.logger?.debug("prefix", { data: { command: command2.name, user: message.author.id } });
|
|
661
|
+
const cooldown = command2.cooldown ?? this.defaultCooldown;
|
|
662
|
+
if (cooldown !== void 0 && this.cooldowns !== void 0) {
|
|
663
|
+
const result = this.cooldowns.consume(`prefix:${command2.name}`, cooldown, actorFromMessage(message));
|
|
664
|
+
if (!result.allowed) {
|
|
665
|
+
await message.reply(formatCooldownMessage(cooldown, result.remaining)).catch(() => void 0);
|
|
666
|
+
return true;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
const args = rest.length > 0 ? rest.split(/\s+/) : [];
|
|
670
|
+
try {
|
|
671
|
+
await command2.run(new PrefixContext(message, name, args, rest));
|
|
672
|
+
this.onUsage?.({
|
|
673
|
+
type: "prefix",
|
|
674
|
+
name: command2.name,
|
|
675
|
+
userId: message.author.id,
|
|
676
|
+
userTag: message.author.tag,
|
|
677
|
+
guildId: message.guildId,
|
|
678
|
+
channelId: message.channelId,
|
|
679
|
+
timestamp: /* @__PURE__ */ new Date()
|
|
680
|
+
});
|
|
681
|
+
} catch (error) {
|
|
682
|
+
const err = toError(error);
|
|
683
|
+
this.logger?.error(`prefix command "${command2.name}" failed`, { error: err });
|
|
684
|
+
if (this.errorHandler !== void 0) await this.errorHandler(err, message);
|
|
685
|
+
}
|
|
686
|
+
return true;
|
|
687
|
+
}
|
|
688
|
+
};
|
|
689
|
+
var MemoryUsageStore = class {
|
|
690
|
+
constructor(limit = Number.POSITIVE_INFINITY) {
|
|
691
|
+
this.limit = limit;
|
|
692
|
+
}
|
|
693
|
+
limit;
|
|
694
|
+
events = [];
|
|
695
|
+
record(event2) {
|
|
696
|
+
this.events.push(event2);
|
|
697
|
+
if (this.events.length > this.limit) this.events.splice(0, this.events.length - this.limit);
|
|
698
|
+
}
|
|
699
|
+
all() {
|
|
700
|
+
return this.events;
|
|
701
|
+
}
|
|
702
|
+
/** Total recorded events. */
|
|
703
|
+
get size() {
|
|
704
|
+
return this.events.length;
|
|
705
|
+
}
|
|
706
|
+
/** Events recorded for a given user id. */
|
|
707
|
+
byUser(userId) {
|
|
708
|
+
return this.events.filter((event2) => event2.userId === userId);
|
|
709
|
+
}
|
|
710
|
+
/** Forget everything. */
|
|
711
|
+
clear() {
|
|
712
|
+
this.events.length = 0;
|
|
713
|
+
}
|
|
714
|
+
};
|
|
715
|
+
var JsonFileUsageStore = class {
|
|
716
|
+
constructor(path) {
|
|
717
|
+
this.path = path;
|
|
718
|
+
}
|
|
719
|
+
path;
|
|
720
|
+
async record(event2) {
|
|
721
|
+
const line = `${JSON.stringify({ ...event2, timestamp: event2.timestamp.toISOString() })}
|
|
722
|
+
`;
|
|
723
|
+
await promises.mkdir(path.dirname(this.path), { recursive: true });
|
|
724
|
+
await promises.appendFile(this.path, line, "utf8");
|
|
725
|
+
}
|
|
726
|
+
async all() {
|
|
727
|
+
let content;
|
|
728
|
+
try {
|
|
729
|
+
content = await promises.readFile(this.path, "utf8");
|
|
730
|
+
} catch {
|
|
731
|
+
return [];
|
|
732
|
+
}
|
|
733
|
+
const events = [];
|
|
734
|
+
for (const line of content.split("\n")) {
|
|
735
|
+
if (line.trim().length === 0) continue;
|
|
736
|
+
const parsed = JSON.parse(line);
|
|
737
|
+
events.push({ ...parsed, timestamp: new Date(parsed.timestamp) });
|
|
738
|
+
}
|
|
739
|
+
return events;
|
|
740
|
+
}
|
|
741
|
+
};
|
|
742
|
+
function formatUsage(event2) {
|
|
743
|
+
const who = event2.userTag ?? (event2.userId !== void 0 ? `<@${event2.userId}>` : "unknown");
|
|
744
|
+
const where = event2.channelId !== void 0 && event2.channelId !== null ? ` in <#${event2.channelId}>` : "";
|
|
745
|
+
const detail = event2.detail !== void 0 ? ` \u2014 ${event2.detail}` : "";
|
|
746
|
+
return `\`${event2.type}\` **${event2.name}** by ${who}${where}${detail}`;
|
|
747
|
+
}
|
|
748
|
+
var UsageTracker = class {
|
|
749
|
+
/** The configured store, if any. Directly queryable. */
|
|
750
|
+
store;
|
|
751
|
+
reporter;
|
|
752
|
+
client;
|
|
753
|
+
logger;
|
|
754
|
+
/** Whether anything will happen on {@link track}. */
|
|
755
|
+
get enabled() {
|
|
756
|
+
return this.store !== void 0 || this.reporter !== void 0;
|
|
757
|
+
}
|
|
758
|
+
/** @internal Used by the client to resolve report channels. */
|
|
759
|
+
setClient(client) {
|
|
760
|
+
this.client = client;
|
|
761
|
+
return this;
|
|
762
|
+
}
|
|
763
|
+
setLogger(logger) {
|
|
764
|
+
this.logger = logger;
|
|
765
|
+
return this;
|
|
766
|
+
}
|
|
767
|
+
/** Persist events to a store (a database). */
|
|
768
|
+
setStore(store) {
|
|
769
|
+
this.store = store;
|
|
770
|
+
return this;
|
|
771
|
+
}
|
|
772
|
+
/** Mirror events into a Discord channel. */
|
|
773
|
+
reportTo(channelId, format = formatUsage) {
|
|
774
|
+
this.reporter = { channelId, format };
|
|
775
|
+
return this;
|
|
776
|
+
}
|
|
777
|
+
/** Record a use. Returns immediately; storing/reporting happen in the background. */
|
|
778
|
+
track(event2) {
|
|
779
|
+
if (!this.enabled) return;
|
|
780
|
+
void this.run(event2);
|
|
781
|
+
}
|
|
782
|
+
async run(event2) {
|
|
783
|
+
if (this.store !== void 0) {
|
|
784
|
+
try {
|
|
785
|
+
await this.store.record(event2);
|
|
786
|
+
} catch (error) {
|
|
787
|
+
this.logger?.error("usage store failed", { error: toError(error) });
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
if (this.reporter !== void 0 && this.client !== void 0) {
|
|
791
|
+
try {
|
|
792
|
+
const cache = this.client.channels.cache.get(this.reporter.channelId);
|
|
793
|
+
const channel = cache ?? await this.client.channels.fetch(this.reporter.channelId);
|
|
794
|
+
if (channel !== null && "send" in channel) {
|
|
795
|
+
await channel.send(this.reporter.format(event2));
|
|
796
|
+
}
|
|
797
|
+
} catch (error) {
|
|
798
|
+
this.logger?.error("usage report failed", { error: toError(error) });
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
};
|
|
9
803
|
function withEphemeralFlag(flags) {
|
|
10
804
|
if (flags == null) return discord_js.MessageFlags.Ephemeral;
|
|
11
805
|
if (typeof flags === "number" || typeof flags === "bigint") {
|
|
@@ -286,6 +1080,8 @@ var SlashCommand = class {
|
|
|
286
1080
|
json;
|
|
287
1081
|
executor;
|
|
288
1082
|
autocompleter;
|
|
1083
|
+
/** Resolved cooldown configuration for this command, if any. */
|
|
1084
|
+
cooldown;
|
|
289
1085
|
/** @internal */
|
|
290
1086
|
constructor(spec) {
|
|
291
1087
|
this.name = spec.name;
|
|
@@ -293,6 +1089,7 @@ var SlashCommand = class {
|
|
|
293
1089
|
this.json = spec.json;
|
|
294
1090
|
this.executor = spec.executor;
|
|
295
1091
|
this.autocompleter = spec.autocompleter;
|
|
1092
|
+
this.cooldown = spec.cooldown;
|
|
296
1093
|
}
|
|
297
1094
|
/** Serialise to the discord REST chat-input command payload. */
|
|
298
1095
|
toJSON() {
|
|
@@ -307,7 +1104,7 @@ var SlashCommand = class {
|
|
|
307
1104
|
return this.autocompleter(interaction);
|
|
308
1105
|
}
|
|
309
1106
|
};
|
|
310
|
-
function
|
|
1107
|
+
function resolveOptions2(interaction, options) {
|
|
311
1108
|
const resolved = {};
|
|
312
1109
|
for (const [name, def] of Object.entries(options)) {
|
|
313
1110
|
resolved[name] = readOption(interaction.options, name, def);
|
|
@@ -362,7 +1159,7 @@ function command(config) {
|
|
|
362
1159
|
const options = config.options ?? {};
|
|
363
1160
|
const { run } = config;
|
|
364
1161
|
const executor = async (interaction) => {
|
|
365
|
-
const resolved =
|
|
1162
|
+
const resolved = resolveOptions2(interaction, options);
|
|
366
1163
|
await run(new CommandContext(interaction, resolved));
|
|
367
1164
|
};
|
|
368
1165
|
return new SlashCommand({
|
|
@@ -370,14 +1167,15 @@ function command(config) {
|
|
|
370
1167
|
json: baseJSON(config, leafOptionsJSON(options)),
|
|
371
1168
|
hasAutocomplete: optionsHaveAutocomplete(options),
|
|
372
1169
|
executor,
|
|
373
|
-
autocompleter: makeAutocompleter(options)
|
|
1170
|
+
autocompleter: makeAutocompleter(options),
|
|
1171
|
+
cooldown: config.cooldown !== void 0 ? normalizeCooldown(config.cooldown) : void 0
|
|
374
1172
|
});
|
|
375
1173
|
}
|
|
376
1174
|
function subcommand(config) {
|
|
377
1175
|
const options = config.options ?? {};
|
|
378
1176
|
const { run } = config;
|
|
379
1177
|
const execute = async (interaction) => {
|
|
380
|
-
const resolved =
|
|
1178
|
+
const resolved = resolveOptions2(interaction, options);
|
|
381
1179
|
await run(new CommandContext(interaction, resolved));
|
|
382
1180
|
};
|
|
383
1181
|
return {
|
|
@@ -441,12 +1239,17 @@ function commandGroup(config) {
|
|
|
441
1239
|
json: baseJSON(config, options),
|
|
442
1240
|
hasAutocomplete,
|
|
443
1241
|
executor,
|
|
444
|
-
autocompleter
|
|
1242
|
+
autocompleter,
|
|
1243
|
+
cooldown: config.cooldown !== void 0 ? normalizeCooldown(config.cooldown) : void 0
|
|
445
1244
|
});
|
|
446
1245
|
}
|
|
447
1246
|
var CommandRegistry = class {
|
|
448
1247
|
commands = /* @__PURE__ */ new Map();
|
|
449
1248
|
errorHandler;
|
|
1249
|
+
logger;
|
|
1250
|
+
cooldowns;
|
|
1251
|
+
defaultCooldown;
|
|
1252
|
+
onUsage;
|
|
450
1253
|
/** Register one or more commands. Later registrations override by name. */
|
|
451
1254
|
add(...commands) {
|
|
452
1255
|
for (const command2 of commands) this.commands.set(command2.name, command2);
|
|
@@ -477,6 +1280,22 @@ var CommandRegistry = class {
|
|
|
477
1280
|
this.errorHandler = handler;
|
|
478
1281
|
return this;
|
|
479
1282
|
}
|
|
1283
|
+
/** Attach a logger used for dispatch tracing (debug level). */
|
|
1284
|
+
setLogger(logger) {
|
|
1285
|
+
this.logger = logger;
|
|
1286
|
+
return this;
|
|
1287
|
+
}
|
|
1288
|
+
/** Wire a shared cooldown manager and an optional default cooldown for every command. */
|
|
1289
|
+
setCooldowns(manager, defaultCooldown) {
|
|
1290
|
+
this.cooldowns = manager;
|
|
1291
|
+
this.defaultCooldown = defaultCooldown;
|
|
1292
|
+
return this;
|
|
1293
|
+
}
|
|
1294
|
+
/** Attach a hook called after each successful command execution. */
|
|
1295
|
+
setUsageHook(hook) {
|
|
1296
|
+
this.onUsage = hook;
|
|
1297
|
+
return this;
|
|
1298
|
+
}
|
|
480
1299
|
/** Serialise every command to discord REST payloads. */
|
|
481
1300
|
toJSON() {
|
|
482
1301
|
return this.all().map((c) => c.toJSON());
|
|
@@ -485,8 +1304,28 @@ var CommandRegistry = class {
|
|
|
485
1304
|
async handle(interaction) {
|
|
486
1305
|
const command2 = this.commands.get(interaction.commandName);
|
|
487
1306
|
if (command2 === void 0) return;
|
|
1307
|
+
this.logger?.debug("command", {
|
|
1308
|
+
data: { command: interaction.commandName, user: interaction.user.id }
|
|
1309
|
+
});
|
|
1310
|
+
const cooldown = command2.cooldown ?? this.defaultCooldown;
|
|
1311
|
+
if (cooldown !== void 0 && this.cooldowns !== void 0) {
|
|
1312
|
+
const result = this.cooldowns.consume(command2.name, cooldown, actorOf(interaction));
|
|
1313
|
+
if (!result.allowed) {
|
|
1314
|
+
await this.replyCooldown(interaction, cooldown, result.remaining);
|
|
1315
|
+
return;
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
488
1318
|
try {
|
|
489
1319
|
await command2.execute(interaction);
|
|
1320
|
+
this.onUsage?.({
|
|
1321
|
+
type: "command",
|
|
1322
|
+
name: command2.name,
|
|
1323
|
+
userId: interaction.user.id,
|
|
1324
|
+
userTag: interaction.user.tag,
|
|
1325
|
+
guildId: interaction.guildId,
|
|
1326
|
+
channelId: interaction.channelId,
|
|
1327
|
+
timestamp: /* @__PURE__ */ new Date()
|
|
1328
|
+
});
|
|
490
1329
|
} catch (error) {
|
|
491
1330
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
492
1331
|
if (this.errorHandler !== void 0) {
|
|
@@ -538,10 +1377,36 @@ var CommandRegistry = class {
|
|
|
538
1377
|
} catch {
|
|
539
1378
|
}
|
|
540
1379
|
}
|
|
1380
|
+
async replyCooldown(interaction, config, remaining) {
|
|
1381
|
+
this.logger?.debug("cooldown", {
|
|
1382
|
+
data: { command: interaction.commandName, user: interaction.user.id, remaining }
|
|
1383
|
+
});
|
|
1384
|
+
const content = formatCooldownMessage(config, remaining);
|
|
1385
|
+
try {
|
|
1386
|
+
if (interaction.deferred) await interaction.editReply({ content });
|
|
1387
|
+
else if (interaction.replied) await interaction.followUp({ content, flags: discord_js.MessageFlags.Ephemeral });
|
|
1388
|
+
else await interaction.reply({ content, flags: discord_js.MessageFlags.Ephemeral });
|
|
1389
|
+
} catch {
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
541
1392
|
};
|
|
1393
|
+
function actorOf(interaction) {
|
|
1394
|
+
const member = interaction.member;
|
|
1395
|
+
let roleIds = [];
|
|
1396
|
+
if (member !== null) {
|
|
1397
|
+
const roles = member.roles;
|
|
1398
|
+
roleIds = Array.isArray(roles) ? roles : [...roles.cache.keys()];
|
|
1399
|
+
}
|
|
1400
|
+
return {
|
|
1401
|
+
userId: interaction.user.id,
|
|
1402
|
+
roleIds,
|
|
1403
|
+
guildId: interaction.guildId,
|
|
1404
|
+
channelId: interaction.channelId
|
|
1405
|
+
};
|
|
1406
|
+
}
|
|
542
1407
|
|
|
543
1408
|
// src/events.ts
|
|
544
|
-
function
|
|
1409
|
+
function toError2(value) {
|
|
545
1410
|
return value instanceof Error ? value : new Error(String(value));
|
|
546
1411
|
}
|
|
547
1412
|
function build(name, once, run) {
|
|
@@ -554,10 +1419,10 @@ function build(name, once, run) {
|
|
|
554
1419
|
try {
|
|
555
1420
|
const result = run(...args);
|
|
556
1421
|
if (result instanceof Promise) {
|
|
557
|
-
result.catch((error) => client.emit("error",
|
|
1422
|
+
result.catch((error) => client.emit("error", toError2(error)));
|
|
558
1423
|
}
|
|
559
1424
|
} catch (error) {
|
|
560
|
-
client.emit("error",
|
|
1425
|
+
client.emit("error", toError2(error));
|
|
561
1426
|
}
|
|
562
1427
|
};
|
|
563
1428
|
listeners.set(client, listener);
|
|
@@ -763,7 +1628,7 @@ var ModalContext = class extends BaseContext {
|
|
|
763
1628
|
return this.interaction.customId;
|
|
764
1629
|
}
|
|
765
1630
|
};
|
|
766
|
-
function
|
|
1631
|
+
function toError3(value) {
|
|
767
1632
|
return value instanceof Error ? value : new Error(String(value));
|
|
768
1633
|
}
|
|
769
1634
|
var ComponentRegistry = class {
|
|
@@ -775,6 +1640,8 @@ var ComponentRegistry = class {
|
|
|
775
1640
|
mentionableSelects = /* @__PURE__ */ new Map();
|
|
776
1641
|
modals = /* @__PURE__ */ new Map();
|
|
777
1642
|
errorHandler;
|
|
1643
|
+
logger;
|
|
1644
|
+
onUsage;
|
|
778
1645
|
/** Register one or more components. Later registrations override by namespace. */
|
|
779
1646
|
add(...defs) {
|
|
780
1647
|
for (const def of defs) {
|
|
@@ -809,6 +1676,16 @@ var ComponentRegistry = class {
|
|
|
809
1676
|
this.errorHandler = handler;
|
|
810
1677
|
return this;
|
|
811
1678
|
}
|
|
1679
|
+
/** Attach a logger used for dispatch tracing (debug level). */
|
|
1680
|
+
setLogger(logger) {
|
|
1681
|
+
this.logger = logger;
|
|
1682
|
+
return this;
|
|
1683
|
+
}
|
|
1684
|
+
/** Attach a hook called after each successful component handler run. */
|
|
1685
|
+
setUsageHook(hook) {
|
|
1686
|
+
this.onUsage = hook;
|
|
1687
|
+
return this;
|
|
1688
|
+
}
|
|
812
1689
|
/** Total number of registered components. */
|
|
813
1690
|
get size() {
|
|
814
1691
|
return this.buttons.size + this.stringSelects.size + this.userSelects.size + this.roleSelects.size + this.channelSelects.size + this.mentionableSelects.size + this.modals.size;
|
|
@@ -843,12 +1720,22 @@ var ComponentRegistry = class {
|
|
|
843
1720
|
}
|
|
844
1721
|
async exec(route, interaction) {
|
|
845
1722
|
if (route === void 0) return false;
|
|
1723
|
+
this.logger?.debug("component", { data: { customId: interaction.customId } });
|
|
846
1724
|
const { values } = parseCustomId(interaction.customId);
|
|
847
1725
|
const params = paramsFromValues(route.paramNames, values);
|
|
848
1726
|
try {
|
|
849
1727
|
await route.handle(interaction, params);
|
|
1728
|
+
this.onUsage?.({
|
|
1729
|
+
type: "component",
|
|
1730
|
+
name: route.namespace,
|
|
1731
|
+
userId: interaction.user.id,
|
|
1732
|
+
userTag: interaction.user.tag,
|
|
1733
|
+
guildId: interaction.guildId,
|
|
1734
|
+
channelId: interaction.channelId,
|
|
1735
|
+
timestamp: /* @__PURE__ */ new Date()
|
|
1736
|
+
});
|
|
850
1737
|
} catch (error) {
|
|
851
|
-
const err =
|
|
1738
|
+
const err = toError3(error);
|
|
852
1739
|
if (this.errorHandler !== void 0) {
|
|
853
1740
|
await this.errorHandler(err, interaction);
|
|
854
1741
|
} else {
|
|
@@ -1057,6 +1944,9 @@ function isRegisterable(value) {
|
|
|
1057
1944
|
if (typeof record["kind"] === "string" && typeof record["handle"] === "function") {
|
|
1058
1945
|
return true;
|
|
1059
1946
|
}
|
|
1947
|
+
if ((record["kind"] === "task" || record["kind"] === "prefixCommand") && typeof record["run"] === "function") {
|
|
1948
|
+
return true;
|
|
1949
|
+
}
|
|
1060
1950
|
return false;
|
|
1061
1951
|
}
|
|
1062
1952
|
async function collectModules(dir, options = {}) {
|
|
@@ -1111,11 +2001,46 @@ var SpearClient = class extends discord_js.Client {
|
|
|
1111
2001
|
events = new EventRegistry();
|
|
1112
2002
|
/** Button / select / modal registry and router. */
|
|
1113
2003
|
components = new ComponentRegistry();
|
|
2004
|
+
/** Structured logger shared across spearkit and available to your code. */
|
|
2005
|
+
logger;
|
|
2006
|
+
/** Shared cooldown manager used by command dispatch; also usable directly. */
|
|
2007
|
+
cooldowns = new CooldownManager();
|
|
2008
|
+
/** Cron/interval task scheduler; started on ready and stopped on destroy. */
|
|
2009
|
+
scheduler = new TaskScheduler();
|
|
2010
|
+
/** Prefix (text) command registry, dispatched from `messageCreate`. */
|
|
2011
|
+
prefix = new PrefixRegistry();
|
|
2012
|
+
/** Usage tracker: records who used what to a store and/or a Discord channel. */
|
|
2013
|
+
usage = new UsageTracker();
|
|
2014
|
+
envConfig;
|
|
1114
2015
|
constructor(options = {}) {
|
|
1115
|
-
const { intents, ...rest } = options;
|
|
2016
|
+
const { intents, logger, dotenv, cooldown, prefix, usage, ...rest } = options;
|
|
1116
2017
|
super({ ...rest, intents: intents ?? Intents.default });
|
|
2018
|
+
this.envConfig = dotenv === false ? false : dotenv === void 0 || dotenv === true ? {} : dotenv;
|
|
2019
|
+
this.logger = logger instanceof Logger ? logger : new Logger(logger);
|
|
2020
|
+
this.commands.setLogger(this.logger.child("commands"));
|
|
2021
|
+
const defaultCooldown = cooldown !== void 0 ? normalizeCooldown(cooldown) : void 0;
|
|
2022
|
+
this.commands.setCooldowns(this.cooldowns, defaultCooldown);
|
|
2023
|
+
this.components.setLogger(this.logger.child("components"));
|
|
1117
2024
|
this.events.attachAll(this);
|
|
1118
2025
|
this.on("interactionCreate", (interaction) => this.route(interaction));
|
|
2026
|
+
this.on("error", (error) => this.logger.error("client error", { error: toError(error) }));
|
|
2027
|
+
this.scheduler.setLogger(this.logger.child("scheduler"));
|
|
2028
|
+
this.once("clientReady", () => this.scheduler.start(this));
|
|
2029
|
+
this.prefix.setLogger(this.logger.child("prefix"));
|
|
2030
|
+
this.prefix.setCooldowns(this.cooldowns, defaultCooldown);
|
|
2031
|
+
if (prefix !== void 0) this.prefix.setOptions(prefix);
|
|
2032
|
+
this.on("messageCreate", (message) => {
|
|
2033
|
+
void this.prefix.handle(message);
|
|
2034
|
+
});
|
|
2035
|
+
this.usage.setClient(this).setLogger(this.logger.child("usage"));
|
|
2036
|
+
if (usage !== void 0) {
|
|
2037
|
+
if (usage.store !== void 0) this.usage.setStore(usage.store);
|
|
2038
|
+
if (usage.channel !== void 0) this.usage.reportTo(usage.channel, usage.format);
|
|
2039
|
+
const onUsage = (event2) => this.usage.track(event2);
|
|
2040
|
+
this.commands.setUsageHook(onUsage);
|
|
2041
|
+
this.components.setUsageHook(onUsage);
|
|
2042
|
+
this.prefix.setUsageHook(onUsage);
|
|
2043
|
+
}
|
|
1119
2044
|
}
|
|
1120
2045
|
/**
|
|
1121
2046
|
* Register commands, events and components in one call. Each item is routed
|
|
@@ -1127,6 +2052,10 @@ var SpearClient = class extends discord_js.Client {
|
|
|
1127
2052
|
this.commands.add(item);
|
|
1128
2053
|
} else if ("attach" in item) {
|
|
1129
2054
|
this.events.add(item);
|
|
2055
|
+
} else if (item.kind === "task") {
|
|
2056
|
+
this.scheduler.add(item);
|
|
2057
|
+
} else if (item.kind === "prefixCommand") {
|
|
2058
|
+
this.prefix.add(item);
|
|
1130
2059
|
} else {
|
|
1131
2060
|
this.components.add(item);
|
|
1132
2061
|
}
|
|
@@ -1152,6 +2081,7 @@ var SpearClient = class extends discord_js.Client {
|
|
|
1152
2081
|
* token is passed.
|
|
1153
2082
|
*/
|
|
1154
2083
|
async start(token) {
|
|
2084
|
+
if (this.envConfig !== false) loadEnv(this.envConfig);
|
|
1155
2085
|
const resolved = token ?? process.env.DISCORD_TOKEN;
|
|
1156
2086
|
if (resolved === void 0 || resolved.length === 0) {
|
|
1157
2087
|
throw new Error("spearkit: start() needs a token (pass one or set DISCORD_TOKEN)");
|
|
@@ -1179,6 +2109,17 @@ var SpearClient = class extends discord_js.Client {
|
|
|
1179
2109
|
await this.components.handle(interaction);
|
|
1180
2110
|
}
|
|
1181
2111
|
}
|
|
2112
|
+
/** Define and register a scheduled task in one call. */
|
|
2113
|
+
schedule(config) {
|
|
2114
|
+
const scheduled = task(config);
|
|
2115
|
+
this.scheduler.add(scheduled);
|
|
2116
|
+
return scheduled;
|
|
2117
|
+
}
|
|
2118
|
+
/** Stop the scheduler, then tear down the discord.js client. */
|
|
2119
|
+
async destroy() {
|
|
2120
|
+
this.scheduler.stop();
|
|
2121
|
+
await super.destroy();
|
|
2122
|
+
}
|
|
1182
2123
|
};
|
|
1183
2124
|
|
|
1184
2125
|
// src/plugin.ts
|
|
@@ -1193,16 +2134,25 @@ exports.ChannelSelectContext = ChannelSelectContext;
|
|
|
1193
2134
|
exports.CommandContext = CommandContext;
|
|
1194
2135
|
exports.CommandRegistry = CommandRegistry;
|
|
1195
2136
|
exports.ComponentRegistry = ComponentRegistry;
|
|
2137
|
+
exports.CooldownManager = CooldownManager;
|
|
2138
|
+
exports.CronExpression = CronExpression;
|
|
1196
2139
|
exports.EventRegistry = EventRegistry;
|
|
1197
2140
|
exports.Intents = Intents;
|
|
2141
|
+
exports.JsonFileUsageStore = JsonFileUsageStore;
|
|
2142
|
+
exports.Logger = Logger;
|
|
1198
2143
|
exports.MAX_CUSTOM_ID_LENGTH = MAX_CUSTOM_ID_LENGTH;
|
|
2144
|
+
exports.MemoryUsageStore = MemoryUsageStore;
|
|
1199
2145
|
exports.MentionableSelectContext = MentionableSelectContext;
|
|
1200
2146
|
exports.MessageComponentContext = MessageComponentContext;
|
|
1201
2147
|
exports.ModalContext = ModalContext;
|
|
2148
|
+
exports.PrefixContext = PrefixContext;
|
|
2149
|
+
exports.PrefixRegistry = PrefixRegistry;
|
|
1202
2150
|
exports.RoleSelectContext = RoleSelectContext;
|
|
1203
2151
|
exports.SlashCommand = SlashCommand;
|
|
1204
2152
|
exports.SpearClient = SpearClient;
|
|
1205
2153
|
exports.StringSelectContext = StringSelectContext;
|
|
2154
|
+
exports.TaskScheduler = TaskScheduler;
|
|
2155
|
+
exports.UsageTracker = UsageTracker;
|
|
1206
2156
|
exports.UserSelectContext = UserSelectContext;
|
|
1207
2157
|
exports.asEphemeral = asEphemeral;
|
|
1208
2158
|
exports.buildCustomId = buildCustomId;
|
|
@@ -1212,25 +2162,37 @@ exports.collectModules = collectModules;
|
|
|
1212
2162
|
exports.command = command;
|
|
1213
2163
|
exports.commandGroup = commandGroup;
|
|
1214
2164
|
exports.compilePattern = compilePattern;
|
|
2165
|
+
exports.consoleSink = consoleSink;
|
|
2166
|
+
exports.cron = cron;
|
|
1215
2167
|
exports.definePlugin = definePlugin;
|
|
2168
|
+
exports.effectiveDuration = effectiveDuration;
|
|
2169
|
+
exports.env = env;
|
|
1216
2170
|
exports.event = event;
|
|
2171
|
+
exports.formatCooldownMessage = formatCooldownMessage;
|
|
2172
|
+
exports.formatUsage = formatUsage;
|
|
1217
2173
|
exports.linkButton = linkButton;
|
|
2174
|
+
exports.loadEnv = loadEnv;
|
|
1218
2175
|
exports.loadInto = loadInto;
|
|
1219
2176
|
exports.mentionableSelect = mentionableSelect;
|
|
1220
2177
|
exports.modal = modal;
|
|
2178
|
+
exports.normalizeCooldown = normalizeCooldown;
|
|
1221
2179
|
exports.normalizeReply = normalizeReply;
|
|
1222
2180
|
exports.option = option;
|
|
1223
2181
|
exports.optionsHaveAutocomplete = optionsHaveAutocomplete;
|
|
1224
2182
|
exports.paramsFromValues = paramsFromValues;
|
|
1225
2183
|
exports.parseCustomId = parseCustomId;
|
|
2184
|
+
exports.parseEnv = parseEnv;
|
|
2185
|
+
exports.prefixCommand = prefixCommand;
|
|
1226
2186
|
exports.readOption = readOption;
|
|
1227
2187
|
exports.roleSelect = roleSelect;
|
|
1228
2188
|
exports.row = row;
|
|
1229
2189
|
exports.stringSelect = stringSelect;
|
|
1230
2190
|
exports.subcommand = subcommand;
|
|
1231
2191
|
exports.subcommandGroup = subcommandGroup;
|
|
2192
|
+
exports.task = task;
|
|
1232
2193
|
exports.textInput = textInput;
|
|
1233
2194
|
exports.toAPIOption = toAPIOption;
|
|
2195
|
+
exports.toError = toError;
|
|
1234
2196
|
exports.userSelect = userSelect;
|
|
1235
2197
|
Object.keys(discord_js).forEach(function (k) {
|
|
1236
2198
|
if (k !== 'default' && !Object.prototype.hasOwnProperty.call(exports, k)) Object.defineProperty(exports, k, {
|