groove-dev 0.20.0 → 0.21.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.
Files changed (40) hide show
  1. package/node_modules/@groove-dev/daemon/package.json +4 -0
  2. package/node_modules/@groove-dev/daemon/src/api.js +86 -0
  3. package/node_modules/@groove-dev/daemon/src/gateways/base.js +87 -0
  4. package/node_modules/@groove-dev/daemon/src/gateways/discord.js +220 -0
  5. package/node_modules/@groove-dev/daemon/src/gateways/formatter.js +201 -0
  6. package/node_modules/@groove-dev/daemon/src/gateways/manager.js +695 -0
  7. package/node_modules/@groove-dev/daemon/src/gateways/slack.js +165 -0
  8. package/node_modules/@groove-dev/daemon/src/gateways/telegram.js +265 -0
  9. package/node_modules/@groove-dev/daemon/src/index.js +4 -0
  10. package/node_modules/@groove-dev/daemon/src/validate.js +55 -0
  11. package/node_modules/@groove-dev/gui/.groove/codebase-index.json +1 -1
  12. package/node_modules/@groove-dev/gui/.groove/daemon.host +1 -0
  13. package/node_modules/@groove-dev/gui/.groove/daemon.pid +1 -0
  14. package/node_modules/@groove-dev/gui/.groove/timeline.json +2944 -0
  15. package/node_modules/@groove-dev/gui/AGENTS_REGISTRY.md +9 -0
  16. package/node_modules/@groove-dev/gui/dist/assets/index-CNqM3_F2.js +552 -0
  17. package/node_modules/@groove-dev/gui/dist/assets/index-ChDhUvQR.css +1 -0
  18. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  19. package/node_modules/@groove-dev/gui/src/stores/groove.js +7 -0
  20. package/node_modules/@groove-dev/gui/src/views/settings.jsx +353 -3
  21. package/package.json +1 -1
  22. package/packages/daemon/package.json +4 -0
  23. package/packages/daemon/src/api.js +86 -0
  24. package/packages/daemon/src/gateways/base.js +87 -0
  25. package/packages/daemon/src/gateways/discord.js +220 -0
  26. package/packages/daemon/src/gateways/formatter.js +201 -0
  27. package/packages/daemon/src/gateways/manager.js +695 -0
  28. package/packages/daemon/src/gateways/slack.js +165 -0
  29. package/packages/daemon/src/gateways/telegram.js +265 -0
  30. package/packages/daemon/src/index.js +4 -0
  31. package/packages/daemon/src/validate.js +55 -0
  32. package/packages/gui/dist/assets/index-CNqM3_F2.js +552 -0
  33. package/packages/gui/dist/assets/index-ChDhUvQR.css +1 -0
  34. package/packages/gui/dist/index.html +2 -2
  35. package/packages/gui/src/stores/groove.js +7 -0
  36. package/packages/gui/src/views/settings.jsx +353 -3
  37. package/node_modules/@groove-dev/gui/dist/assets/index-B8ZmjJeV.css +0 -1
  38. package/node_modules/@groove-dev/gui/dist/assets/index-DKov-d0e.js +0 -537
  39. package/packages/gui/dist/assets/index-B8ZmjJeV.css +0 -1
  40. package/packages/gui/dist/assets/index-DKov-d0e.js +0 -537
@@ -0,0 +1,695 @@
1
+ // GROOVE — Gateway Manager (Lifecycle, Event Routing, Command Dispatch)
2
+ // FSL-1.1-Apache-2.0 — see LICENSE
3
+
4
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, unlinkSync } from 'fs';
5
+ import { resolve } from 'path';
6
+ import { randomUUID } from 'crypto';
7
+ import { eventToSummary, agentListText, statusText, approvalsText, teamsText, schedulesText, truncate, formatTokens } from './formatter.js';
8
+
9
+ const GATEWAY_TYPES = ['telegram', 'discord', 'slack'];
10
+
11
+ // Notification presets — which event types each preset includes
12
+ const PRESETS = {
13
+ critical: new Set([
14
+ 'approval:request',
15
+ 'conflict:detected',
16
+ // agent:exit only when crashed — handled specially in _shouldNotify
17
+ ]),
18
+ lifecycle: new Set([
19
+ 'approval:request',
20
+ 'conflict:detected',
21
+ 'agent:exit',
22
+ 'rotation:complete',
23
+ 'rotation:failed',
24
+ 'schedule:execute',
25
+ 'phase2:spawned',
26
+ 'qc:activated',
27
+ ]),
28
+ all: new Set([
29
+ 'approval:request',
30
+ 'approval:resolved',
31
+ 'conflict:detected',
32
+ 'agent:exit',
33
+ 'rotation:start',
34
+ 'rotation:complete',
35
+ 'rotation:failed',
36
+ 'schedule:execute',
37
+ 'phase2:spawned',
38
+ 'qc:activated',
39
+ 'journalist:cycle',
40
+ 'team:created',
41
+ 'team:updated',
42
+ 'team:deleted',
43
+ ]),
44
+ };
45
+
46
+ // Events that are never forwarded (too high-frequency / GUI-only)
47
+ const NEVER_FORWARD = new Set(['state', 'agent:output', 'file:changed', 'ollama:pull:start', 'ollama:pull:progress', 'ollama:pull:complete', 'ollama:pull:error', 'terminal:output', 'terminal:spawned', 'terminal:exit', 'indexer:complete']);
48
+
49
+ const COALESCE_WINDOW = 3000; // 3 seconds
50
+ const NEVER_COALESCE = new Set(['approval:request']); // Always send immediately
51
+
52
+ // Commands that require 'full' permission (mutate state)
53
+ const WRITE_COMMANDS = new Set(['spawn', 'kill', 'approve', 'reject', 'rotate']);
54
+ // Commands allowed in 'read-only' mode
55
+ const READ_COMMANDS = new Set(['status', 'agents', 'teams', 'schedules', 'help']);
56
+
57
+ export class GatewayManager {
58
+ constructor(daemon) {
59
+ this.daemon = daemon;
60
+ this.gatewaysDir = resolve(daemon.grooveDir, 'gateways');
61
+ mkdirSync(this.gatewaysDir, { recursive: true });
62
+ this.gateways = new Map(); // id -> gateway instance
63
+ this._coalesceTimers = new Map(); // eventType -> { timer, events[] }
64
+ this._originalBroadcast = null;
65
+ this._load();
66
+ }
67
+
68
+ /**
69
+ * Start all enabled gateways and begin routing events.
70
+ */
71
+ async start() {
72
+ // Wrap daemon.broadcast to intercept events for gateway routing
73
+ this._originalBroadcast = this.daemon.broadcast.bind(this.daemon);
74
+ this.daemon.broadcast = (message) => {
75
+ this._originalBroadcast(message);
76
+ this._routeEvent(message);
77
+ };
78
+
79
+ // Replace placeholders with real gateway instances (async imports)
80
+ await this._materialize();
81
+
82
+ // Connect all enabled gateways
83
+ for (const [id, gw] of this.gateways) {
84
+ if (gw.config.enabled && gw.connect) {
85
+ try {
86
+ await gw.connect();
87
+ this.daemon.audit.log('gateway.connect', { id, type: gw.config.type });
88
+ } catch (err) {
89
+ console.log(`[Groove:Gateway] Failed to connect ${id}: ${err.message}`);
90
+ }
91
+ }
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Disconnect all gateways and restore original broadcast.
97
+ */
98
+ async stop() {
99
+ // Clear coalesce timers
100
+ for (const { timer } of this._coalesceTimers.values()) {
101
+ clearTimeout(timer);
102
+ }
103
+ this._coalesceTimers.clear();
104
+
105
+ // Disconnect all gateways
106
+ for (const [id, gw] of this.gateways) {
107
+ if (gw.connected) {
108
+ try {
109
+ await gw.disconnect();
110
+ } catch (err) {
111
+ console.log(`[Groove:Gateway] Error disconnecting ${id}: ${err.message}`);
112
+ }
113
+ }
114
+ }
115
+
116
+ // Restore original broadcast
117
+ if (this._originalBroadcast) {
118
+ this.daemon.broadcast = this._originalBroadcast;
119
+ this._originalBroadcast = null;
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Create a new gateway configuration.
125
+ */
126
+ async create(config) {
127
+ if (!config.type || !GATEWAY_TYPES.includes(config.type)) {
128
+ throw new Error(`Invalid gateway type. Must be one of: ${GATEWAY_TYPES.join(', ')}`);
129
+ }
130
+
131
+ const id = config.id || `${config.type}-${randomUUID().slice(0, 6)}`;
132
+
133
+ if (this.gateways.has(id)) {
134
+ throw new Error(`Gateway already exists: ${id}`);
135
+ }
136
+
137
+ const gwConfig = {
138
+ id,
139
+ type: config.type,
140
+ enabled: config.enabled !== false,
141
+ chatId: config.chatId || null,
142
+ allowedUsers: Array.isArray(config.allowedUsers) ? config.allowedUsers.map(String) : [],
143
+ notifications: config.notifications || { preset: 'critical' },
144
+ commandPermission: config.commandPermission === 'read-only' ? 'read-only' : 'full',
145
+ createdAt: new Date().toISOString(),
146
+ };
147
+
148
+ const gw = await this._instantiate(gwConfig);
149
+ this.gateways.set(id, gw);
150
+ this._save(id);
151
+
152
+ this.daemon.audit.log('gateway.create', { id, type: config.type });
153
+
154
+ // Broadcast gateway status to GUI
155
+ this._broadcastGatewayStatus();
156
+
157
+ return gw.getStatus();
158
+ }
159
+
160
+ /**
161
+ * Update an existing gateway configuration.
162
+ */
163
+ async update(id, updates) {
164
+ const gw = this.gateways.get(id);
165
+ if (!gw) throw new Error(`Gateway not found: ${id}`);
166
+
167
+ const SAFE = ['enabled', 'chatId', 'allowedUsers', 'notifications', 'commandPermission'];
168
+ let needsReconnect = false;
169
+
170
+ for (const key of Object.keys(updates)) {
171
+ if (SAFE.includes(key)) {
172
+ if (key === 'allowedUsers' && Array.isArray(updates[key])) {
173
+ gw.config[key] = updates[key].map(String);
174
+ } else {
175
+ gw.config[key] = updates[key];
176
+ }
177
+ }
178
+ }
179
+
180
+ // If enabled state changed, connect/disconnect
181
+ if ('enabled' in updates) {
182
+ if (updates.enabled && !gw.connected) {
183
+ needsReconnect = true;
184
+ } else if (!updates.enabled && gw.connected) {
185
+ await gw.disconnect();
186
+ }
187
+ }
188
+
189
+ this._save(id);
190
+
191
+ if (needsReconnect) {
192
+ try {
193
+ await gw.connect();
194
+ } catch (err) {
195
+ console.log(`[Groove:Gateway] Failed to reconnect ${id}: ${err.message}`);
196
+ }
197
+ }
198
+
199
+ this._broadcastGatewayStatus();
200
+ return gw.getStatus();
201
+ }
202
+
203
+ /**
204
+ * Delete a gateway.
205
+ */
206
+ async delete(id) {
207
+ const gw = this.gateways.get(id);
208
+ if (!gw) throw new Error(`Gateway not found: ${id}`);
209
+
210
+ if (gw.connected) {
211
+ await gw.disconnect();
212
+ }
213
+
214
+ // Remove credentials
215
+ for (const ck of gw.constructor.credentialKeys) {
216
+ try { this.daemon.credentials.deleteKey(`gateway:${id}:${ck.key}`); } catch { /* ignore */ }
217
+ }
218
+
219
+ this.gateways.delete(id);
220
+
221
+ const filePath = resolve(this.gatewaysDir, `${id}.json`);
222
+ if (existsSync(filePath)) unlinkSync(filePath);
223
+
224
+ this.daemon.audit.log('gateway.delete', { id });
225
+ this._broadcastGatewayStatus();
226
+ }
227
+
228
+ /**
229
+ * List all gateways with their status.
230
+ */
231
+ list() {
232
+ return Array.from(this.gateways.values()).map((gw) => gw.getStatus());
233
+ }
234
+
235
+ /**
236
+ * Get a specific gateway's status.
237
+ */
238
+ get(id) {
239
+ const gw = this.gateways.get(id);
240
+ if (!gw) return null;
241
+ return gw.getStatus();
242
+ }
243
+
244
+ /**
245
+ * Send a test message through a gateway.
246
+ */
247
+ async test(id) {
248
+ const gw = this.gateways.get(id);
249
+ if (!gw) throw new Error(`Gateway not found: ${id}`);
250
+ if (!gw.connected) throw new Error('Gateway is not connected');
251
+
252
+ await gw.send('\u2705 Groove gateway connected! Notifications will appear here.');
253
+ this.daemon.audit.log('gateway.test', { id });
254
+ return { ok: true };
255
+ }
256
+
257
+ /**
258
+ * Manually connect a gateway.
259
+ */
260
+ async connect(id) {
261
+ const gw = this.gateways.get(id);
262
+ if (!gw) throw new Error(`Gateway not found: ${id}`);
263
+ if (gw.connected) return gw.getStatus();
264
+
265
+ await gw.connect();
266
+ this.daemon.audit.log('gateway.connect', { id, type: gw.config.type });
267
+ this._broadcastGatewayStatus();
268
+ return gw.getStatus();
269
+ }
270
+
271
+ /**
272
+ * Manually disconnect a gateway.
273
+ */
274
+ async disconnect(id) {
275
+ const gw = this.gateways.get(id);
276
+ if (!gw) throw new Error(`Gateway not found: ${id}`);
277
+ if (!gw.connected) return gw.getStatus();
278
+
279
+ await gw.disconnect();
280
+ this.daemon.audit.log('gateway.disconnect', { id });
281
+ this._broadcastGatewayStatus();
282
+ return gw.getStatus();
283
+ }
284
+
285
+ /**
286
+ * Set a credential for a gateway.
287
+ */
288
+ setCredential(id, key, value) {
289
+ if (!this.gateways.has(id)) throw new Error(`Gateway not found: ${id}`);
290
+ this.daemon.credentials.setKey(`gateway:${id}:${key}`, value);
291
+ this.daemon.audit.log('gateway.credential.set', { id, key });
292
+ }
293
+
294
+ /**
295
+ * Delete a credential for a gateway.
296
+ */
297
+ deleteCredential(id, key) {
298
+ if (!this.gateways.has(id)) throw new Error(`Gateway not found: ${id}`);
299
+ this.daemon.credentials.deleteKey(`gateway:${id}:${key}`);
300
+ }
301
+
302
+ // -------------------------------------------------------------------
303
+ // Command Routing — chat command → daemon internals
304
+ // -------------------------------------------------------------------
305
+
306
+ /**
307
+ * Route a command from a chat gateway to the appropriate daemon method.
308
+ * Called by BaseGateway.handleCommand() after authorization check.
309
+ * Enforces commandPermission: 'full' (default) or 'read-only'.
310
+ */
311
+ async routeCommand(gateway, command, args) {
312
+ // Permission level check
313
+ const permission = gateway.config.commandPermission || 'full';
314
+ if (permission === 'read-only' && WRITE_COMMANDS.has(command)) {
315
+ return { text: `Permission denied. This gateway is read-only.\nAllowed: ${[...READ_COMMANDS].map((c) => '/' + c).join(', ')}` };
316
+ }
317
+
318
+ try {
319
+ switch (command) {
320
+ case 'status':
321
+ return this._cmdStatus();
322
+ case 'agents':
323
+ return this._cmdAgents();
324
+ case 'spawn':
325
+ return await this._cmdSpawn(args);
326
+ case 'kill':
327
+ return this._cmdKill(args);
328
+ case 'approve':
329
+ return this._cmdApprove(args);
330
+ case 'reject':
331
+ return this._cmdReject(args);
332
+ case 'rotate':
333
+ return await this._cmdRotate(args);
334
+ case 'teams':
335
+ return this._cmdTeams();
336
+ case 'schedules':
337
+ return this._cmdSchedules();
338
+ case 'help':
339
+ return this._cmdHelp();
340
+ default:
341
+ return { text: `Unknown command: /${command}\nType /help for available commands.` };
342
+ }
343
+ } catch (err) {
344
+ return { text: `Error: ${err.message}` };
345
+ }
346
+ }
347
+
348
+ _cmdStatus() {
349
+ const agents = this.daemon.registry.getAll();
350
+ const uptime = process.uptime() * 1000;
351
+ return { text: statusText(agents, uptime) };
352
+ }
353
+
354
+ _cmdAgents() {
355
+ const agents = this.daemon.registry.getAll();
356
+ return { text: agentListText(agents) };
357
+ }
358
+
359
+ async _cmdSpawn(args) {
360
+ if (args.length === 0) {
361
+ return { text: 'Usage: /spawn <role> [--name <name>] [--prompt "task"]' };
362
+ }
363
+
364
+ const role = args[0];
365
+ let name, prompt;
366
+
367
+ // Parse --name and --prompt flags
368
+ for (let i = 1; i < args.length; i++) {
369
+ if (args[i] === '--name' && args[i + 1]) {
370
+ name = args[++i];
371
+ } else if (args[i] === '--prompt' && args[i + 1]) {
372
+ // Collect remaining args as prompt (may be quoted)
373
+ prompt = args.slice(i + 1).join(' ').replace(/^["']|["']$/g, '');
374
+ break;
375
+ }
376
+ }
377
+
378
+ const config = { role };
379
+ if (name) config.name = name;
380
+ if (prompt) config.prompt = prompt;
381
+
382
+ const agent = await this.daemon.processes.spawn(config);
383
+ return { text: `\u2705 Spawned ${agent.name || agent.id} (${role})` };
384
+ }
385
+
386
+ _cmdKill(args) {
387
+ if (args.length === 0) return { text: 'Usage: /kill <agent-id>' };
388
+ const id = args[0];
389
+ this.daemon.processes.kill(id);
390
+ return { text: `\u26d4 Killed agent ${id}` };
391
+ }
392
+
393
+ _cmdApprove(args) {
394
+ if (args.length === 0) return { text: 'Usage: /approve <approval-id>' };
395
+ this.daemon.supervisor.approve(args[0]);
396
+ return { text: `\u2705 Approved: ${args[0]}` };
397
+ }
398
+
399
+ _cmdReject(args) {
400
+ if (args.length === 0) return { text: 'Usage: /reject <approval-id> [reason]' };
401
+ const reason = args.slice(1).join(' ') || undefined;
402
+ this.daemon.supervisor.reject(args[0], reason);
403
+ return { text: `\u274c Rejected: ${args[0]}${reason ? ` — ${reason}` : ''}` };
404
+ }
405
+
406
+ async _cmdRotate(args) {
407
+ if (args.length === 0) return { text: 'Usage: /rotate <agent-id>' };
408
+ await this.daemon.rotator.rotate(args[0]);
409
+ return { text: `\u{1f504} Rotating agent ${args[0]}...` };
410
+ }
411
+
412
+ _cmdTeams() {
413
+ const teams = this.daemon.teams.list();
414
+ return { text: teamsText(teams) };
415
+ }
416
+
417
+ _cmdSchedules() {
418
+ const schedules = this.daemon.scheduler.list();
419
+ return { text: schedulesText(schedules) };
420
+ }
421
+
422
+ _cmdHelp() {
423
+ return {
424
+ text: [
425
+ 'Groove Commands:',
426
+ '/status — daemon status + active agents',
427
+ '/agents — list all agents',
428
+ '/spawn <role> [--name X] [--prompt "Y"] — spawn agent',
429
+ '/kill <id> — kill agent',
430
+ '/approve <id> — approve pending request',
431
+ '/reject <id> [reason] — reject request',
432
+ '/rotate <id> — rotate agent context',
433
+ '/teams — list teams',
434
+ '/schedules — list schedules',
435
+ '/help — this message',
436
+ ].join('\n'),
437
+ };
438
+ }
439
+
440
+ // -------------------------------------------------------------------
441
+ // Event Routing — daemon broadcast → gateway notifications
442
+ // -------------------------------------------------------------------
443
+
444
+ /**
445
+ * Route a daemon broadcast event to all connected gateways.
446
+ */
447
+ _routeEvent(message) {
448
+ if (!message || !message.type) return;
449
+ if (NEVER_FORWARD.has(message.type)) return;
450
+
451
+ for (const gw of this.gateways.values()) {
452
+ if (!gw.connected || !gw.config.enabled) continue;
453
+ if (!this._shouldNotify(gw, message)) continue;
454
+
455
+ // Coalesce or send immediately
456
+ if (NEVER_COALESCE.has(message.type)) {
457
+ this._sendEvent(gw, message);
458
+ } else {
459
+ this._coalesceEvent(gw, message);
460
+ }
461
+ }
462
+ }
463
+
464
+ /**
465
+ * Check if a gateway should receive this event based on notification preferences.
466
+ */
467
+ _shouldNotify(gw, message) {
468
+ const prefs = gw.config.notifications || { preset: 'critical' };
469
+
470
+ // Custom per-event overrides take priority
471
+ if (prefs.custom && message.type in prefs.custom) {
472
+ return prefs.custom[message.type];
473
+ }
474
+
475
+ // Use preset
476
+ const preset = PRESETS[prefs.preset || 'critical'];
477
+ if (!preset) return false;
478
+
479
+ // Special case: 'critical' preset only wants crashed agent:exit
480
+ if (prefs.preset === 'critical' && message.type === 'agent:exit') {
481
+ return message.status === 'crashed';
482
+ }
483
+
484
+ return preset.has(message.type);
485
+ }
486
+
487
+ /**
488
+ * Format and send an event notification to a gateway.
489
+ */
490
+ _sendEvent(gw, message) {
491
+ const text = eventToSummary(message);
492
+ if (!text) return;
493
+
494
+ const options = {};
495
+
496
+ // Add inline action buttons for approval requests (platform-specific)
497
+ if (message.type === 'approval:request' && message.data?.id) {
498
+ options.approvalId = message.data.id;
499
+ }
500
+
501
+ gw.send(text, options).catch((err) => {
502
+ console.log(`[Groove:Gateway] Send failed (${gw.config.id}): ${err.message}`);
503
+ });
504
+ }
505
+
506
+ /**
507
+ * Coalesce rapid events of the same type within a time window.
508
+ */
509
+ _coalesceEvent(gw, message) {
510
+ const key = `${gw.config.id}:${message.type}`;
511
+ let bucket = this._coalesceTimers.get(key);
512
+
513
+ if (!bucket) {
514
+ bucket = { events: [], timer: null };
515
+ this._coalesceTimers.set(key, bucket);
516
+ }
517
+
518
+ bucket.events.push(message);
519
+
520
+ // Reset the flush timer
521
+ if (bucket.timer) clearTimeout(bucket.timer);
522
+ bucket.timer = setTimeout(() => {
523
+ this._flushCoalesced(gw, key, bucket.events);
524
+ this._coalesceTimers.delete(key);
525
+ }, COALESCE_WINDOW);
526
+ }
527
+
528
+ /**
529
+ * Flush coalesced events — send as a single batch message.
530
+ */
531
+ _flushCoalesced(gw, key, events) {
532
+ if (events.length === 0) return;
533
+
534
+ if (events.length === 1) {
535
+ this._sendEvent(gw, events[0]);
536
+ return;
537
+ }
538
+
539
+ // Batch: summarize multiple events of the same type
540
+ const type = events[0].type;
541
+ let text;
542
+
543
+ switch (type) {
544
+ case 'agent:exit': {
545
+ const groups = {};
546
+ for (const e of events) {
547
+ const s = e.status || 'unknown';
548
+ if (!groups[s]) groups[s] = [];
549
+ groups[s].push(e.agentId || 'unknown');
550
+ }
551
+ const parts = Object.entries(groups).map(([s, ids]) => `${ids.length} ${s}: ${ids.join(', ')}`);
552
+ text = `\u{1f4cb} Agent updates — ${parts.join(' | ')}`;
553
+ break;
554
+ }
555
+ case 'conflict:detected':
556
+ text = `\u26a0\ufe0f ${events.length} scope conflicts detected`;
557
+ break;
558
+ default: {
559
+ // Generic batch: send summaries joined
560
+ const summaries = events.map(eventToSummary).filter(Boolean);
561
+ text = summaries.join('\n');
562
+ break;
563
+ }
564
+ }
565
+
566
+ if (text) {
567
+ gw.send(text).catch((err) => {
568
+ console.log(`[Groove:Gateway] Batch send failed (${gw.config.id}): ${err.message}`);
569
+ });
570
+ }
571
+ }
572
+
573
+ // -------------------------------------------------------------------
574
+ // Gateway Status Broadcast to GUI
575
+ // -------------------------------------------------------------------
576
+
577
+ _broadcastGatewayStatus() {
578
+ if (this._originalBroadcast) {
579
+ this._originalBroadcast({
580
+ type: 'gateway:status',
581
+ data: this.list(),
582
+ });
583
+ }
584
+ }
585
+
586
+ // -------------------------------------------------------------------
587
+ // Instantiation & Persistence
588
+ // -------------------------------------------------------------------
589
+
590
+ /**
591
+ * Dynamically instantiate a gateway by type.
592
+ */
593
+ async _instantiate(config) {
594
+ switch (config.type) {
595
+ case 'telegram': {
596
+ const { TelegramGateway } = await import('./telegram.js');
597
+ return new TelegramGateway(this.daemon, config);
598
+ }
599
+ case 'discord': {
600
+ try {
601
+ const { DiscordGateway } = await import('./discord.js');
602
+ return new DiscordGateway(this.daemon, config);
603
+ } catch (err) {
604
+ if (err.code === 'ERR_MODULE_NOT_FOUND') {
605
+ throw new Error('Discord gateway requires discord.js. Install with: npm i discord.js');
606
+ }
607
+ throw err;
608
+ }
609
+ }
610
+ case 'slack': {
611
+ try {
612
+ const { SlackGateway } = await import('./slack.js');
613
+ return new SlackGateway(this.daemon, config);
614
+ } catch (err) {
615
+ if (err.code === 'ERR_MODULE_NOT_FOUND') {
616
+ throw new Error('Slack gateway requires @slack/bolt. Install with: npm i @slack/bolt');
617
+ }
618
+ throw err;
619
+ }
620
+ }
621
+ default:
622
+ throw new Error(`Unknown gateway type: ${config.type}`);
623
+ }
624
+ }
625
+
626
+ _save(id) {
627
+ const gw = this.gateways.get(id);
628
+ if (!gw) return;
629
+ const filePath = resolve(this.gatewaysDir, `${id}.json`);
630
+ writeFileSync(filePath, JSON.stringify(gw.config, null, 2));
631
+ }
632
+
633
+ _load() {
634
+ if (!existsSync(this.gatewaysDir)) return;
635
+ for (const file of readdirSync(this.gatewaysDir)) {
636
+ if (!file.endsWith('.json')) continue;
637
+ try {
638
+ const config = JSON.parse(readFileSync(resolve(this.gatewaysDir, file), 'utf8'));
639
+ const id = config.id || file.replace('.json', '');
640
+ config.id = id;
641
+ // Synchronous load — use TelegramGateway directly for known types
642
+ // Dynamic import is async, so we defer connection to start()
643
+ this._instantiateSync(config);
644
+ } catch (err) {
645
+ console.log(`[Groove:Gateway] Failed to load ${file}: ${err.message}`);
646
+ }
647
+ }
648
+ }
649
+
650
+ /**
651
+ * Synchronous instantiation for load-time (before start).
652
+ * Only Telegram is guaranteed available (no external deps).
653
+ * Discord/Slack will be instantiated in start() if their deps are present.
654
+ */
655
+ _instantiateSync(config) {
656
+ // Store config for deferred async instantiation in start()
657
+ // Use a placeholder that holds config but isn't connected
658
+ const placeholder = {
659
+ config,
660
+ connected: false,
661
+ constructor: { type: config.type, displayName: config.type, credentialKeys: [] },
662
+ getStatus() {
663
+ return {
664
+ id: config.id,
665
+ type: config.type,
666
+ displayName: config.type,
667
+ connected: false,
668
+ enabled: config.enabled,
669
+ chatId: config.chatId || null,
670
+ notifications: config.notifications || { preset: 'critical' },
671
+ allowedUsers: (config.allowedUsers || []).length,
672
+ pending: true, // Not yet fully instantiated
673
+ };
674
+ },
675
+ };
676
+ this.gateways.set(config.id, placeholder);
677
+ }
678
+
679
+ /**
680
+ * Called during start() to replace placeholders with real gateway instances.
681
+ */
682
+ async _materialize() {
683
+ for (const [id, entry] of this.gateways) {
684
+ if (entry.pending || !entry.connect) {
685
+ try {
686
+ const gw = await this._instantiate(entry.config);
687
+ this.gateways.set(id, gw);
688
+ } catch (err) {
689
+ console.log(`[Groove:Gateway] Failed to instantiate ${id}: ${err.message}`);
690
+ this.gateways.delete(id);
691
+ }
692
+ }
693
+ }
694
+ }
695
+ }