nothumanallowed 5.0.0 → 6.0.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.
@@ -0,0 +1,778 @@
1
+ /**
2
+ * Message Responder — auto-responds to Telegram and Discord messages using NHA agents.
3
+ *
4
+ * Runs inside the daemon process. Connects via:
5
+ * - Telegram Bot API (long polling via native fetch, zero dependencies)
6
+ * - Discord Gateway (WebSocket via native net/tls, zero dependencies)
7
+ *
8
+ * Routing: keyword-based (no LLM call) to save API costs. Falls back to CONDUCTOR.
9
+ */
10
+
11
+ import { callAgent } from './llm.mjs';
12
+ import https from 'https';
13
+ import http from 'http';
14
+ import { URL } from 'url';
15
+
16
+ // ── Agent Routing (keyword-based, zero LLM calls) ───────────────────────────
17
+
18
+ const ROUTING_TABLE = [
19
+ {
20
+ agent: 'saber',
21
+ keywords: [
22
+ 'security', 'secure', 'vulnerability', 'vuln', 'exploit', 'attack',
23
+ 'pentest', 'penetration', 'cve', 'owasp', 'xss', 'sql injection',
24
+ 'firewall', 'malware', 'phishing', 'ransomware', 'encryption',
25
+ 'authentication', 'auth', 'csrf', 'ssrf', 'rce', 'injection',
26
+ ],
27
+ },
28
+ {
29
+ agent: 'forge',
30
+ keywords: [
31
+ 'code', 'coding', 'deploy', 'deployment', 'ci', 'cd', 'cicd',
32
+ 'pipeline', 'build', 'compile', 'docker', 'kubernetes', 'k8s',
33
+ 'git', 'commit', 'merge', 'pull request', 'pr', 'branch',
34
+ 'debug', 'debugger', 'refactor', 'typescript', 'javascript',
35
+ 'python', 'rust', 'golang', 'java', 'react', 'node', 'npm',
36
+ ],
37
+ },
38
+ {
39
+ agent: 'oracle',
40
+ keywords: [
41
+ 'data', 'analysis', 'analyze', 'analytics', 'stats', 'statistics',
42
+ 'metric', 'metrics', 'chart', 'graph', 'dashboard', 'report',
43
+ 'trend', 'forecast', 'predict', 'prediction', 'dataset',
44
+ 'database', 'query', 'sql', 'aggregate', 'visualization',
45
+ ],
46
+ },
47
+ {
48
+ agent: 'herald',
49
+ keywords: [
50
+ 'schedule', 'scheduling', 'meeting', 'meetings', 'calendar',
51
+ 'appointment', 'event', 'agenda', 'reminder', 'remind',
52
+ 'reschedule', 'cancel meeting', 'book', 'booking', 'slot',
53
+ 'availability', 'free time', 'when', 'tomorrow', 'next week',
54
+ ],
55
+ },
56
+ {
57
+ agent: 'scheherazade',
58
+ keywords: [
59
+ 'write', 'writing', 'draft', 'blog', 'article', 'essay',
60
+ 'documentation', 'docs', 'readme', 'copywriting', 'copy',
61
+ 'content', 'post', 'newsletter', 'email draft', 'template',
62
+ 'summarize', 'summary', 'outline', 'creative', 'story',
63
+ ],
64
+ },
65
+ {
66
+ agent: 'athena',
67
+ keywords: [
68
+ 'audit', 'review', 'compliance', 'policy', 'governance',
69
+ 'risk', 'assessment', 'standard', 'regulation', 'gdpr',
70
+ 'hipaa', 'soc2', 'iso', 'framework', 'benchmark',
71
+ ],
72
+ },
73
+ {
74
+ agent: 'sauron',
75
+ keywords: [
76
+ 'monitor', 'monitoring', 'alert', 'alerting', 'uptime',
77
+ 'downtime', 'health check', 'status', 'incident', 'outage',
78
+ 'prometheus', 'grafana', 'log', 'logs', 'logging', 'trace',
79
+ ],
80
+ },
81
+ ];
82
+
83
+ /**
84
+ * Route a message to the appropriate agent using keyword matching.
85
+ * Returns agent name (lowercase).
86
+ */
87
+ function routeMessage(text, useAutoRoute = true) {
88
+ if (!useAutoRoute) return 'conductor';
89
+
90
+ const lower = text.toLowerCase();
91
+
92
+ let bestAgent = 'conductor';
93
+ let bestScore = 0;
94
+
95
+ for (const entry of ROUTING_TABLE) {
96
+ let score = 0;
97
+ for (const kw of entry.keywords) {
98
+ if (lower.includes(kw)) {
99
+ // Longer keywords get higher weight to avoid false positives
100
+ score += kw.length;
101
+ }
102
+ }
103
+ if (score > bestScore) {
104
+ bestScore = score;
105
+ bestAgent = entry.agent;
106
+ }
107
+ }
108
+
109
+ return bestAgent;
110
+ }
111
+
112
+ // ── Telegram Bot (Long Polling via native fetch) ─────────────────────────────
113
+
114
+ class TelegramResponder {
115
+ constructor(config, log, wsBroadcast) {
116
+ this.config = config;
117
+ this.log = log;
118
+ this.wsBroadcast = wsBroadcast;
119
+ this.token = config.responder?.telegram?.token || '';
120
+ this.allowedChatIds = config.responder?.telegram?.allowedChatIds || [];
121
+ this.autoRoute = config.responder?.autoRoute !== false;
122
+ this.running = false;
123
+ this.offset = 0;
124
+ this.abortController = null;
125
+ this.pendingRequests = 0;
126
+ this.maxConcurrent = 3;
127
+ }
128
+
129
+ get enabled() {
130
+ return !!this.token;
131
+ }
132
+
133
+ async start() {
134
+ if (!this.enabled) return;
135
+ this.running = true;
136
+ this.log('[Telegram] Responder started — polling for messages');
137
+ this._pollLoop();
138
+ }
139
+
140
+ stop() {
141
+ this.running = false;
142
+ if (this.abortController) {
143
+ this.abortController.abort();
144
+ this.abortController = null;
145
+ }
146
+ this.log('[Telegram] Responder stopped');
147
+ }
148
+
149
+ async _pollLoop() {
150
+ while (this.running) {
151
+ try {
152
+ this.abortController = new AbortController();
153
+ const url = `https://api.telegram.org/bot${this.token}/getUpdates?offset=${this.offset}&timeout=30&allowed_updates=["message"]`;
154
+
155
+ const res = await fetch(url, {
156
+ signal: this.abortController.signal,
157
+ headers: { 'Accept': 'application/json' },
158
+ });
159
+
160
+ if (!res.ok) {
161
+ const errText = await res.text();
162
+ this.log(`[Telegram] API error ${res.status}: ${errText}`);
163
+ // Backoff on error
164
+ await this._sleep(5000);
165
+ continue;
166
+ }
167
+
168
+ const data = await res.json();
169
+
170
+ if (!data.ok || !Array.isArray(data.result)) {
171
+ await this._sleep(2000);
172
+ continue;
173
+ }
174
+
175
+ for (const update of data.result) {
176
+ this.offset = update.update_id + 1;
177
+
178
+ if (update.message && update.message.text) {
179
+ // Fire-and-forget with concurrency guard
180
+ if (this.pendingRequests < this.maxConcurrent) {
181
+ this._handleMessage(update.message).catch(err => {
182
+ this.log(`[Telegram] Handle error: ${err.message}`);
183
+ });
184
+ }
185
+ }
186
+ }
187
+ } catch (err) {
188
+ if (err.name === 'AbortError') break;
189
+ this.log(`[Telegram] Poll error: ${err.message}`);
190
+ await this._sleep(5000);
191
+ }
192
+ }
193
+ }
194
+
195
+ async _handleMessage(message) {
196
+ const chatId = message.chat.id;
197
+ const text = message.text;
198
+ const fromUser = message.from?.first_name || message.from?.username || 'Unknown';
199
+
200
+ // Chat ID allowlist check
201
+ if (this.allowedChatIds.length > 0 && !this.allowedChatIds.includes(chatId)) {
202
+ return;
203
+ }
204
+
205
+ // Skip bot commands that aren't directed at us
206
+ if (text.startsWith('/') && !text.startsWith('/ask') && !text.startsWith('/nha')) {
207
+ return;
208
+ }
209
+
210
+ // Strip /ask or /nha prefix if present
211
+ const cleanText = text.replace(/^\/(ask|nha)\s*/i, '').trim();
212
+ if (!cleanText) return;
213
+
214
+ this.pendingRequests++;
215
+ try {
216
+ const agent = routeMessage(cleanText, this.autoRoute);
217
+ this.log(`[Telegram] ${fromUser} (chat ${chatId}): routed to ${agent.toUpperCase()}`);
218
+
219
+ // Broadcast event
220
+ this.wsBroadcast({
221
+ type: 'responder_message',
222
+ timestamp: new Date().toISOString(),
223
+ data: { platform: 'telegram', from: fromUser, chatId, agent, text: cleanText.slice(0, 120) },
224
+ });
225
+
226
+ // Send typing indicator
227
+ await this._telegramCall('sendChatAction', { chat_id: chatId, action: 'typing' });
228
+
229
+ // Call agent
230
+ const response = await callAgent(this.config, agent, cleanText);
231
+
232
+ // Truncate if too long for Telegram (4096 char limit)
233
+ const truncated = response.length > 4000
234
+ ? response.slice(0, 3950) + '\n\n... [truncated]'
235
+ : response;
236
+
237
+ // Send response
238
+ await this._telegramCall('sendMessage', {
239
+ chat_id: chatId,
240
+ text: `[${agent.toUpperCase()}]\n\n${truncated}`,
241
+ parse_mode: 'Markdown',
242
+ });
243
+
244
+ this.log(`[Telegram] Responded to ${fromUser} via ${agent.toUpperCase()} (${response.length} chars)`);
245
+ } catch (err) {
246
+ this.log(`[Telegram] Agent call failed: ${err.message}`);
247
+ // Send error message to user
248
+ await this._telegramCall('sendMessage', {
249
+ chat_id: chatId,
250
+ text: `Error: ${err.message}`,
251
+ }).catch(() => {});
252
+ } finally {
253
+ this.pendingRequests--;
254
+ }
255
+ }
256
+
257
+ async _telegramCall(method, body) {
258
+ const res = await fetch(`https://api.telegram.org/bot${this.token}/${method}`, {
259
+ method: 'POST',
260
+ headers: { 'Content-Type': 'application/json' },
261
+ body: JSON.stringify(body),
262
+ });
263
+ if (!res.ok) {
264
+ const err = await res.text();
265
+ throw new Error(`Telegram ${method} ${res.status}: ${err}`);
266
+ }
267
+ return res.json();
268
+ }
269
+
270
+ _sleep(ms) {
271
+ return new Promise(resolve => setTimeout(resolve, ms));
272
+ }
273
+ }
274
+
275
+ // ── Discord Bot (Gateway WebSocket via raw TLS, zero dependencies) ───────────
276
+
277
+ class DiscordResponder {
278
+ constructor(config, log, wsBroadcast) {
279
+ this.config = config;
280
+ this.log = log;
281
+ this.wsBroadcast = wsBroadcast;
282
+ this.token = config.responder?.discord?.token || '';
283
+ this.allowedChannelIds = config.responder?.discord?.allowedChannelIds || [];
284
+ this.autoRoute = config.responder?.autoRoute !== false;
285
+ this.running = false;
286
+ this.ws = null;
287
+ this.heartbeatInterval = null;
288
+ this.heartbeatAck = true;
289
+ this.sequence = null;
290
+ this.sessionId = null;
291
+ this.resumeGatewayUrl = null;
292
+ this.pendingRequests = 0;
293
+ this.maxConcurrent = 3;
294
+ this.reconnectAttempts = 0;
295
+ this.maxReconnectAttempts = 10;
296
+ this.botUserId = null;
297
+ }
298
+
299
+ get enabled() {
300
+ return !!this.token;
301
+ }
302
+
303
+ async start() {
304
+ if (!this.enabled) return;
305
+ this.running = true;
306
+ this.log('[Discord] Responder starting — connecting to gateway');
307
+ await this._connect();
308
+ }
309
+
310
+ stop() {
311
+ this.running = false;
312
+ this._clearHeartbeat();
313
+ if (this.ws) {
314
+ try { this.ws.destroy(); } catch {}
315
+ this.ws = null;
316
+ }
317
+ this.log('[Discord] Responder stopped');
318
+ }
319
+
320
+ async _connect(resumeUrl) {
321
+ const gatewayUrl = resumeUrl || 'wss://gateway.discord.gg/?v=10&encoding=json';
322
+
323
+ try {
324
+ const parsed = new URL(gatewayUrl);
325
+ const port = 443;
326
+
327
+ this.ws = https.request({
328
+ hostname: parsed.hostname,
329
+ port,
330
+ path: parsed.pathname + parsed.search,
331
+ method: 'GET',
332
+ headers: {
333
+ 'Upgrade': 'websocket',
334
+ 'Connection': 'Upgrade',
335
+ 'Sec-WebSocket-Key': Buffer.from(Array.from({ length: 16 }, () => Math.random() * 256 | 0)).toString('base64'),
336
+ 'Sec-WebSocket-Version': '13',
337
+ },
338
+ });
339
+
340
+ this.ws.on('upgrade', (res, socket, head) => {
341
+ this.log('[Discord] WebSocket connected');
342
+ this.reconnectAttempts = 0;
343
+ this._wsBuffer = '';
344
+ this._wsSocket = socket;
345
+
346
+ socket.on('data', (chunk) => this._onWsData(chunk));
347
+ socket.on('close', () => this._onClose());
348
+ socket.on('error', (err) => {
349
+ this.log(`[Discord] Socket error: ${err.message}`);
350
+ this._onClose();
351
+ });
352
+ });
353
+
354
+ this.ws.on('error', (err) => {
355
+ this.log(`[Discord] Connection error: ${err.message}`);
356
+ this._scheduleReconnect();
357
+ });
358
+
359
+ this.ws.end();
360
+ } catch (err) {
361
+ this.log(`[Discord] Connect failed: ${err.message}`);
362
+ this._scheduleReconnect();
363
+ }
364
+ }
365
+
366
+ _onWsData(chunk) {
367
+ // Decode WebSocket frames — Discord sends unmasked text frames
368
+ // This is a minimal frame parser for text frames from server (unmasked)
369
+ let offset = 0;
370
+ const buf = chunk;
371
+
372
+ while (offset < buf.length) {
373
+ if (buf.length - offset < 2) break;
374
+
375
+ const firstByte = buf[offset];
376
+ const secondByte = buf[offset + 1];
377
+ const opcode = firstByte & 0x0f;
378
+ const isMasked = (secondByte & 0x80) !== 0;
379
+ let payloadLen = secondByte & 0x7f;
380
+ let headerLen = 2;
381
+
382
+ if (payloadLen === 126) {
383
+ if (buf.length - offset < 4) break;
384
+ payloadLen = buf.readUInt16BE(offset + 2);
385
+ headerLen = 4;
386
+ } else if (payloadLen === 127) {
387
+ if (buf.length - offset < 10) break;
388
+ payloadLen = Number(buf.readBigUInt64BE(offset + 2));
389
+ headerLen = 10;
390
+ }
391
+
392
+ if (isMasked) headerLen += 4;
393
+
394
+ const totalLen = headerLen + payloadLen;
395
+ if (buf.length - offset < totalLen) break;
396
+
397
+ if (opcode === 1) { // text frame
398
+ const payload = buf.slice(offset + headerLen, offset + headerLen + payloadLen).toString('utf-8');
399
+ this._handleGatewayMessage(payload);
400
+ } else if (opcode === 8) { // close
401
+ const code = payloadLen >= 2 ? buf.readUInt16BE(offset + headerLen) : 1000;
402
+ this.log(`[Discord] Gateway close: ${code}`);
403
+ this._onClose(code);
404
+ return;
405
+ } else if (opcode === 9) { // ping
406
+ this._sendWsFrame(10, buf.slice(offset + headerLen, offset + headerLen + payloadLen));
407
+ }
408
+
409
+ offset += totalLen;
410
+ }
411
+ }
412
+
413
+ _sendWsFrame(opcode, data) {
414
+ if (!this._wsSocket || this._wsSocket.destroyed) return;
415
+
416
+ const payload = typeof data === 'string' ? Buffer.from(data, 'utf-8') : (data || Buffer.alloc(0));
417
+ const len = payload.length;
418
+
419
+ // Client frames MUST be masked
420
+ const mask = Buffer.from(Array.from({ length: 4 }, () => Math.random() * 256 | 0));
421
+
422
+ let header;
423
+ if (len < 126) {
424
+ header = Buffer.alloc(6);
425
+ header[0] = 0x80 | opcode;
426
+ header[1] = 0x80 | len;
427
+ mask.copy(header, 2);
428
+ } else if (len < 65536) {
429
+ header = Buffer.alloc(8);
430
+ header[0] = 0x80 | opcode;
431
+ header[1] = 0x80 | 126;
432
+ header.writeUInt16BE(len, 2);
433
+ mask.copy(header, 4);
434
+ } else {
435
+ header = Buffer.alloc(14);
436
+ header[0] = 0x80 | opcode;
437
+ header[1] = 0x80 | 127;
438
+ header.writeBigUInt64BE(BigInt(len), 2);
439
+ mask.copy(header, 10);
440
+ }
441
+
442
+ // Mask the payload
443
+ const masked = Buffer.alloc(len);
444
+ for (let i = 0; i < len; i++) {
445
+ masked[i] = payload[i] ^ mask[i % 4];
446
+ }
447
+
448
+ try {
449
+ this._wsSocket.write(Buffer.concat([header, masked]));
450
+ } catch {}
451
+ }
452
+
453
+ _sendJson(data) {
454
+ this._sendWsFrame(1, JSON.stringify(data));
455
+ }
456
+
457
+ _handleGatewayMessage(raw) {
458
+ let msg;
459
+ try { msg = JSON.parse(raw); } catch { return; }
460
+
461
+ const { op, d, s, t } = msg;
462
+
463
+ if (s !== null && s !== undefined) this.sequence = s;
464
+
465
+ switch (op) {
466
+ case 10: // HELLO
467
+ this._startHeartbeat(d.heartbeat_interval);
468
+ this._identify();
469
+ break;
470
+
471
+ case 11: // HEARTBEAT ACK
472
+ this.heartbeatAck = true;
473
+ break;
474
+
475
+ case 1: // HEARTBEAT request
476
+ this._sendHeartbeat();
477
+ break;
478
+
479
+ case 7: // RECONNECT
480
+ this.log('[Discord] Gateway requested reconnect');
481
+ this._reconnect();
482
+ break;
483
+
484
+ case 9: // INVALID SESSION
485
+ this.log('[Discord] Invalid session, re-identifying');
486
+ setTimeout(() => this._identify(), 1000 + Math.random() * 4000);
487
+ break;
488
+
489
+ case 0: // DISPATCH
490
+ this._handleDispatch(t, d);
491
+ break;
492
+ }
493
+ }
494
+
495
+ _identify() {
496
+ if (this.sessionId && this.sequence !== null) {
497
+ // Resume
498
+ this._sendJson({
499
+ op: 6,
500
+ d: { token: this.token, session_id: this.sessionId, seq: this.sequence },
501
+ });
502
+ this.log('[Discord] Sent RESUME');
503
+ } else {
504
+ // Fresh identify
505
+ this._sendJson({
506
+ op: 2,
507
+ d: {
508
+ token: this.token,
509
+ intents: (1 << 9) | (1 << 15), // GUILD_MESSAGES | MESSAGE_CONTENT
510
+ properties: { os: 'linux', browser: 'nha-cli', device: 'nha-cli' },
511
+ },
512
+ });
513
+ this.log('[Discord] Sent IDENTIFY');
514
+ }
515
+ }
516
+
517
+ _startHeartbeat(intervalMs) {
518
+ this._clearHeartbeat();
519
+ // First heartbeat at random jitter
520
+ const jitter = Math.random() * intervalMs;
521
+ setTimeout(() => {
522
+ this._sendHeartbeat();
523
+ this.heartbeatInterval = setInterval(() => {
524
+ if (!this.heartbeatAck) {
525
+ this.log('[Discord] Heartbeat ACK missed — reconnecting');
526
+ this._reconnect();
527
+ return;
528
+ }
529
+ this.heartbeatAck = false;
530
+ this._sendHeartbeat();
531
+ }, intervalMs);
532
+ }, jitter);
533
+ }
534
+
535
+ _sendHeartbeat() {
536
+ this._sendJson({ op: 1, d: this.sequence });
537
+ }
538
+
539
+ _clearHeartbeat() {
540
+ if (this.heartbeatInterval) {
541
+ clearInterval(this.heartbeatInterval);
542
+ this.heartbeatInterval = null;
543
+ }
544
+ }
545
+
546
+ _handleDispatch(event, data) {
547
+ switch (event) {
548
+ case 'READY':
549
+ this.sessionId = data.session_id;
550
+ this.resumeGatewayUrl = data.resume_gateway_url;
551
+ this.botUserId = data.user?.id;
552
+ this.log(`[Discord] READY — session ${this.sessionId}, bot user ${this.botUserId}`);
553
+ break;
554
+
555
+ case 'RESUMED':
556
+ this.log('[Discord] Session resumed');
557
+ break;
558
+
559
+ case 'MESSAGE_CREATE':
560
+ this._handleDiscordMessage(data);
561
+ break;
562
+ }
563
+ }
564
+
565
+ async _handleDiscordMessage(message) {
566
+ // Ignore bot messages (including our own)
567
+ if (message.author?.bot) return;
568
+ if (message.author?.id === this.botUserId) return;
569
+
570
+ const channelId = message.channel_id;
571
+ const text = message.content;
572
+ const fromUser = message.author?.username || 'Unknown';
573
+
574
+ if (!text || text.trim().length === 0) return;
575
+
576
+ // Channel allowlist check
577
+ if (this.allowedChannelIds.length > 0 && !this.allowedChannelIds.includes(channelId)) {
578
+ return;
579
+ }
580
+
581
+ // Only respond to mentions or messages starting with !nha / !ask
582
+ const mentionPattern = this.botUserId ? `<@${this.botUserId}>` : null;
583
+ const isMentioned = mentionPattern && text.includes(mentionPattern);
584
+ const isCommand = text.startsWith('!nha') || text.startsWith('!ask');
585
+
586
+ if (!isMentioned && !isCommand) return;
587
+
588
+ // Strip prefix
589
+ let cleanText = text;
590
+ if (mentionPattern) cleanText = cleanText.replace(new RegExp(`<@!?${this.botUserId}>`, 'g'), '');
591
+ cleanText = cleanText.replace(/^!(nha|ask)\s*/i, '').trim();
592
+ if (!cleanText) return;
593
+
594
+ if (this.pendingRequests >= this.maxConcurrent) return;
595
+ this.pendingRequests++;
596
+
597
+ try {
598
+ const agent = routeMessage(cleanText, this.autoRoute);
599
+ this.log(`[Discord] ${fromUser} (#${channelId}): routed to ${agent.toUpperCase()}`);
600
+
601
+ this.wsBroadcast({
602
+ type: 'responder_message',
603
+ timestamp: new Date().toISOString(),
604
+ data: { platform: 'discord', from: fromUser, channelId, agent, text: cleanText.slice(0, 120) },
605
+ });
606
+
607
+ // Send typing indicator
608
+ await this._discordApiCall('POST', `/channels/${channelId}/typing`);
609
+
610
+ // Call agent
611
+ const response = await callAgent(this.config, agent, cleanText);
612
+
613
+ // Discord message limit is 2000 chars
614
+ const truncated = response.length > 1900
615
+ ? response.slice(0, 1850) + '\n\n... [truncated]'
616
+ : response;
617
+
618
+ // Send response
619
+ await this._discordApiCall('POST', `/channels/${channelId}/messages`, {
620
+ content: `**[${agent.toUpperCase()}]**\n\n${truncated}`,
621
+ });
622
+
623
+ this.log(`[Discord] Responded to ${fromUser} via ${agent.toUpperCase()} (${response.length} chars)`);
624
+ } catch (err) {
625
+ this.log(`[Discord] Agent call failed: ${err.message}`);
626
+ await this._discordApiCall('POST', `/channels/${channelId}/messages`, {
627
+ content: `Error: ${err.message}`,
628
+ }).catch(() => {});
629
+ } finally {
630
+ this.pendingRequests--;
631
+ }
632
+ }
633
+
634
+ async _discordApiCall(method, endpoint, body) {
635
+ const res = await fetch(`https://discord.com/api/v10${endpoint}`, {
636
+ method,
637
+ headers: {
638
+ 'Authorization': `Bot ${this.token}`,
639
+ 'Content-Type': 'application/json',
640
+ 'User-Agent': 'NHA-CLI (https://nothumanallowed.com, 5.0)',
641
+ },
642
+ body: body ? JSON.stringify(body) : undefined,
643
+ });
644
+
645
+ if (!res.ok) {
646
+ const err = await res.text();
647
+ throw new Error(`Discord API ${method} ${endpoint}: ${res.status} ${err}`);
648
+ }
649
+
650
+ const contentType = res.headers.get('content-type') || '';
651
+ if (contentType.includes('application/json')) {
652
+ return res.json();
653
+ }
654
+ return null;
655
+ }
656
+
657
+ _onClose(code) {
658
+ this._clearHeartbeat();
659
+ if (!this.running) return;
660
+
661
+ // Certain close codes mean we should not reconnect
662
+ const nonRecoverable = [4004, 4010, 4011, 4012, 4013, 4014];
663
+ if (code && nonRecoverable.includes(code)) {
664
+ this.log(`[Discord] Non-recoverable close code ${code} — stopping`);
665
+ this.running = false;
666
+ return;
667
+ }
668
+
669
+ this._scheduleReconnect();
670
+ }
671
+
672
+ _reconnect() {
673
+ this._clearHeartbeat();
674
+ if (this._wsSocket) {
675
+ try { this._wsSocket.destroy(); } catch {}
676
+ this._wsSocket = null;
677
+ }
678
+ this._scheduleReconnect();
679
+ }
680
+
681
+ _scheduleReconnect() {
682
+ if (!this.running) return;
683
+ if (this.reconnectAttempts >= this.maxReconnectAttempts) {
684
+ this.log('[Discord] Max reconnect attempts reached — stopping');
685
+ this.running = false;
686
+ return;
687
+ }
688
+
689
+ this.reconnectAttempts++;
690
+ const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 60000);
691
+ this.log(`[Discord] Reconnecting in ${delay / 1000}s (attempt ${this.reconnectAttempts})`);
692
+
693
+ setTimeout(() => {
694
+ if (this.running) {
695
+ this._connect(this.resumeGatewayUrl || undefined);
696
+ }
697
+ }, delay);
698
+ }
699
+ }
700
+
701
+ // ── Exported API ─────────────────────────────────────────────────────────────
702
+
703
+ let _telegramInstance = null;
704
+ let _discordInstance = null;
705
+
706
+ /**
707
+ * Start the message responder for all configured platforms.
708
+ * Called from the daemon loop.
709
+ *
710
+ * @param {object} config — NHA config
711
+ * @param {function} log — log function
712
+ * @param {function} wsBroadcast — WebSocket broadcast function
713
+ */
714
+ export function startResponder(config, log, wsBroadcast) {
715
+ stopResponder();
716
+
717
+ const hasAnyToken = config.responder?.telegram?.token || config.responder?.discord?.token;
718
+ if (!hasAnyToken) {
719
+ log('[Responder] No tokens configured — skipping');
720
+ return { telegram: false, discord: false };
721
+ }
722
+
723
+ if (!config.llm?.apiKey) {
724
+ log('[Responder] No LLM API key — cannot respond to messages');
725
+ return { telegram: false, discord: false };
726
+ }
727
+
728
+ const result = { telegram: false, discord: false };
729
+
730
+ if (config.responder?.telegram?.token) {
731
+ _telegramInstance = new TelegramResponder(config, log, wsBroadcast);
732
+ _telegramInstance.start();
733
+ result.telegram = true;
734
+ }
735
+
736
+ if (config.responder?.discord?.token) {
737
+ _discordInstance = new DiscordResponder(config, log, wsBroadcast);
738
+ _discordInstance.start();
739
+ result.discord = true;
740
+ }
741
+
742
+ return result;
743
+ }
744
+
745
+ /**
746
+ * Stop all responder instances.
747
+ */
748
+ export function stopResponder() {
749
+ if (_telegramInstance) {
750
+ _telegramInstance.stop();
751
+ _telegramInstance = null;
752
+ }
753
+ if (_discordInstance) {
754
+ _discordInstance.stop();
755
+ _discordInstance = null;
756
+ }
757
+ }
758
+
759
+ /**
760
+ * Get responder status.
761
+ */
762
+ export function getResponderStatus() {
763
+ return {
764
+ telegram: {
765
+ enabled: _telegramInstance?.enabled || false,
766
+ running: _telegramInstance?.running || false,
767
+ pending: _telegramInstance?.pendingRequests || 0,
768
+ },
769
+ discord: {
770
+ enabled: _discordInstance?.enabled || false,
771
+ running: _discordInstance?.running || false,
772
+ pending: _discordInstance?.pendingRequests || 0,
773
+ sessionId: _discordInstance?.sessionId || null,
774
+ },
775
+ };
776
+ }
777
+
778
+ export { routeMessage };