2ndbrain 2026.1.30 → 2026.1.32

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,440 @@
1
+ import { execSync } from 'node:child_process';
2
+ import { escapeMarkdownV2 } from './bot.js';
3
+
4
+ /** How long a confirmation remains valid (ms). */
5
+ const CONFIRMATION_TTL_MS = 60_000;
6
+
7
+ /**
8
+ * Descriptions shown by the /help command.
9
+ * @type {Record<string, string>}
10
+ */
11
+ const COMMAND_DESCRIPTIONS = {
12
+ '/status': 'System status -- uptime, memory, message count, last activity',
13
+ '/health': 'Health check -- component status (db, telegram, claude)',
14
+ '/stop': 'Graceful shutdown of the service',
15
+ '/restart': 'Restart the service process',
16
+ '/reboot': 'Reboot the host operating system',
17
+ '/new': 'Start a new conversation session',
18
+ '/help': 'List available commands',
19
+ };
20
+
21
+ /**
22
+ * Routes slash commands to their respective handlers and manages
23
+ * the confirmation flow for dangerous operations.
24
+ */
25
+ class CommandRouter {
26
+ /**
27
+ * @param {object} deps
28
+ * @param {import('./bot.js').TelegramBot} deps.bot - Telegram bot instance
29
+ * @param {import('../logging.js').Logger} deps.logger - Logger instance
30
+ * @param {object} deps.conversationManager - Conversation manager with resetSession()
31
+ * @param {object} deps.processInfo - Runtime info provider
32
+ * @param {number} deps.processInfo.startTime - Timestamp (ms) when the service started
33
+ * @param {() => Promise<number>} deps.processInfo.getMessageCount - Async fn returning total message count
34
+ * @param {() => Promise<object>} deps.processInfo.getHealthStatus - Async fn returning component health
35
+ */
36
+ constructor({ bot, logger, conversationManager, processInfo }) {
37
+ /** @type {import('./bot.js').TelegramBot} */
38
+ this._bot = bot;
39
+
40
+ /** @type {import('../logging.js').Logger} */
41
+ this._logger = logger;
42
+
43
+ /** @type {object} */
44
+ this._conversationManager = conversationManager;
45
+
46
+ /** @type {object} */
47
+ this._processInfo = processInfo;
48
+
49
+ /**
50
+ * Pending confirmations keyed by chatId.
51
+ * @type {Map<string, { command: string, timestamp: number }>}
52
+ */
53
+ this._pendingConfirmations = new Map();
54
+ }
55
+
56
+ /**
57
+ * Check whether the given text is a slash command.
58
+ *
59
+ * @param {string} text
60
+ * @returns {boolean}
61
+ */
62
+ isCommand(text) {
63
+ return typeof text === 'string' && text.startsWith('/');
64
+ }
65
+
66
+ /**
67
+ * Route an inbound message to the appropriate command handler.
68
+ * If the message is a "YES" reply to a pending confirmation, that
69
+ * confirmation is executed instead.
70
+ *
71
+ * @param {object} message
72
+ * @param {string} message.chatId
73
+ * @param {string} message.userId
74
+ * @param {string} message.text
75
+ * @param {number} message.messageId
76
+ * @returns {Promise<boolean>} true if the message was handled as a command or confirmation
77
+ */
78
+ async route(message) {
79
+ const { chatId, text, messageId } = message;
80
+ const trimmed = (text || '').trim();
81
+
82
+ // Check for pending confirmation reply
83
+ if (this._checkConfirmation(chatId, trimmed)) {
84
+ return true;
85
+ }
86
+
87
+ if (!this.isCommand(trimmed)) {
88
+ return false;
89
+ }
90
+
91
+ // Extract the command name (everything up to the first space or @)
92
+ const command = trimmed.split(/[\s@]/)[0].toLowerCase();
93
+
94
+ const replyOpts = { reply_to_message_id: messageId, parse_mode: undefined };
95
+
96
+ try {
97
+ switch (command) {
98
+ case '/status':
99
+ await this._handleStatus(chatId, replyOpts);
100
+ break;
101
+ case '/health':
102
+ await this._handleHealth(chatId, replyOpts);
103
+ break;
104
+ case '/stop':
105
+ await this._handleStop(chatId, replyOpts);
106
+ break;
107
+ case '/restart':
108
+ await this._handleRestart(chatId, replyOpts);
109
+ break;
110
+ case '/reboot':
111
+ await this._handleReboot(chatId, replyOpts);
112
+ break;
113
+ case '/new':
114
+ await this._handleNew(chatId, replyOpts);
115
+ break;
116
+ case '/help':
117
+ await this._handleHelp(chatId, replyOpts);
118
+ break;
119
+ default:
120
+ await this._bot.sendMessage(
121
+ chatId,
122
+ escapeMarkdownV2(`Unknown command: ${command}\nType /help for available commands.`),
123
+ replyOpts,
124
+ );
125
+ break;
126
+ }
127
+ } catch (err) {
128
+ this._logger.error('commands', `Error handling ${command}: ${err.message}`);
129
+ await this._sendPlain(chatId, `Error executing ${command}: ${err.message}`, replyOpts);
130
+ }
131
+
132
+ return true;
133
+ }
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // Confirmation flow
137
+ // ---------------------------------------------------------------------------
138
+
139
+ /**
140
+ * Request confirmation from the user for a dangerous operation.
141
+ *
142
+ * @param {string} chatId
143
+ * @param {string} command - The command that requires confirmation
144
+ * @param {string} prompt - Message to send asking for confirmation
145
+ * @param {object} replyOpts
146
+ * @private
147
+ */
148
+ async _requestConfirmation(chatId, command, prompt, replyOpts) {
149
+ this._pendingConfirmations.set(chatId, {
150
+ command,
151
+ timestamp: Date.now(),
152
+ });
153
+
154
+ await this._sendPlain(chatId, prompt, replyOpts);
155
+ }
156
+
157
+ /**
158
+ * Check whether an incoming message is a "YES" confirmation for a
159
+ * pending dangerous command. Expired confirmations are cleaned up.
160
+ *
161
+ * @param {string} chatId
162
+ * @param {string} text
163
+ * @returns {boolean} true if a confirmation was matched and executed
164
+ * @private
165
+ */
166
+ _checkConfirmation(chatId, text) {
167
+ const pending = this._pendingConfirmations.get(chatId);
168
+ if (!pending) return false;
169
+
170
+ // Clean up expired confirmations
171
+ if (Date.now() - pending.timestamp > CONFIRMATION_TTL_MS) {
172
+ this._pendingConfirmations.delete(chatId);
173
+ return false;
174
+ }
175
+
176
+ if (text.toUpperCase() !== 'YES') {
177
+ return false;
178
+ }
179
+
180
+ this._pendingConfirmations.delete(chatId);
181
+ const { command } = pending;
182
+
183
+ // Execute the confirmed command asynchronously
184
+ this._executeConfirmed(chatId, command);
185
+ return true;
186
+ }
187
+
188
+ /**
189
+ * Execute a command that has been confirmed by the user.
190
+ *
191
+ * @param {string} chatId
192
+ * @param {string} command
193
+ * @private
194
+ */
195
+ async _executeConfirmed(chatId, command) {
196
+ try {
197
+ switch (command) {
198
+ case '/stop':
199
+ await this._sendPlain(chatId, 'Shutting down...');
200
+ this._logger.info('commands', 'Graceful shutdown initiated via /stop');
201
+ // Allow message to send before exiting
202
+ setTimeout(() => process.exit(0), 500);
203
+ break;
204
+
205
+ case '/restart':
206
+ await this._sendPlain(chatId, 'Restarting...');
207
+ this._logger.info('commands', 'Process restart initiated via /restart');
208
+ // Exit with a restart-indicating code; process manager (systemd) should restart
209
+ setTimeout(() => process.exit(0), 500);
210
+ break;
211
+
212
+ case '/reboot':
213
+ await this._sendPlain(chatId, 'Rebooting host system...');
214
+ this._logger.info('commands', 'OS reboot initiated via /reboot');
215
+ setTimeout(() => {
216
+ try {
217
+ execSync('sudo reboot', { timeout: 10_000 });
218
+ } catch (err) {
219
+ this._logger.error('commands', `Reboot failed: ${err.message}`);
220
+ this._sendPlain(chatId, `Reboot failed: ${err.message}`).catch(() => {});
221
+ }
222
+ }, 500);
223
+ break;
224
+
225
+ default:
226
+ this._logger.warn('commands', `Unknown confirmed command: ${command}`);
227
+ break;
228
+ }
229
+ } catch (err) {
230
+ this._logger.error('commands', `Failed to execute confirmed ${command}: ${err.message}`);
231
+ await this._sendPlain(chatId, `Failed to execute ${command}: ${err.message}`).catch(() => {});
232
+ }
233
+ }
234
+
235
+ // ---------------------------------------------------------------------------
236
+ // Command handlers
237
+ // ---------------------------------------------------------------------------
238
+
239
+ /**
240
+ * /status -- System status summary.
241
+ * @private
242
+ */
243
+ async _handleStatus(chatId, replyOpts) {
244
+ const uptimeMs = Date.now() - this._processInfo.startTime;
245
+ const uptime = this._formatUptime(uptimeMs);
246
+ const mem = process.memoryUsage();
247
+
248
+ let messageCount = 'N/A';
249
+ try {
250
+ messageCount = String(await this._processInfo.getMessageCount());
251
+ } catch {
252
+ // Fall through with N/A
253
+ }
254
+
255
+ const lines = [
256
+ 'System Status',
257
+ '',
258
+ `Uptime: ${uptime}`,
259
+ `Memory (RSS): ${this._formatBytes(mem.rss)}`,
260
+ `Memory (Heap Used): ${this._formatBytes(mem.heapUsed)} / ${this._formatBytes(mem.heapTotal)}`,
261
+ `Messages: ${messageCount}`,
262
+ `Last Activity: ${new Date().toISOString()}`,
263
+ `Node.js: ${process.version}`,
264
+ `PID: ${process.pid}`,
265
+ ];
266
+
267
+ await this._sendPlain(chatId, lines.join('\n'), replyOpts);
268
+ }
269
+
270
+ /**
271
+ * /health -- Component health check.
272
+ * @private
273
+ */
274
+ async _handleHealth(chatId, replyOpts) {
275
+ let health;
276
+ try {
277
+ health = await this._processInfo.getHealthStatus();
278
+ } catch (err) {
279
+ await this._sendPlain(
280
+ chatId,
281
+ `Health check failed: ${err.message}`,
282
+ replyOpts,
283
+ );
284
+ return;
285
+ }
286
+
287
+ const { status, components } = health;
288
+ const statusEmoji = status === 'ok' ? 'OK' : status === 'degraded' ? 'DEGRADED' : 'ERROR';
289
+
290
+ const lines = [`Health: ${statusEmoji}`, ''];
291
+
292
+ if (components) {
293
+ for (const [name, detail] of Object.entries(components)) {
294
+ const componentStatus = detail.ok ? 'ok' : 'error';
295
+ const info = detail.message ? ` -- ${detail.message}` : '';
296
+ lines.push(` ${name}: ${componentStatus}${info}`);
297
+ }
298
+ }
299
+
300
+ await this._sendPlain(chatId, lines.join('\n'), replyOpts);
301
+ }
302
+
303
+ /**
304
+ * /stop -- Graceful shutdown with confirmation.
305
+ * @private
306
+ */
307
+ async _handleStop(chatId, replyOpts) {
308
+ await this._requestConfirmation(
309
+ chatId,
310
+ '/stop',
311
+ 'Are you sure you want to stop the service? Reply YES to confirm.',
312
+ replyOpts,
313
+ );
314
+ }
315
+
316
+ /**
317
+ * /restart -- Process restart with confirmation.
318
+ * @private
319
+ */
320
+ async _handleRestart(chatId, replyOpts) {
321
+ await this._requestConfirmation(
322
+ chatId,
323
+ '/restart',
324
+ 'Are you sure you want to restart the service? Reply YES to confirm.',
325
+ replyOpts,
326
+ );
327
+ }
328
+
329
+ /**
330
+ * /reboot -- OS reboot with confirmation.
331
+ * @private
332
+ */
333
+ async _handleReboot(chatId, replyOpts) {
334
+ await this._requestConfirmation(
335
+ chatId,
336
+ '/reboot',
337
+ 'Are you sure you want to reboot the host system? Reply YES to confirm.',
338
+ replyOpts,
339
+ );
340
+ }
341
+
342
+ /**
343
+ * /new -- Start a new conversation session.
344
+ * @private
345
+ */
346
+ async _handleNew(chatId, replyOpts) {
347
+ try {
348
+ if (this._conversationManager && typeof this._conversationManager.newSession === 'function') {
349
+ this._conversationManager.newSession();
350
+ }
351
+
352
+ this._logger.info('commands', 'New conversation session started via /new');
353
+ await this._sendPlain(
354
+ chatId,
355
+ 'New conversation session started. Previous session is preserved in logs.',
356
+ replyOpts,
357
+ );
358
+ } catch (err) {
359
+ this._logger.error('commands', `Failed to start new session: ${err.message}`);
360
+ await this._sendPlain(
361
+ chatId,
362
+ `Failed to start new session: ${err.message}`,
363
+ replyOpts,
364
+ );
365
+ }
366
+ }
367
+
368
+ /**
369
+ * /help -- List all available commands.
370
+ * @private
371
+ */
372
+ async _handleHelp(chatId, replyOpts) {
373
+ const lines = ['Available Commands', ''];
374
+
375
+ for (const [cmd, desc] of Object.entries(COMMAND_DESCRIPTIONS)) {
376
+ lines.push(`${cmd} -- ${desc}`);
377
+ }
378
+
379
+ await this._sendPlain(chatId, lines.join('\n'), replyOpts);
380
+ }
381
+
382
+ // ---------------------------------------------------------------------------
383
+ // Utilities
384
+ // ---------------------------------------------------------------------------
385
+
386
+ /**
387
+ * Send a plain-text message (no MarkdownV2 parsing).
388
+ *
389
+ * @param {string} chatId
390
+ * @param {string} text
391
+ * @param {object} [replyOpts]
392
+ * @returns {Promise<object[]>}
393
+ * @private
394
+ */
395
+ async _sendPlain(chatId, text, replyOpts = {}) {
396
+ return this._bot.sendMessage(chatId, text, {
397
+ ...replyOpts,
398
+ parse_mode: undefined,
399
+ });
400
+ }
401
+
402
+ /**
403
+ * Format a duration in milliseconds to a human-readable string.
404
+ *
405
+ * @param {number} ms
406
+ * @returns {string} e.g. "2d 5h 23m 12s"
407
+ * @private
408
+ */
409
+ _formatUptime(ms) {
410
+ const seconds = Math.floor(ms / 1_000) % 60;
411
+ const minutes = Math.floor(ms / 60_000) % 60;
412
+ const hours = Math.floor(ms / 3_600_000) % 24;
413
+ const days = Math.floor(ms / 86_400_000);
414
+
415
+ const parts = [];
416
+ if (days > 0) parts.push(`${days}d`);
417
+ if (hours > 0) parts.push(`${hours}h`);
418
+ if (minutes > 0) parts.push(`${minutes}m`);
419
+ parts.push(`${seconds}s`);
420
+
421
+ return parts.join(' ');
422
+ }
423
+
424
+ /**
425
+ * Format a byte count to a human-readable string.
426
+ *
427
+ * @param {number} bytes
428
+ * @returns {string} e.g. "12.34 MB"
429
+ * @private
430
+ */
431
+ _formatBytes(bytes) {
432
+ if (bytes < 1024) return `${bytes} B`;
433
+ if (bytes < 1_048_576) return `${(bytes / 1024).toFixed(1)} KB`;
434
+ if (bytes < 1_073_741_824) return `${(bytes / 1_048_576).toFixed(1)} MB`;
435
+ return `${(bytes / 1_073_741_824).toFixed(2)} GB`;
436
+ }
437
+ }
438
+
439
+ export { CommandRouter, COMMAND_DESCRIPTIONS };
440
+ export default CommandRouter;