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