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/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "spiralcord-full",
3
- "version": "2.1.0",
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"
@@ -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
- const pluginFolders = await fs.readdir(pluginsDir);
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 loadPromises = sorted.map(({ folder, pluginPath, manifest }) =>
49
- this.loadPlugin(folder, pluginPath, manifest)
50
- );
51
- await Promise.all(loadPromises);
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
- if (pluginModule.init) {
108
- await pluginModule.init(config, this.runtime);
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
- this.on(hookName, async (payload) => {
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
- delete require.cache[require.resolve(indexPath)];
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 (collisionPolicy === 'error') {
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.emit(eventName, payload);
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 match = trimmed.match(/COMMAND\s+(\S+)\s+"([^"]+)"/);
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 = { type: 'command', name: match[1], description: match[2], aliases: [], cooldown: 0, requiresArgs: null, requiresPermission: null, response: null, responsePool: null, embed: null };
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
- return { commands, init: async (config, runtime) => {} };
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 ')) block.cooldown = parseInt(line.slice(9)) * 1000;
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 (block.responsePool !== null && !line.startsWith('TITLE') && !line.startsWith('DESCRIPTION')) {
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?.permissions.has(blockRef.requiresPermission)) {
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] = { description: blockRef.description, execute: handler, slash: false };
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
- const map = {
36
- 'Guilds': GatewayIntentBits.Guilds,
37
- 'GuildMessages': GatewayIntentBits.GuildMessages,
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
- process.on('unhandledRejection', (error) => {
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
- await this.pluginManager.emitHook('bot_shutdown', { client: this.client });
259
+ try {
260
+ await this.pluginManager.emitHook('bot_shutdown', { client: this.client });
261
+ } catch {}
138
262
 
139
- this.client.destroy();
140
- this.client.removeAllListeners();
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
- await this.client.login(this.config.token);
146
-
147
- console.log(`[runtime] Restarted in ${Date.now() - t0}ms`);
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
- await this.pluginManager.emitHook('bot_shutdown', { client: this.client });
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;