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.
- package/package.json +10 -3
- package/src/cli.mjs +111 -3
- package/src/commands/autostart.mjs +342 -0
- package/src/commands/chat.mjs +12 -2
- package/src/commands/ops.mjs +37 -0
- package/src/commands/ui.mjs +22 -5
- package/src/config.mjs +38 -0
- package/src/constants.mjs +8 -1
- package/src/services/llm.mjs +22 -1
- package/src/services/memory.mjs +627 -0
- package/src/services/message-responder.mjs +778 -0
- package/src/services/ops-daemon.mjs +463 -8
- package/src/services/tool-executor.mjs +392 -0
|
@@ -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 };
|