spiralcord-full 2.1.0 → 2.3.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/bin/spiral.js +621 -347
- package/package.json +4 -2
- package/src/plugins/manager.js +177 -34
- package/src/runtime.js +157 -22
- package/src/test.js +85 -63
- package/src/types/index.d.ts +126 -0
- package/src/web/server.js +69 -41
package/package.json
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spiralcord-full",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.0",
|
|
4
4
|
"description": "Spiralcord Full - Discord Bot Runtime with Voice, Music, AI, Database & Image Support",
|
|
5
5
|
"main": "src/index.js",
|
|
6
|
+
"types": "src/types/index.d.ts",
|
|
6
7
|
"bin": {
|
|
7
8
|
"spiral": "./bin/spiral.js"
|
|
8
9
|
},
|
|
@@ -43,7 +44,8 @@
|
|
|
43
44
|
"dotenv": "^16.3.0",
|
|
44
45
|
"chalk": "^4.1.2",
|
|
45
46
|
"ms": "^2.1.3",
|
|
46
|
-
"uuid": "^9.0.0"
|
|
47
|
+
"uuid": "^9.0.0",
|
|
48
|
+
"chokidar": "^3.6.0"
|
|
47
49
|
},
|
|
48
50
|
"engines": {
|
|
49
51
|
"node": ">=16.0.0"
|
package/src/plugins/manager.js
CHANGED
|
@@ -2,6 +2,7 @@ const fs = require('fs').promises;
|
|
|
2
2
|
const fsSync = require('fs');
|
|
3
3
|
const path = require('path');
|
|
4
4
|
const EventEmitter = require('events');
|
|
5
|
+
const { EmbedBuilder } = require('discord.js');
|
|
5
6
|
|
|
6
7
|
class SpiralPluginManager extends EventEmitter {
|
|
7
8
|
constructor(runtime) {
|
|
@@ -14,6 +15,7 @@ class SpiralPluginManager extends EventEmitter {
|
|
|
14
15
|
this.loadOrder = [];
|
|
15
16
|
this.maxListeners = 50;
|
|
16
17
|
this._cache = new Map();
|
|
18
|
+
this._hookCleanups = [];
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
async loadPlugins(pluginsDir) {
|
|
@@ -23,14 +25,18 @@ class SpiralPluginManager extends EventEmitter {
|
|
|
23
25
|
return;
|
|
24
26
|
}
|
|
25
27
|
|
|
26
|
-
|
|
28
|
+
this.removeAllListeners();
|
|
29
|
+
this._hookCleanups.forEach(cleanup => cleanup());
|
|
30
|
+
this._hookCleanups = [];
|
|
31
|
+
|
|
32
|
+
const entries = await fs.readdir(pluginsDir, { withFileTypes: true });
|
|
33
|
+
const pluginFolders = entries.filter(e => e.isDirectory()).map(e => e.name);
|
|
27
34
|
const manifests = [];
|
|
28
35
|
|
|
29
36
|
const manifestPromises = pluginFolders.map(async (folder) => {
|
|
30
37
|
const pluginPath = path.resolve(pluginsDir, folder);
|
|
31
38
|
const manifestPath = path.join(pluginPath, 'plugin.json');
|
|
32
39
|
try {
|
|
33
|
-
await fs.access(manifestPath);
|
|
34
40
|
const data = await fs.readFile(manifestPath, 'utf-8');
|
|
35
41
|
return { folder, pluginPath, manifest: JSON.parse(data) };
|
|
36
42
|
} catch {
|
|
@@ -45,10 +51,13 @@ class SpiralPluginManager extends EventEmitter {
|
|
|
45
51
|
|
|
46
52
|
const sorted = this.resolveDependencies(manifests);
|
|
47
53
|
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
54
|
+
for (const { folder, pluginPath, manifest } of sorted) {
|
|
55
|
+
try {
|
|
56
|
+
await this.loadPlugin(folder, pluginPath, manifest);
|
|
57
|
+
} catch (error) {
|
|
58
|
+
console.error(`[manager] Failed to load ${manifest.name}:`, error.message);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
52
61
|
}
|
|
53
62
|
|
|
54
63
|
resolveDependencies(manifests) {
|
|
@@ -64,7 +73,7 @@ class SpiralPluginManager extends EventEmitter {
|
|
|
64
73
|
const visit = (name) => {
|
|
65
74
|
if (visited.has(name)) return;
|
|
66
75
|
if (visiting.has(name)) {
|
|
67
|
-
console.error(`[manager] Circular dependency: ${name}`);
|
|
76
|
+
console.error(`[manager] Circular dependency detected: ${name}`);
|
|
68
77
|
return;
|
|
69
78
|
}
|
|
70
79
|
visiting.add(name);
|
|
@@ -72,6 +81,9 @@ class SpiralPluginManager extends EventEmitter {
|
|
|
72
81
|
const m = manifestMap.get(name);
|
|
73
82
|
if (m && m.manifest.dependencies) {
|
|
74
83
|
for (const dep of Object.keys(m.manifest.dependencies)) {
|
|
84
|
+
if (!manifestMap.has(dep)) {
|
|
85
|
+
console.warn(`[manager] Missing dependency "${dep}" for ${name}`);
|
|
86
|
+
}
|
|
75
87
|
visit(dep);
|
|
76
88
|
}
|
|
77
89
|
}
|
|
@@ -104,8 +116,12 @@ class SpiralPluginManager extends EventEmitter {
|
|
|
104
116
|
this.pluginConfigs.set(manifest.name, config);
|
|
105
117
|
this.plugins.set(manifest.name, { manifest, module: pluginModule, path: pluginPath });
|
|
106
118
|
|
|
107
|
-
|
|
108
|
-
|
|
119
|
+
try {
|
|
120
|
+
if (pluginModule.init) {
|
|
121
|
+
await pluginModule.init(config, this.runtime);
|
|
122
|
+
}
|
|
123
|
+
} catch (error) {
|
|
124
|
+
console.error(`[manager] Init failed for ${manifest.name}:`, error.message);
|
|
109
125
|
}
|
|
110
126
|
|
|
111
127
|
if (pluginModule.commands) {
|
|
@@ -122,13 +138,15 @@ class SpiralPluginManager extends EventEmitter {
|
|
|
122
138
|
|
|
123
139
|
if (pluginModule.hooks) {
|
|
124
140
|
for (const [hookName, handler] of Object.entries(pluginModule.hooks)) {
|
|
125
|
-
|
|
141
|
+
const listener = async (payload) => {
|
|
126
142
|
try {
|
|
127
143
|
await handler(payload, this.runtime);
|
|
128
144
|
} catch (error) {
|
|
129
145
|
console.error(`[manager] Hook error ${hookName} from ${manifest.name}:`, error.message);
|
|
130
146
|
}
|
|
131
|
-
}
|
|
147
|
+
};
|
|
148
|
+
this.on(hookName, listener);
|
|
149
|
+
this._hookCleanups.push(() => this.removeListener(hookName, listener));
|
|
132
150
|
}
|
|
133
151
|
}
|
|
134
152
|
|
|
@@ -143,15 +161,24 @@ class SpiralPluginManager extends EventEmitter {
|
|
|
143
161
|
async _loadModule(indexPath, dslPath, manifest) {
|
|
144
162
|
try {
|
|
145
163
|
await fs.access(indexPath);
|
|
146
|
-
|
|
164
|
+
const resolved = require.resolve(indexPath);
|
|
165
|
+
delete require.cache[resolved];
|
|
147
166
|
return require(indexPath);
|
|
148
|
-
} catch {
|
|
167
|
+
} catch (error) {
|
|
168
|
+
if (error.code !== 'ENOENT') {
|
|
169
|
+
console.error(`[manager] Failed to load ${indexPath}:`, error.message);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
149
172
|
|
|
150
173
|
try {
|
|
151
174
|
await fs.access(dslPath);
|
|
152
175
|
const dslContent = await fs.readFile(dslPath, 'utf-8');
|
|
153
176
|
return this.compileDSL(dslContent, manifest);
|
|
154
|
-
} catch {
|
|
177
|
+
} catch (error) {
|
|
178
|
+
if (error.code !== 'ENOENT') {
|
|
179
|
+
console.error(`[manager] Failed to load ${dslPath}:`, error.message);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
155
182
|
|
|
156
183
|
return null;
|
|
157
184
|
}
|
|
@@ -167,20 +194,26 @@ class SpiralPluginManager extends EventEmitter {
|
|
|
167
194
|
if (existing) {
|
|
168
195
|
existing.handler = cmd.execute || cmd;
|
|
169
196
|
existing.description = cmd.description || existing.description;
|
|
197
|
+
registrations.sort((a, b) => b.priority - a.priority);
|
|
170
198
|
return;
|
|
171
199
|
}
|
|
172
200
|
|
|
173
|
-
const manifest = this.plugins.get(pluginName)?.manifest;
|
|
174
|
-
const collisionPolicy = manifest?.collision_policy || 'last-wins';
|
|
175
|
-
|
|
176
201
|
if (registrations.length > 0) {
|
|
177
202
|
const conflict = registrations[0];
|
|
203
|
+
const conflictManifest = this.plugins.get(conflict.plugin)?.manifest;
|
|
204
|
+
const policy = conflictManifest?.collision_policy || 'last-wins';
|
|
205
|
+
|
|
178
206
|
console.warn(`[manager] Command "${name}" collision: ${conflict.plugin} vs ${pluginName}`);
|
|
179
207
|
|
|
180
|
-
if (
|
|
208
|
+
if (policy === 'error') {
|
|
181
209
|
console.error(`[manager] Refusing ${pluginName}: "${name}" already registered`);
|
|
182
210
|
return;
|
|
183
211
|
}
|
|
212
|
+
|
|
213
|
+
if (policy === 'first-wins') {
|
|
214
|
+
console.log(`[manager] Keeping ${conflict.plugin}'s "${name}" (first-wins)`);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
184
217
|
}
|
|
185
218
|
|
|
186
219
|
registrations.push({
|
|
@@ -222,6 +255,21 @@ class SpiralPluginManager extends EventEmitter {
|
|
|
222
255
|
return result;
|
|
223
256
|
}
|
|
224
257
|
|
|
258
|
+
getReplCommands() {
|
|
259
|
+
const result = {};
|
|
260
|
+
for (const [pluginName, { module: mod }] of this.plugins) {
|
|
261
|
+
if (mod.repl) {
|
|
262
|
+
for (const [cmdName, cmd] of Object.entries(mod.repl)) {
|
|
263
|
+
result[cmdName] = {
|
|
264
|
+
...cmd,
|
|
265
|
+
plugin: pluginName
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return result;
|
|
271
|
+
}
|
|
272
|
+
|
|
225
273
|
async executeCommand(name, message, args) {
|
|
226
274
|
const cmd = this.getCommand(name);
|
|
227
275
|
if (!cmd) return false;
|
|
@@ -234,7 +282,7 @@ class SpiralPluginManager extends EventEmitter {
|
|
|
234
282
|
}
|
|
235
283
|
|
|
236
284
|
try {
|
|
237
|
-
await cmd.handler(message, args, this.runtime);
|
|
285
|
+
await cmd.handler(message, args || [], this.runtime);
|
|
238
286
|
return true;
|
|
239
287
|
} catch (error) {
|
|
240
288
|
console.error(`[manager] Error executing ${name}:`, error);
|
|
@@ -244,7 +292,14 @@ class SpiralPluginManager extends EventEmitter {
|
|
|
244
292
|
}
|
|
245
293
|
|
|
246
294
|
async emitHook(eventName, payload) {
|
|
247
|
-
this.
|
|
295
|
+
const listeners = this.listeners(eventName);
|
|
296
|
+
for (const listener of listeners) {
|
|
297
|
+
try {
|
|
298
|
+
await listener(payload);
|
|
299
|
+
} catch (error) {
|
|
300
|
+
console.error(`[manager] Hook error ${eventName}:`, error.message);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
248
303
|
}
|
|
249
304
|
|
|
250
305
|
async emitHookSerial(eventName, payload) {
|
|
@@ -313,6 +368,7 @@ class SpiralPluginManager extends EventEmitter {
|
|
|
313
368
|
compileDSL(dslContent, manifest) {
|
|
314
369
|
const lines = dslContent.split('\n');
|
|
315
370
|
const commands = {};
|
|
371
|
+
const slashCommands = [];
|
|
316
372
|
|
|
317
373
|
let currentBlock = null;
|
|
318
374
|
let blockLines = [];
|
|
@@ -321,21 +377,30 @@ class SpiralPluginManager extends EventEmitter {
|
|
|
321
377
|
const trimmed = line.trim();
|
|
322
378
|
if (trimmed === '' || trimmed.startsWith('#')) continue;
|
|
323
379
|
|
|
324
|
-
if (trimmed.startsWith('COMMAND ')) {
|
|
380
|
+
if (trimmed.startsWith('COMMAND ') || trimmed.startsWith('SLASH_COMMAND ')) {
|
|
325
381
|
if (currentBlock) {
|
|
326
|
-
this._processDSLBlock(currentBlock, blockLines, commands);
|
|
382
|
+
this._processDSLBlock(currentBlock, blockLines, commands, slashCommands);
|
|
327
383
|
blockLines = [];
|
|
328
384
|
}
|
|
329
|
-
const
|
|
385
|
+
const isSlash = trimmed.startsWith('SLASH_COMMAND ');
|
|
386
|
+
const pattern = isSlash ? /SLASH_COMMAND\s+(\S+)\s+"([^"]+)"/ : /COMMAND\s+(\S+)\s+"([^"]+)"/;
|
|
387
|
+
const match = trimmed.match(pattern);
|
|
330
388
|
if (match) {
|
|
331
|
-
currentBlock = {
|
|
389
|
+
currentBlock = {
|
|
390
|
+
type: 'command', name: match[1], description: match[2],
|
|
391
|
+
aliases: [], cooldown: 0, requiresArgs: null, requiresPermission: null,
|
|
392
|
+
response: null, responsePool: null, embed: null, slash: isSlash,
|
|
393
|
+
buttons: [], selects: [], dbOps: [], selectPlaceholder: null
|
|
394
|
+
};
|
|
395
|
+
} else {
|
|
396
|
+
currentBlock = null;
|
|
332
397
|
}
|
|
333
398
|
continue;
|
|
334
399
|
}
|
|
335
400
|
|
|
336
401
|
if (trimmed === 'END') {
|
|
337
402
|
if (currentBlock) {
|
|
338
|
-
this._processDSLBlock(currentBlock, blockLines, commands);
|
|
403
|
+
this._processDSLBlock(currentBlock, blockLines, commands, slashCommands);
|
|
339
404
|
currentBlock = null;
|
|
340
405
|
blockLines = [];
|
|
341
406
|
}
|
|
@@ -345,15 +410,30 @@ class SpiralPluginManager extends EventEmitter {
|
|
|
345
410
|
if (currentBlock) blockLines.push(trimmed);
|
|
346
411
|
}
|
|
347
412
|
|
|
348
|
-
|
|
413
|
+
const init = async (config, runtime) => {};
|
|
414
|
+
|
|
415
|
+
if (slashCommands.length > 0) {
|
|
416
|
+
init._slashCommands = slashCommands;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
return { commands, init };
|
|
349
420
|
}
|
|
350
421
|
|
|
351
|
-
_processDSLBlock(block, lines, commands) {
|
|
422
|
+
_processDSLBlock(block, lines, commands, slashCommands) {
|
|
352
423
|
if (block.type !== 'command') return;
|
|
353
424
|
|
|
425
|
+
const dslKeywords = [
|
|
426
|
+
'ALIASES', 'COOLDOWN', 'REQUIRES_ARGS', 'REQUIRES_PERMISSION',
|
|
427
|
+
'RESPONSE', 'RESPONSE_POOL', 'EMBED', 'TITLE', 'DESCRIPTION',
|
|
428
|
+
'BUTTON', 'SELECT', 'DB_SET', 'DB_GET', 'DB_ADD', 'DB_DELETE', 'DB_RESPONSE'
|
|
429
|
+
];
|
|
430
|
+
|
|
354
431
|
for (const line of lines) {
|
|
355
432
|
if (line.startsWith('ALIASES ')) block.aliases = line.slice(8).split(',').map(a => a.trim());
|
|
356
|
-
else if (line.startsWith('COOLDOWN '))
|
|
433
|
+
else if (line.startsWith('COOLDOWN ')) {
|
|
434
|
+
const val = parseInt(line.slice(9), 10);
|
|
435
|
+
block.cooldown = isNaN(val) ? 0 : val * 1000;
|
|
436
|
+
}
|
|
357
437
|
else if (line.startsWith('REQUIRES_ARGS ')) block.requiresArgs = line.slice(14).replace(/^["']|["']$/g, '');
|
|
358
438
|
else if (line.startsWith('REQUIRES_PERMISSION ')) block.requiresPermission = line.slice(20);
|
|
359
439
|
else if (line.startsWith('RESPONSE ')) block.response = line.slice(9).replace(/^["']|["']$/g, '');
|
|
@@ -364,7 +444,41 @@ class SpiralPluginManager extends EventEmitter {
|
|
|
364
444
|
}
|
|
365
445
|
else if (line.startsWith('TITLE ')) { if (block.embed) block.embed.title = line.slice(6).replace(/^["']|["']$/g, ''); }
|
|
366
446
|
else if (line.startsWith('DESCRIPTION ')) { if (block.embed) block.embed.description = line.slice(12).replace(/^["']|["']$/g, ''); }
|
|
367
|
-
else if (
|
|
447
|
+
else if (line.startsWith('BUTTON ')) {
|
|
448
|
+
const bMatch = line.match(/BUTTON\s+"([^"]+)"\s+STYLE\s+(\w+)\s+ACTION\s+(\S+)/);
|
|
449
|
+
if (bMatch) block.buttons.push({ label: bMatch[1], style: bMatch[2], action: bMatch[3] });
|
|
450
|
+
}
|
|
451
|
+
else if (line.startsWith('SELECT ')) {
|
|
452
|
+
const sMatch = line.match(/SELECT\s+"([^"]+)"\s+OPTION\s+"([^"]+)"\s+VALUE\s+"([^"]+)"/);
|
|
453
|
+
if (sMatch) {
|
|
454
|
+
if (!block.selectPlaceholder) block.selectPlaceholder = sMatch[1];
|
|
455
|
+
block.selects.push({ label: sMatch[2], value: sMatch[3] });
|
|
456
|
+
} else {
|
|
457
|
+
const sPlaceholder = line.match(/SELECT\s+"([^"]+)"/);
|
|
458
|
+
if (sPlaceholder) block.selectPlaceholder = sPlaceholder[1];
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
else if (line.startsWith('DB_SET ')) {
|
|
462
|
+
const dbMatch = line.match(/DB_SET\s+(\S+)\s+(.+)/);
|
|
463
|
+
if (dbMatch) block.dbOps.push({ op: 'set', key: dbMatch[1], value: dbMatch[2].replace(/^["']|["']$/g, '') });
|
|
464
|
+
}
|
|
465
|
+
else if (line.startsWith('DB_GET ')) {
|
|
466
|
+
const dbMatch = line.match(/DB_GET\s+(\S+)/);
|
|
467
|
+
if (dbMatch) block.dbOps.push({ op: 'get', key: dbMatch[1] });
|
|
468
|
+
}
|
|
469
|
+
else if (line.startsWith('DB_ADD ')) {
|
|
470
|
+
const dbMatch = line.match(/DB_ADD\s+(\S+)\s+(-?\d+)/);
|
|
471
|
+
if (dbMatch) block.dbOps.push({ op: 'add', key: dbMatch[1], amount: parseInt(dbMatch[2], 10) });
|
|
472
|
+
}
|
|
473
|
+
else if (line.startsWith('DB_DELETE ')) {
|
|
474
|
+
const dbMatch = line.match(/DB_DELETE\s+(\S+)/);
|
|
475
|
+
if (dbMatch) block.dbOps.push({ op: 'delete', key: dbMatch[1] });
|
|
476
|
+
}
|
|
477
|
+
else if (line.startsWith('DB_RESPONSE ')) {
|
|
478
|
+
const dbMatch = line.match(/DB_RESPONSE\s+"([^"]+)"/);
|
|
479
|
+
if (dbMatch) block.dbOps.push({ op: 'response', key: dbMatch[1] });
|
|
480
|
+
}
|
|
481
|
+
else if (block.responsePool !== null && !dslKeywords.some(kw => line.startsWith(kw))) {
|
|
368
482
|
block.responsePool.push(line.replace(/^["']|["']$/g, ''));
|
|
369
483
|
}
|
|
370
484
|
}
|
|
@@ -373,23 +487,45 @@ class SpiralPluginManager extends EventEmitter {
|
|
|
373
487
|
const manager = this;
|
|
374
488
|
const handler = async (message, args, runtime) => {
|
|
375
489
|
if (blockRef.requiresArgs && (!args || args.length === 0)) return message.reply(blockRef.requiresArgs);
|
|
376
|
-
if (blockRef.requiresPermission && !message.member
|
|
490
|
+
if (blockRef.requiresPermission && message.member && !message.member.permissions.has(blockRef.requiresPermission)) {
|
|
377
491
|
return message.reply(`You need \`${blockRef.requiresPermission}\` permission.`);
|
|
378
492
|
}
|
|
379
493
|
|
|
380
494
|
const ctxMap = new Map([
|
|
381
495
|
['user.id', message.author.id], ['user.name', message.author.username],
|
|
382
496
|
['channel.id', message.channel.id], ['guild.id', message.guild?.id || 'dm'],
|
|
383
|
-
['guild.name', message.guild?.name || 'DM'], ['args', args.join(' ')]
|
|
497
|
+
['guild.name', message.guild?.name || 'DM'], ['args', (args || []).join(' ')]
|
|
384
498
|
]);
|
|
385
499
|
|
|
500
|
+
for (const dbOp of blockRef.dbOps) {
|
|
501
|
+
const key = manager._resolveDSLVars(dbOp.key, ctxMap);
|
|
502
|
+
const userId = message.author.id;
|
|
503
|
+
const fullKey = `${userId}.${key}`;
|
|
504
|
+
|
|
505
|
+
if (dbOp.op === 'set') {
|
|
506
|
+
const val = manager._resolveDSLVars(dbOp.value, ctxMap);
|
|
507
|
+
const num = parseFloat(val);
|
|
508
|
+
runtime.db.set(fullKey, isNaN(num) ? val : num);
|
|
509
|
+
} else if (dbOp.op === 'get') {
|
|
510
|
+
const val = runtime.db.get(fullKey);
|
|
511
|
+
ctxMap.set('db_value', val !== null ? String(val) : '0');
|
|
512
|
+
} else if (dbOp.op === 'add') {
|
|
513
|
+
const newVal = runtime.db.add(fullKey, dbOp.amount);
|
|
514
|
+
ctxMap.set('db_value', String(newVal));
|
|
515
|
+
} else if (dbOp.op === 'delete') {
|
|
516
|
+
runtime.db.delete(fullKey);
|
|
517
|
+
} else if (dbOp.op === 'response') {
|
|
518
|
+
const val = runtime.db.get(fullKey);
|
|
519
|
+
ctxMap.set('db_response', val !== null ? String(val) : '0');
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
386
523
|
if (blockRef.response) return message.reply(manager._resolveDSLVars(blockRef.response, ctxMap));
|
|
387
524
|
if (blockRef.responsePool && blockRef.responsePool.length > 0) {
|
|
388
525
|
const response = blockRef.responsePool[Math.floor(Math.random() * blockRef.responsePool.length)];
|
|
389
526
|
return message.reply(manager._resolveDSLVars(response, ctxMap));
|
|
390
527
|
}
|
|
391
528
|
if (blockRef.embed) {
|
|
392
|
-
const { EmbedBuilder } = require('discord.js');
|
|
393
529
|
const embed = new EmbedBuilder()
|
|
394
530
|
.setColor(manager._resolveDSLVars(blockRef.embed.color, ctxMap))
|
|
395
531
|
.setTitle(manager._resolveDSLVars(blockRef.embed.title || '', ctxMap))
|
|
@@ -398,10 +534,17 @@ class SpiralPluginManager extends EventEmitter {
|
|
|
398
534
|
}
|
|
399
535
|
};
|
|
400
536
|
|
|
401
|
-
commands[blockRef.name] = {
|
|
537
|
+
commands[blockRef.name] = {
|
|
538
|
+
description: blockRef.description, execute: handler,
|
|
539
|
+
slash: blockRef.slash || false
|
|
540
|
+
};
|
|
402
541
|
for (const alias of blockRef.aliases) {
|
|
403
542
|
commands[alias] = { description: blockRef.description, execute: handler, slash: false };
|
|
404
543
|
}
|
|
544
|
+
|
|
545
|
+
if (blockRef.slash) {
|
|
546
|
+
slashCommands.push({ name: blockRef.name, description: blockRef.description });
|
|
547
|
+
}
|
|
405
548
|
}
|
|
406
549
|
|
|
407
550
|
_resolveDSLVars(text, ctxMap) {
|
|
@@ -410,4 +553,4 @@ class SpiralPluginManager extends EventEmitter {
|
|
|
410
553
|
}
|
|
411
554
|
}
|
|
412
555
|
|
|
413
|
-
module.exports = SpiralPluginManager;
|
|
556
|
+
module.exports = SpiralPluginManager;
|
package/src/runtime.js
CHANGED
|
@@ -3,13 +3,117 @@ const fs = require('fs');
|
|
|
3
3
|
const path = require('path');
|
|
4
4
|
const SpiralPluginManager = require('./plugins/manager');
|
|
5
5
|
|
|
6
|
+
class SpiralDB {
|
|
7
|
+
constructor(filePath) {
|
|
8
|
+
this.filePath = filePath;
|
|
9
|
+
this.data = {};
|
|
10
|
+
this._load();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
_load() {
|
|
14
|
+
try {
|
|
15
|
+
if (fs.existsSync(this.filePath)) {
|
|
16
|
+
this.data = JSON.parse(fs.readFileSync(this.filePath, 'utf-8'));
|
|
17
|
+
}
|
|
18
|
+
} catch { this.data = {}; }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
_save() {
|
|
22
|
+
try {
|
|
23
|
+
const dir = path.dirname(this.filePath);
|
|
24
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
25
|
+
fs.writeFileSync(this.filePath, JSON.stringify(this.data, null, 2));
|
|
26
|
+
} catch (e) {
|
|
27
|
+
console.error('[db] Save error:', e.message);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
get(key, defaultValue = null) {
|
|
32
|
+
if (key == null || typeof key !== 'string') return defaultValue;
|
|
33
|
+
const keys = key.split('.');
|
|
34
|
+
let val = this.data;
|
|
35
|
+
for (const k of keys) {
|
|
36
|
+
if (val === null || val === undefined || typeof val !== 'object') return defaultValue;
|
|
37
|
+
val = val[k];
|
|
38
|
+
}
|
|
39
|
+
return val !== undefined ? val : defaultValue;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
set(key, value) {
|
|
43
|
+
if (key == null || typeof key !== 'string') return value;
|
|
44
|
+
const keys = key.split('.');
|
|
45
|
+
let obj = this.data;
|
|
46
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
47
|
+
if (obj[keys[i]] !== undefined && typeof obj[keys[i]] !== 'object') {
|
|
48
|
+
console.warn(`[db] Overwriting non-object at key "${keys.slice(0, i + 1).join('.')}"`);
|
|
49
|
+
}
|
|
50
|
+
if (!obj[keys[i]] || typeof obj[keys[i]] !== 'object') obj[keys[i]] = {};
|
|
51
|
+
obj = obj[keys[i]];
|
|
52
|
+
}
|
|
53
|
+
obj[keys[keys.length - 1]] = value;
|
|
54
|
+
this._save();
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
add(key, amount) {
|
|
59
|
+
if (typeof amount !== 'number' || isNaN(amount)) {
|
|
60
|
+
console.warn(`[db] add() received non-numeric amount: ${amount}`);
|
|
61
|
+
amount = 0;
|
|
62
|
+
}
|
|
63
|
+
const current = this.get(key, 0);
|
|
64
|
+
const newVal = (typeof current === 'number' ? current : 0) + amount;
|
|
65
|
+
this.set(key, newVal);
|
|
66
|
+
return newVal;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
delete(key) {
|
|
70
|
+
if (key == null || typeof key !== 'string') return false;
|
|
71
|
+
const keys = key.split('.');
|
|
72
|
+
let obj = this.data;
|
|
73
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
74
|
+
if (!obj[keys[i]] || typeof obj[keys[i]] !== 'object') return false;
|
|
75
|
+
obj = obj[keys[i]];
|
|
76
|
+
}
|
|
77
|
+
const existed = keys[keys.length - 1] in obj;
|
|
78
|
+
delete obj[keys[keys.length - 1]];
|
|
79
|
+
this._save();
|
|
80
|
+
return existed;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
has(key) {
|
|
84
|
+
if (key == null || typeof key !== 'string') return false;
|
|
85
|
+
const keys = key.split('.');
|
|
86
|
+
let val = this.data;
|
|
87
|
+
for (const k of keys) {
|
|
88
|
+
if (val === null || val === undefined || typeof val !== 'object') return false;
|
|
89
|
+
if (!(k in val)) return false;
|
|
90
|
+
val = val[k];
|
|
91
|
+
}
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
all() {
|
|
96
|
+
return { ...this.data };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
clear() {
|
|
100
|
+
this.data = {};
|
|
101
|
+
this._save();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
6
105
|
class SpiralRuntime {
|
|
7
106
|
constructor(config) {
|
|
8
107
|
this.config = config || {};
|
|
108
|
+
if (!this.config.token || typeof this.config.token !== 'string') {
|
|
109
|
+
throw new Error('Bot token is required. Set it in spiral.json');
|
|
110
|
+
}
|
|
9
111
|
this.client = null;
|
|
10
112
|
this.pluginManager = new SpiralPluginManager(this);
|
|
11
113
|
this.startTime = Date.now();
|
|
12
114
|
this._pluginsDir = path.join(process.cwd(), 'plugins');
|
|
115
|
+
this._unhandledHandler = null;
|
|
116
|
+
this.db = new SpiralDB(path.join(process.cwd(), 'data.json'));
|
|
13
117
|
}
|
|
14
118
|
|
|
15
119
|
async start() {
|
|
@@ -31,22 +135,27 @@ class SpiralRuntime {
|
|
|
31
135
|
|
|
32
136
|
_resolveIntents() {
|
|
33
137
|
const intents = this.config.intents || ['Guilds', 'GuildMessages', 'MessageContent'];
|
|
138
|
+
const map = {
|
|
139
|
+
'Guilds': GatewayIntentBits.Guilds,
|
|
140
|
+
'GuildMessages': GatewayIntentBits.GuildMessages,
|
|
141
|
+
'MessageContent': GatewayIntentBits.MessageContent,
|
|
142
|
+
'GuildMembers': GatewayIntentBits.GuildMembers,
|
|
143
|
+
'GuildVoiceStates': GatewayIntentBits.GuildVoiceStates,
|
|
144
|
+
'DirectMessages': GatewayIntentBits.DirectMessages,
|
|
145
|
+
'GuildPresences': GatewayIntentBits.GuildPresences
|
|
146
|
+
};
|
|
34
147
|
return intents.map(i => {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
'MessageContent': GatewayIntentBits.MessageContent,
|
|
39
|
-
'GuildMembers': GatewayIntentBits.GuildMembers,
|
|
40
|
-
'GuildVoiceStates': GatewayIntentBits.GuildVoiceStates,
|
|
41
|
-
'DirectMessages': GatewayIntentBits.DirectMessages,
|
|
42
|
-
'GuildPresences': GatewayIntentBits.GuildPresences
|
|
43
|
-
};
|
|
44
|
-
return map[i] || GatewayIntentBits.Guilds;
|
|
148
|
+
if (map[i] !== undefined) return map[i];
|
|
149
|
+
console.warn(`[runtime] Unknown intent "${i}", defaulting to Guilds`);
|
|
150
|
+
return GatewayIntentBits.Guilds;
|
|
45
151
|
});
|
|
46
152
|
}
|
|
47
153
|
|
|
48
154
|
_bindEvents() {
|
|
49
155
|
this.client.removeAllListeners();
|
|
156
|
+
if (this._unhandledHandler) {
|
|
157
|
+
process.removeListener('unhandledRejection', this._unhandledHandler);
|
|
158
|
+
}
|
|
50
159
|
|
|
51
160
|
this.client.on(Events.ClientReady, async () => {
|
|
52
161
|
console.log(`[runtime] Ready as ${this.client.user.tag} | ${this.client.guilds.cache.size} servers | ${this.client.users.cache.size} users`);
|
|
@@ -106,6 +215,12 @@ class SpiralRuntime {
|
|
|
106
215
|
});
|
|
107
216
|
|
|
108
217
|
this.client.on(Events.MessageReactionAdd, async (reaction, user) => {
|
|
218
|
+
if (reaction.partial) {
|
|
219
|
+
try { await reaction.fetch(); } catch { return; }
|
|
220
|
+
}
|
|
221
|
+
if (user.partial) {
|
|
222
|
+
try { await user.fetch(); } catch { return; }
|
|
223
|
+
}
|
|
109
224
|
await this.pluginManager.emitHook('reaction_add', {
|
|
110
225
|
reaction,
|
|
111
226
|
user,
|
|
@@ -114,6 +229,12 @@ class SpiralRuntime {
|
|
|
114
229
|
});
|
|
115
230
|
|
|
116
231
|
this.client.on(Events.MessageReactionRemove, async (reaction, user) => {
|
|
232
|
+
if (reaction.partial) {
|
|
233
|
+
try { await reaction.fetch(); } catch { return; }
|
|
234
|
+
}
|
|
235
|
+
if (user.partial) {
|
|
236
|
+
try { await user.fetch(); } catch { return; }
|
|
237
|
+
}
|
|
117
238
|
await this.pluginManager.emitHook('reaction_remove', {
|
|
118
239
|
reaction,
|
|
119
240
|
user,
|
|
@@ -125,35 +246,49 @@ class SpiralRuntime {
|
|
|
125
246
|
console.error('[runtime] Error:', error.message);
|
|
126
247
|
});
|
|
127
248
|
|
|
128
|
-
|
|
249
|
+
this._unhandledHandler = (error) => {
|
|
129
250
|
console.error('[runtime] Unhandled:', error);
|
|
130
|
-
}
|
|
251
|
+
};
|
|
252
|
+
process.on('unhandledRejection', this._unhandledHandler);
|
|
131
253
|
}
|
|
132
254
|
|
|
133
255
|
async restart() {
|
|
134
256
|
const t0 = Date.now();
|
|
135
257
|
console.log('[runtime] Restarting...');
|
|
136
258
|
|
|
137
|
-
|
|
259
|
+
try {
|
|
260
|
+
await this.pluginManager.emitHook('bot_shutdown', { client: this.client });
|
|
261
|
+
} catch {}
|
|
138
262
|
|
|
139
|
-
|
|
140
|
-
|
|
263
|
+
try {
|
|
264
|
+
if (this.client) this.client.destroy();
|
|
265
|
+
} catch {}
|
|
266
|
+
if (this.client) this.client.removeAllListeners();
|
|
141
267
|
|
|
142
268
|
this.client = new Client({ intents: this._resolveIntents() });
|
|
143
269
|
this._bindEvents();
|
|
144
270
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
271
|
+
try {
|
|
272
|
+
await this.pluginManager.loadPlugins(this._pluginsDir);
|
|
273
|
+
await this.client.login(this.config.token);
|
|
274
|
+
console.log(`[runtime] Restarted in ${Date.now() - t0}ms`);
|
|
275
|
+
} catch (error) {
|
|
276
|
+
console.error('[runtime] Restart failed:', error.message);
|
|
277
|
+
throw error;
|
|
278
|
+
}
|
|
148
279
|
}
|
|
149
280
|
|
|
150
281
|
async stop() {
|
|
151
282
|
console.log('[runtime] Shutting down...');
|
|
152
|
-
|
|
283
|
+
try {
|
|
284
|
+
await this.pluginManager.emitHook('bot_shutdown', { client: this.client });
|
|
285
|
+
} catch {}
|
|
153
286
|
if (this.client) {
|
|
154
|
-
this.client.destroy();
|
|
287
|
+
try { this.client.destroy(); } catch {}
|
|
288
|
+
}
|
|
289
|
+
if (this._unhandledHandler) {
|
|
290
|
+
process.removeListener('unhandledRejection', this._unhandledHandler);
|
|
155
291
|
}
|
|
156
|
-
process.exit(0);
|
|
157
292
|
}
|
|
158
293
|
|
|
159
294
|
getUptime() {
|
|
@@ -177,4 +312,4 @@ class SpiralRuntime {
|
|
|
177
312
|
}
|
|
178
313
|
}
|
|
179
314
|
|
|
180
|
-
module.exports = SpiralRuntime;
|
|
315
|
+
module.exports = SpiralRuntime;
|