spiralcord-full 2.1.0 → 2.2.7

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.2.7",
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({
@@ -234,7 +267,7 @@ class SpiralPluginManager extends EventEmitter {
234
267
  }
235
268
 
236
269
  try {
237
- await cmd.handler(message, args, this.runtime);
270
+ await cmd.handler(message, args || [], this.runtime);
238
271
  return true;
239
272
  } catch (error) {
240
273
  console.error(`[manager] Error executing ${name}:`, error);
@@ -244,7 +277,14 @@ class SpiralPluginManager extends EventEmitter {
244
277
  }
245
278
 
246
279
  async emitHook(eventName, payload) {
247
- this.emit(eventName, payload);
280
+ const listeners = this.listeners(eventName);
281
+ for (const listener of listeners) {
282
+ try {
283
+ await listener(payload);
284
+ } catch (error) {
285
+ console.error(`[manager] Hook error ${eventName}:`, error.message);
286
+ }
287
+ }
248
288
  }
249
289
 
250
290
  async emitHookSerial(eventName, payload) {
@@ -313,6 +353,7 @@ class SpiralPluginManager extends EventEmitter {
313
353
  compileDSL(dslContent, manifest) {
314
354
  const lines = dslContent.split('\n');
315
355
  const commands = {};
356
+ const slashCommands = [];
316
357
 
317
358
  let currentBlock = null;
318
359
  let blockLines = [];
@@ -321,21 +362,30 @@ class SpiralPluginManager extends EventEmitter {
321
362
  const trimmed = line.trim();
322
363
  if (trimmed === '' || trimmed.startsWith('#')) continue;
323
364
 
324
- if (trimmed.startsWith('COMMAND ')) {
365
+ if (trimmed.startsWith('COMMAND ') || trimmed.startsWith('SLASH_COMMAND ')) {
325
366
  if (currentBlock) {
326
- this._processDSLBlock(currentBlock, blockLines, commands);
367
+ this._processDSLBlock(currentBlock, blockLines, commands, slashCommands);
327
368
  blockLines = [];
328
369
  }
329
- const match = trimmed.match(/COMMAND\s+(\S+)\s+"([^"]+)"/);
370
+ const isSlash = trimmed.startsWith('SLASH_COMMAND ');
371
+ const pattern = isSlash ? /SLASH_COMMAND\s+(\S+)\s+"([^"]+)"/ : /COMMAND\s+(\S+)\s+"([^"]+)"/;
372
+ const match = trimmed.match(pattern);
330
373
  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 };
374
+ currentBlock = {
375
+ type: 'command', name: match[1], description: match[2],
376
+ aliases: [], cooldown: 0, requiresArgs: null, requiresPermission: null,
377
+ response: null, responsePool: null, embed: null, slash: isSlash,
378
+ buttons: [], selects: [], dbOps: [], selectPlaceholder: null
379
+ };
380
+ } else {
381
+ currentBlock = null;
332
382
  }
333
383
  continue;
334
384
  }
335
385
 
336
386
  if (trimmed === 'END') {
337
387
  if (currentBlock) {
338
- this._processDSLBlock(currentBlock, blockLines, commands);
388
+ this._processDSLBlock(currentBlock, blockLines, commands, slashCommands);
339
389
  currentBlock = null;
340
390
  blockLines = [];
341
391
  }
@@ -345,15 +395,30 @@ class SpiralPluginManager extends EventEmitter {
345
395
  if (currentBlock) blockLines.push(trimmed);
346
396
  }
347
397
 
348
- return { commands, init: async (config, runtime) => {} };
398
+ const init = async (config, runtime) => {};
399
+
400
+ if (slashCommands.length > 0) {
401
+ init._slashCommands = slashCommands;
402
+ }
403
+
404
+ return { commands, init };
349
405
  }
350
406
 
351
- _processDSLBlock(block, lines, commands) {
407
+ _processDSLBlock(block, lines, commands, slashCommands) {
352
408
  if (block.type !== 'command') return;
353
409
 
410
+ const dslKeywords = [
411
+ 'ALIASES', 'COOLDOWN', 'REQUIRES_ARGS', 'REQUIRES_PERMISSION',
412
+ 'RESPONSE', 'RESPONSE_POOL', 'EMBED', 'TITLE', 'DESCRIPTION',
413
+ 'BUTTON', 'SELECT', 'DB_SET', 'DB_GET', 'DB_ADD', 'DB_DELETE', 'DB_RESPONSE'
414
+ ];
415
+
354
416
  for (const line of lines) {
355
417
  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;
418
+ else if (line.startsWith('COOLDOWN ')) {
419
+ const val = parseInt(line.slice(9), 10);
420
+ block.cooldown = isNaN(val) ? 0 : val * 1000;
421
+ }
357
422
  else if (line.startsWith('REQUIRES_ARGS ')) block.requiresArgs = line.slice(14).replace(/^["']|["']$/g, '');
358
423
  else if (line.startsWith('REQUIRES_PERMISSION ')) block.requiresPermission = line.slice(20);
359
424
  else if (line.startsWith('RESPONSE ')) block.response = line.slice(9).replace(/^["']|["']$/g, '');
@@ -364,7 +429,41 @@ class SpiralPluginManager extends EventEmitter {
364
429
  }
365
430
  else if (line.startsWith('TITLE ')) { if (block.embed) block.embed.title = line.slice(6).replace(/^["']|["']$/g, ''); }
366
431
  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')) {
432
+ else if (line.startsWith('BUTTON ')) {
433
+ const bMatch = line.match(/BUTTON\s+"([^"]+)"\s+STYLE\s+(\w+)\s+ACTION\s+(\S+)/);
434
+ if (bMatch) block.buttons.push({ label: bMatch[1], style: bMatch[2], action: bMatch[3] });
435
+ }
436
+ else if (line.startsWith('SELECT ')) {
437
+ const sMatch = line.match(/SELECT\s+"([^"]+)"\s+OPTION\s+"([^"]+)"\s+VALUE\s+"([^"]+)"/);
438
+ if (sMatch) {
439
+ if (!block.selectPlaceholder) block.selectPlaceholder = sMatch[1];
440
+ block.selects.push({ label: sMatch[2], value: sMatch[3] });
441
+ } else {
442
+ const sPlaceholder = line.match(/SELECT\s+"([^"]+)"/);
443
+ if (sPlaceholder) block.selectPlaceholder = sPlaceholder[1];
444
+ }
445
+ }
446
+ else if (line.startsWith('DB_SET ')) {
447
+ const dbMatch = line.match(/DB_SET\s+(\S+)\s+(.+)/);
448
+ if (dbMatch) block.dbOps.push({ op: 'set', key: dbMatch[1], value: dbMatch[2].replace(/^["']|["']$/g, '') });
449
+ }
450
+ else if (line.startsWith('DB_GET ')) {
451
+ const dbMatch = line.match(/DB_GET\s+(\S+)/);
452
+ if (dbMatch) block.dbOps.push({ op: 'get', key: dbMatch[1] });
453
+ }
454
+ else if (line.startsWith('DB_ADD ')) {
455
+ const dbMatch = line.match(/DB_ADD\s+(\S+)\s+(-?\d+)/);
456
+ if (dbMatch) block.dbOps.push({ op: 'add', key: dbMatch[1], amount: parseInt(dbMatch[2], 10) });
457
+ }
458
+ else if (line.startsWith('DB_DELETE ')) {
459
+ const dbMatch = line.match(/DB_DELETE\s+(\S+)/);
460
+ if (dbMatch) block.dbOps.push({ op: 'delete', key: dbMatch[1] });
461
+ }
462
+ else if (line.startsWith('DB_RESPONSE ')) {
463
+ const dbMatch = line.match(/DB_RESPONSE\s+"([^"]+)"/);
464
+ if (dbMatch) block.dbOps.push({ op: 'response', key: dbMatch[1] });
465
+ }
466
+ else if (block.responsePool !== null && !dslKeywords.some(kw => line.startsWith(kw))) {
368
467
  block.responsePool.push(line.replace(/^["']|["']$/g, ''));
369
468
  }
370
469
  }
@@ -373,23 +472,45 @@ class SpiralPluginManager extends EventEmitter {
373
472
  const manager = this;
374
473
  const handler = async (message, args, runtime) => {
375
474
  if (blockRef.requiresArgs && (!args || args.length === 0)) return message.reply(blockRef.requiresArgs);
376
- if (blockRef.requiresPermission && !message.member?.permissions.has(blockRef.requiresPermission)) {
475
+ if (blockRef.requiresPermission && message.member && !message.member.permissions.has(blockRef.requiresPermission)) {
377
476
  return message.reply(`You need \`${blockRef.requiresPermission}\` permission.`);
378
477
  }
379
478
 
380
479
  const ctxMap = new Map([
381
480
  ['user.id', message.author.id], ['user.name', message.author.username],
382
481
  ['channel.id', message.channel.id], ['guild.id', message.guild?.id || 'dm'],
383
- ['guild.name', message.guild?.name || 'DM'], ['args', args.join(' ')]
482
+ ['guild.name', message.guild?.name || 'DM'], ['args', (args || []).join(' ')]
384
483
  ]);
385
484
 
485
+ for (const dbOp of blockRef.dbOps) {
486
+ const key = manager._resolveDSLVars(dbOp.key, ctxMap);
487
+ const userId = message.author.id;
488
+ const fullKey = `${userId}.${key}`;
489
+
490
+ if (dbOp.op === 'set') {
491
+ const val = manager._resolveDSLVars(dbOp.value, ctxMap);
492
+ const num = parseFloat(val);
493
+ runtime.db.set(fullKey, isNaN(num) ? val : num);
494
+ } else if (dbOp.op === 'get') {
495
+ const val = runtime.db.get(fullKey);
496
+ ctxMap.set('db_value', val !== null ? String(val) : '0');
497
+ } else if (dbOp.op === 'add') {
498
+ const newVal = runtime.db.add(fullKey, dbOp.amount);
499
+ ctxMap.set('db_value', String(newVal));
500
+ } else if (dbOp.op === 'delete') {
501
+ runtime.db.delete(fullKey);
502
+ } else if (dbOp.op === 'response') {
503
+ const val = runtime.db.get(fullKey);
504
+ ctxMap.set('db_response', val !== null ? String(val) : '0');
505
+ }
506
+ }
507
+
386
508
  if (blockRef.response) return message.reply(manager._resolveDSLVars(blockRef.response, ctxMap));
387
509
  if (blockRef.responsePool && blockRef.responsePool.length > 0) {
388
510
  const response = blockRef.responsePool[Math.floor(Math.random() * blockRef.responsePool.length)];
389
511
  return message.reply(manager._resolveDSLVars(response, ctxMap));
390
512
  }
391
513
  if (blockRef.embed) {
392
- const { EmbedBuilder } = require('discord.js');
393
514
  const embed = new EmbedBuilder()
394
515
  .setColor(manager._resolveDSLVars(blockRef.embed.color, ctxMap))
395
516
  .setTitle(manager._resolveDSLVars(blockRef.embed.title || '', ctxMap))
@@ -398,10 +519,17 @@ class SpiralPluginManager extends EventEmitter {
398
519
  }
399
520
  };
400
521
 
401
- commands[blockRef.name] = { description: blockRef.description, execute: handler, slash: false };
522
+ commands[blockRef.name] = {
523
+ description: blockRef.description, execute: handler,
524
+ slash: blockRef.slash || false
525
+ };
402
526
  for (const alias of blockRef.aliases) {
403
527
  commands[alias] = { description: blockRef.description, execute: handler, slash: false };
404
528
  }
529
+
530
+ if (blockRef.slash) {
531
+ slashCommands.push({ name: blockRef.name, description: blockRef.description });
532
+ }
405
533
  }
406
534
 
407
535
  _resolveDSLVars(text, ctxMap) {
@@ -410,4 +538,4 @@ class SpiralPluginManager extends EventEmitter {
410
538
  }
411
539
  }
412
540
 
413
- module.exports = SpiralPluginManager;
541
+ 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;