multis 0.1.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,712 @@
1
+ const { logAudit } = require('../governance/audit');
2
+ const { addAllowedUser, isOwner } = require('../config');
3
+ const { execCommand, readFile, listSkills } = require('../skills/executor');
4
+ const { DocumentIndexer } = require('../indexer/index');
5
+ const { createLLMClient } = require('../llm/client');
6
+ const { buildRAGPrompt, buildMemorySystemPrompt } = require('../llm/prompts');
7
+ const { getMemoryManager } = require('../memory/manager');
8
+ const { runCapture } = require('../memory/capture');
9
+
10
+ /**
11
+ * Check if a user is paired (allowed).
12
+ * Works with both ctx (Telegram) and Message objects.
13
+ */
14
+ function isPaired(msgOrCtx, config) {
15
+ // Beeper: self-sent messages are always trusted (already filtered by platform)
16
+ if (msgOrCtx.isSelf) return true;
17
+ const userId = msgOrCtx.senderId !== undefined ? msgOrCtx.senderId : msgOrCtx.from?.id;
18
+ return config.allowed_users.includes(userId);
19
+ }
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Platform-agnostic handlers (work with Message + Platform)
23
+ // ---------------------------------------------------------------------------
24
+
25
+ /**
26
+ * Main message dispatcher for all platforms.
27
+ * Takes a normalized Message and routes to the appropriate handler.
28
+ */
29
+ function createMessageRouter(config) {
30
+ const indexer = new DocumentIndexer();
31
+ const memoryManagers = new Map();
32
+
33
+ // Memory config defaults
34
+ const memCfg = {
35
+ recent_window: config.memory?.recent_window || 20,
36
+ capture_threshold: config.memory?.capture_threshold || 20,
37
+ ...config.memory
38
+ };
39
+
40
+ // Create LLM client if configured (null if no API key)
41
+ let llm = null;
42
+ try {
43
+ if (config.llm?.apiKey || config.llm?.provider === 'ollama') {
44
+ llm = createLLMClient(config.llm);
45
+ }
46
+ } catch (err) {
47
+ console.warn(`LLM init skipped: ${err.message}`);
48
+ }
49
+
50
+ return async (msg, platform) => {
51
+ // Handle Telegram document uploads
52
+ if (msg._document) {
53
+ await handleDocumentUpload(msg, platform, config, indexer);
54
+ return;
55
+ }
56
+
57
+ // Natural language / business routing (set by platform adapter)
58
+ if (msg.routeAs === 'natural' || msg.routeAs === 'business') {
59
+ if (!isPaired(msg, config)) return;
60
+ await routeAsk(msg, platform, config, indexer, llm, msg.text, memoryManagers, memCfg);
61
+ return;
62
+ }
63
+
64
+ if (!msg.isCommand()) return;
65
+
66
+ const parsed = msg.parseCommand();
67
+ if (!parsed) return;
68
+
69
+ const { command, args } = parsed;
70
+
71
+ // Pairing: /start <code> (Telegram) or //start <code> (Beeper)
72
+ if (command === 'start') {
73
+ await routeStart(msg, platform, config, args);
74
+ return;
75
+ }
76
+
77
+ // Auth check for all other commands
78
+ if (!isPaired(msg, config)) {
79
+ if (msg.platform === 'telegram') {
80
+ await platform.send(msg.chatId, 'You are not paired. Send /start <pairing_code> to pair.');
81
+ }
82
+ return;
83
+ }
84
+
85
+ switch (command) {
86
+ case 'status':
87
+ await routeStatus(msg, platform, config);
88
+ break;
89
+ case 'unpair':
90
+ await routeUnpair(msg, platform, config);
91
+ break;
92
+ case 'exec':
93
+ await routeExec(msg, platform, config, args);
94
+ break;
95
+ case 'read':
96
+ await routeRead(msg, platform, config, args);
97
+ break;
98
+ case 'index':
99
+ await routeIndex(msg, platform, config, indexer, args);
100
+ break;
101
+ case 'search':
102
+ await routeSearch(msg, platform, config, indexer, args);
103
+ break;
104
+ case 'docs':
105
+ await routeDocs(msg, platform, config, indexer);
106
+ break;
107
+ case 'skills':
108
+ await platform.send(msg.chatId, `Available skills:\n${listSkills()}`);
109
+ break;
110
+ case 'ask':
111
+ await routeAsk(msg, platform, config, indexer, llm, args, memoryManagers, memCfg);
112
+ break;
113
+ case 'memory':
114
+ await routeMemory(msg, platform, memoryManagers);
115
+ break;
116
+ case 'forget':
117
+ await routeForget(msg, platform, memoryManagers);
118
+ break;
119
+ case 'remember':
120
+ await routeRemember(msg, platform, memoryManagers, args);
121
+ break;
122
+ case 'mode':
123
+ await routeMode(msg, platform, config, args);
124
+ break;
125
+ case 'help':
126
+ await routeHelp(msg, platform, config);
127
+ break;
128
+ default:
129
+ // Telegram plain text (no recognized command) → implicit ask
130
+ if (msg.platform === 'telegram' && !msg.text.startsWith('/')) {
131
+ await routeAsk(msg, platform, config, indexer, llm, msg.text, memoryManagers, memCfg);
132
+ }
133
+ break;
134
+ }
135
+ };
136
+ }
137
+
138
+ async function routeStart(msg, platform, config, code) {
139
+ const userId = msg.senderId;
140
+ const username = msg.senderName;
141
+
142
+ if (isPaired(msg, config)) {
143
+ await platform.send(msg.chatId, `Welcome back, ${username}! You're already paired.`);
144
+ logAudit({ action: 'start', user_id: userId, username, status: 'already_paired' });
145
+ return;
146
+ }
147
+
148
+ // On Telegram, also check deep link payload
149
+ if (msg.platform === 'telegram' && msg.raw?.startPayload) {
150
+ code = code || msg.raw.startPayload;
151
+ }
152
+
153
+ if (!code) {
154
+ const prefix = msg.platform === 'telegram' ? '/' : '//';
155
+ await platform.send(msg.chatId, `Send: ${prefix}start <pairing_code>`);
156
+ logAudit({ action: 'start', user_id: userId, username, status: 'no_code' });
157
+ return;
158
+ }
159
+
160
+ if (code.toUpperCase() === config.pairing_code.toUpperCase()) {
161
+ addAllowedUser(userId);
162
+ config.allowed_users.push(userId);
163
+ if (!config.owner_id) config.owner_id = userId;
164
+ const role = config.owner_id === userId ? 'owner' : 'user';
165
+ await platform.send(msg.chatId, `Paired successfully as ${role}! Welcome, ${username}.`);
166
+ logAudit({ action: 'pair', user_id: userId, username, status: 'success', platform: msg.platform });
167
+ } else {
168
+ await platform.send(msg.chatId, 'Invalid pairing code. Try again.');
169
+ logAudit({ action: 'pair', user_id: userId, username, status: 'invalid_code' });
170
+ }
171
+ }
172
+
173
+ async function routeStatus(msg, platform, config) {
174
+ const owner = isOwner(msg.senderId, config);
175
+ const info = [
176
+ 'multis bot v0.1.0',
177
+ `Platform: ${msg.platform}`,
178
+ `Role: ${owner ? 'owner' : 'user'}`,
179
+ `Paired users: ${config.allowed_users.length}`,
180
+ `LLM provider: ${config.llm.provider}`,
181
+ `Governance: ${config.governance.enabled ? 'enabled' : 'disabled'}`
182
+ ];
183
+ await platform.send(msg.chatId, info.join('\n'));
184
+ }
185
+
186
+ async function routeUnpair(msg, platform, config) {
187
+ const userId = msg.senderId;
188
+ config.allowed_users = config.allowed_users.filter(id => id !== userId);
189
+ const { saveConfig } = require('../config');
190
+ saveConfig(config);
191
+
192
+ const prefix = msg.platform === 'telegram' ? '/' : '//';
193
+ await platform.send(msg.chatId, `Unpaired. Send ${prefix}start <code> to pair again.`);
194
+ logAudit({ action: 'unpair', user_id: userId, status: 'success' });
195
+ }
196
+
197
+ async function routeExec(msg, platform, config, command) {
198
+ if (!isOwner(msg.senderId, config)) {
199
+ await platform.send(msg.chatId, 'Owner only command.');
200
+ return;
201
+ }
202
+
203
+ if (!command) {
204
+ const prefix = msg.platform === 'telegram' ? '/' : '//';
205
+ await platform.send(msg.chatId, `Usage: ${prefix}exec <command>`);
206
+ return;
207
+ }
208
+
209
+ const result = execCommand(command, msg.senderId);
210
+
211
+ if (result.denied) {
212
+ await platform.send(msg.chatId, `Denied: ${result.reason}`);
213
+ return;
214
+ }
215
+ if (result.needsConfirmation) {
216
+ await platform.send(msg.chatId, `Command "${command}" requires confirmation.`);
217
+ return;
218
+ }
219
+
220
+ await platform.send(msg.chatId, result.output);
221
+ }
222
+
223
+ async function routeRead(msg, platform, config, filePath) {
224
+ if (!isOwner(msg.senderId, config)) {
225
+ await platform.send(msg.chatId, 'Owner only command.');
226
+ return;
227
+ }
228
+
229
+ if (!filePath) {
230
+ const prefix = msg.platform === 'telegram' ? '/' : '//';
231
+ await platform.send(msg.chatId, `Usage: ${prefix}read <path>`);
232
+ return;
233
+ }
234
+
235
+ const result = readFile(filePath, msg.senderId);
236
+
237
+ if (result.denied) {
238
+ await platform.send(msg.chatId, `Denied: ${result.reason}`);
239
+ return;
240
+ }
241
+
242
+ await platform.send(msg.chatId, result.output);
243
+ }
244
+
245
+ async function routeIndex(msg, platform, config, indexer, filePath) {
246
+ if (!isOwner(msg.senderId, config)) {
247
+ await platform.send(msg.chatId, 'Owner only command.');
248
+ return;
249
+ }
250
+
251
+ if (!filePath) {
252
+ const prefix = msg.platform === 'telegram' ? '/' : '//';
253
+ await platform.send(msg.chatId, `Usage: ${prefix}index <path>`);
254
+ return;
255
+ }
256
+
257
+ const expanded = filePath.replace(/^~/, process.env.HOME || process.env.USERPROFILE);
258
+
259
+ try {
260
+ await platform.send(msg.chatId, `Indexing: ${filePath}...`);
261
+ const count = await indexer.indexFile(expanded);
262
+ await platform.send(msg.chatId, `Indexed ${count} chunks from ${filePath}`);
263
+ } catch (err) {
264
+ await platform.send(msg.chatId, `Index error: ${err.message}`);
265
+ }
266
+ }
267
+
268
+ async function routeSearch(msg, platform, config, indexer, query) {
269
+ if (!query) {
270
+ const prefix = msg.platform === 'telegram' ? '/' : '//';
271
+ await platform.send(msg.chatId, `Usage: ${prefix}search <query>`);
272
+ return;
273
+ }
274
+
275
+ const results = indexer.search(query, 5);
276
+
277
+ if (results.length === 0) {
278
+ await platform.send(msg.chatId, 'No results found.');
279
+ return;
280
+ }
281
+
282
+ const formatted = results.map((r, i) => {
283
+ const path = r.sectionPath.join(' > ') || r.name;
284
+ const preview = r.content.slice(0, 200).replace(/\n/g, ' ');
285
+ return `${i + 1}. [${r.documentType}] ${path}\n${preview}...`;
286
+ });
287
+
288
+ await platform.send(msg.chatId, formatted.join('\n\n'));
289
+ logAudit({ action: 'search', user_id: msg.senderId, query, results: results.length });
290
+ }
291
+
292
+ async function routeDocs(msg, platform, config, indexer) {
293
+ const stats = indexer.getStats();
294
+ const lines = [
295
+ `Indexed documents: ${stats.indexedFiles}`,
296
+ `Total chunks: ${stats.totalChunks}`
297
+ ];
298
+ for (const [type, count] of Object.entries(stats.byType)) {
299
+ lines.push(` ${type}: ${count} chunks`);
300
+ }
301
+ await platform.send(msg.chatId, lines.join('\n') || 'No documents indexed yet.');
302
+ }
303
+
304
+ async function routeAsk(msg, platform, config, indexer, llm, question, memoryManagers, memCfg) {
305
+ if (!question) {
306
+ const prefix = msg.platform === 'telegram' ? '/' : '//';
307
+ await platform.send(msg.chatId, `Usage: ${prefix}ask <question>`);
308
+ return;
309
+ }
310
+
311
+ if (!llm) {
312
+ await platform.send(msg.chatId, 'LLM not configured. Set an API key in ~/.multis/config.json or .env');
313
+ return;
314
+ }
315
+
316
+ const mem = memoryManagers ? getMemoryManager(memoryManagers, msg.chatId) : null;
317
+
318
+ try {
319
+ // Record user message
320
+ if (mem) {
321
+ mem.appendMessage('user', question);
322
+ mem.appendToLog('user', question);
323
+ }
324
+
325
+ // Search for relevant documents
326
+ const chunks = indexer.search(question, 5);
327
+
328
+ // Build messages array from recent conversation
329
+ const recent = mem ? mem.loadRecent() : [];
330
+ const memoryMd = mem ? mem.loadMemory() : '';
331
+ const system = buildMemorySystemPrompt(memoryMd, chunks);
332
+
333
+ // Build messages array: recent history (excluding the just-appended user msg if already there)
334
+ // Recent already includes the current user message from appendMessage above
335
+ const messages = recent.map(m => ({ role: m.role, content: m.content }));
336
+
337
+ // If no recent history (no memory manager), fall back to single-message
338
+ let answer;
339
+ if (messages.length > 0) {
340
+ answer = await llm.generateWithMessages(messages, { system });
341
+ } else {
342
+ const ragPrompt = buildRAGPrompt(question, chunks);
343
+ answer = await llm.generate(ragPrompt.user, { system: ragPrompt.system });
344
+ }
345
+
346
+ await platform.send(msg.chatId, answer);
347
+
348
+ // Record assistant response
349
+ if (mem) {
350
+ mem.appendMessage('assistant', answer);
351
+ mem.appendToLog('assistant', answer);
352
+ }
353
+
354
+ logAudit({ action: 'ask', user_id: msg.senderId, question, chunks: chunks.length, routeAs: msg.routeAs });
355
+
356
+ // Fire-and-forget capture if threshold reached
357
+ if (mem && memCfg && mem.shouldCapture(memCfg.capture_threshold)) {
358
+ runCapture(msg.chatId, mem, llm, indexer, { keepLast: 5 }).catch(err => {
359
+ console.error(`[capture] Background error: ${err.message}`);
360
+ });
361
+ }
362
+ } catch (err) {
363
+ await platform.send(msg.chatId, `LLM error: ${err.message}`);
364
+ }
365
+ }
366
+
367
+ async function routeMemory(msg, platform, memoryManagers) {
368
+ const mem = getMemoryManager(memoryManagers, msg.chatId);
369
+ const memory = mem.loadMemory();
370
+ if (!memory.trim()) {
371
+ await platform.send(msg.chatId, 'No memory notes for this chat yet.');
372
+ return;
373
+ }
374
+ await platform.send(msg.chatId, `Memory notes:\n\n${memory}`);
375
+ }
376
+
377
+ async function routeForget(msg, platform, memoryManagers) {
378
+ const mem = getMemoryManager(memoryManagers, msg.chatId);
379
+ mem.clearMemory();
380
+ await platform.send(msg.chatId, 'Memory cleared for this chat.');
381
+ logAudit({ action: 'forget', user_id: msg.senderId, chatId: msg.chatId });
382
+ }
383
+
384
+ async function routeRemember(msg, platform, memoryManagers, note) {
385
+ if (!note) {
386
+ const prefix = msg.platform === 'telegram' ? '/' : '//';
387
+ await platform.send(msg.chatId, `Usage: ${prefix}remember <note>`);
388
+ return;
389
+ }
390
+ const mem = getMemoryManager(memoryManagers, msg.chatId);
391
+ mem.appendMemory(note);
392
+ await platform.send(msg.chatId, 'Noted.');
393
+ logAudit({ action: 'remember', user_id: msg.senderId, chatId: msg.chatId, note });
394
+ }
395
+
396
+ async function routeMode(msg, platform, config, mode) {
397
+ if (!mode || !['personal', 'business'].includes(mode.trim().toLowerCase())) {
398
+ const prefix = msg.platform === 'telegram' ? '/' : '//';
399
+ await platform.send(msg.chatId, `Usage: ${prefix}mode <personal|business>`);
400
+ return;
401
+ }
402
+
403
+ mode = mode.trim().toLowerCase();
404
+ if (!config.platforms) config.platforms = {};
405
+ if (!config.platforms.beeper) config.platforms.beeper = {};
406
+ if (!config.platforms.beeper.chat_modes) config.platforms.beeper.chat_modes = {};
407
+
408
+ config.platforms.beeper.chat_modes[msg.chatId] = mode;
409
+ const { saveConfig } = require('../config');
410
+ saveConfig(config);
411
+
412
+ await platform.send(msg.chatId, `Chat mode set to: ${mode}`);
413
+ logAudit({ action: 'mode', user_id: msg.senderId, chatId: msg.chatId, mode });
414
+ }
415
+
416
+ async function routeHelp(msg, platform, config) {
417
+ const prefix = msg.platform === 'telegram' ? '/' : '//';
418
+ const cmds = [
419
+ 'multis commands:',
420
+ `${prefix}ask <question> - Ask about indexed documents`,
421
+ `${prefix}status - Bot info`,
422
+ `${prefix}search <query> - Search indexed documents`,
423
+ `${prefix}docs - Show indexing stats`,
424
+ `${prefix}memory - Show conversation memory`,
425
+ `${prefix}remember <note> - Save a note to memory`,
426
+ `${prefix}forget - Clear conversation memory`,
427
+ `${prefix}mode <personal|business> - Set chat mode (Beeper)`,
428
+ `${prefix}skills - List available skills`,
429
+ `${prefix}unpair - Remove pairing`,
430
+ `${prefix}help - This message`,
431
+ '',
432
+ 'Plain text messages are treated as questions.'
433
+ ];
434
+ if (isOwner(msg.senderId, config)) {
435
+ cmds.splice(1, 0,
436
+ `${prefix}exec <cmd> - Run a shell command (owner)`,
437
+ `${prefix}read <path> - Read a file or directory (owner)`,
438
+ `${prefix}index <path> - Index a document (owner)`,
439
+ 'Send a file to index it (owner, Telegram only)'
440
+ );
441
+ }
442
+ await platform.send(msg.chatId, cmds.join('\n'));
443
+ }
444
+
445
+ async function handleDocumentUpload(msg, platform, config, indexer) {
446
+ if (!isPaired(msg, config)) return;
447
+ if (!isOwner(msg.senderId, config)) {
448
+ await platform.send(msg.chatId, 'Owner only. Documents not accepted from non-owners.');
449
+ return;
450
+ }
451
+
452
+ const doc = msg._document;
453
+ if (!doc) return;
454
+
455
+ const filename = doc.file_name || 'unknown';
456
+ const ext = filename.split('.').pop().toLowerCase();
457
+ const supported = ['pdf', 'docx', 'md', 'txt'];
458
+
459
+ if (!supported.includes(ext)) {
460
+ await platform.send(msg.chatId, `Unsupported file type: .${ext}\nSupported: ${supported.join(', ')}`);
461
+ return;
462
+ }
463
+
464
+ try {
465
+ await platform.send(msg.chatId, `Downloading and indexing: ${filename}...`);
466
+ const fileLink = await msg._telegram.getFileLink(doc.file_id);
467
+ const response = await fetch(fileLink.href);
468
+ const buffer = Buffer.from(await response.arrayBuffer());
469
+
470
+ const count = await indexer.indexBuffer(buffer, filename);
471
+ await platform.send(msg.chatId, `Indexed ${count} chunks from ${filename}`);
472
+ logAudit({ action: 'index_upload', user_id: msg.senderId, filename, chunks: count });
473
+ } catch (err) {
474
+ await platform.send(msg.chatId, `Index error: ${err.message}`);
475
+ logAudit({ action: 'index_error', user_id: msg.senderId, filename, error: err.message });
476
+ }
477
+ }
478
+
479
+ // ---------------------------------------------------------------------------
480
+ // Legacy Telegraf-style handlers (kept for backward compat, delegates to above)
481
+ // ---------------------------------------------------------------------------
482
+
483
+ function handleStart(config) {
484
+ return (ctx) => {
485
+ const userId = ctx.from.id;
486
+ const username = ctx.from.username || ctx.from.first_name;
487
+
488
+ if (isPaired(ctx, config)) {
489
+ ctx.reply(`Welcome back, ${username}! You're already paired. Send me any message.`);
490
+ logAudit({ action: 'start', user_id: userId, username, status: 'already_paired' });
491
+ return;
492
+ }
493
+
494
+ const text = ctx.message.text || '';
495
+ const parts = text.split(/\s+/);
496
+ const code = ctx.startPayload || parts[1];
497
+
498
+ if (!code) {
499
+ ctx.reply('Send: /start <pairing_code>\nOr use deep link: t.me/multis02bot?start=<code>');
500
+ logAudit({ action: 'start', user_id: userId, username, status: 'no_code' });
501
+ return;
502
+ }
503
+
504
+ if (code.toUpperCase() === config.pairing_code.toUpperCase()) {
505
+ addAllowedUser(userId);
506
+ config.allowed_users.push(userId);
507
+ if (!config.owner_id) config.owner_id = userId;
508
+ const role = config.owner_id === userId ? 'owner' : 'user';
509
+ ctx.reply(`Paired successfully as ${role}! Welcome, ${username}.`);
510
+ logAudit({ action: 'pair', user_id: userId, username, status: 'success' });
511
+ } else {
512
+ ctx.reply('Invalid pairing code. Try again.');
513
+ logAudit({ action: 'pair', user_id: userId, username, status: 'invalid_code', code_given: code });
514
+ }
515
+ };
516
+ }
517
+
518
+ function handleStatus(config) {
519
+ return (ctx) => {
520
+ if (!isPaired(ctx, config)) return;
521
+ const owner = isOwner(ctx.from.id, config);
522
+ const info = [
523
+ 'multis bot v0.1.0',
524
+ `Role: ${owner ? 'owner' : 'user'}`,
525
+ `Paired users: ${config.allowed_users.length}`,
526
+ `LLM provider: ${config.llm.provider}`,
527
+ `Governance: ${config.governance.enabled ? 'enabled' : 'disabled'}`
528
+ ];
529
+ ctx.reply(info.join('\n'));
530
+ };
531
+ }
532
+
533
+ function handleUnpair(config) {
534
+ return (ctx) => {
535
+ const userId = ctx.from.id;
536
+ if (!isPaired(ctx, config)) return;
537
+ config.allowed_users = config.allowed_users.filter(id => id !== userId);
538
+ const { saveConfig } = require('../config');
539
+ saveConfig(config);
540
+ ctx.reply('Unpaired. Send /start <code> to pair again.');
541
+ logAudit({ action: 'unpair', user_id: userId, status: 'success' });
542
+ };
543
+ }
544
+
545
+ function handleExec(config) {
546
+ return (ctx) => {
547
+ if (!isPaired(ctx, config)) return;
548
+ if (!isOwner(ctx.from.id, config)) { ctx.reply('Owner only command.'); return; }
549
+ const text = ctx.message.text || '';
550
+ const command = text.replace(/^\/exec\s*/, '').trim();
551
+ if (!command) { ctx.reply('Usage: /exec <command>'); return; }
552
+ const result = execCommand(command, ctx.from.id);
553
+ if (result.denied) { ctx.reply(`Denied: ${result.reason}`); return; }
554
+ if (result.needsConfirmation) { ctx.reply(`Command "${command}" requires confirmation.`); return; }
555
+ ctx.reply(result.output);
556
+ };
557
+ }
558
+
559
+ function handleRead(config) {
560
+ return (ctx) => {
561
+ if (!isPaired(ctx, config)) return;
562
+ if (!isOwner(ctx.from.id, config)) { ctx.reply('Owner only command.'); return; }
563
+ const text = ctx.message.text || '';
564
+ const filePath = text.replace(/^\/read\s*/, '').trim();
565
+ if (!filePath) { ctx.reply('Usage: /read <path>'); return; }
566
+ const result = readFile(filePath, ctx.from.id);
567
+ if (result.denied) { ctx.reply(`Denied: ${result.reason}`); return; }
568
+ ctx.reply(result.output);
569
+ };
570
+ }
571
+
572
+ function handleSkills(config) {
573
+ return (ctx) => {
574
+ if (!isPaired(ctx, config)) return;
575
+ ctx.reply(`Available skills:\n${listSkills()}`);
576
+ };
577
+ }
578
+
579
+ function handleHelp(config) {
580
+ return (ctx) => {
581
+ if (!isPaired(ctx, config)) return;
582
+ const cmds = [
583
+ 'multis commands:',
584
+ '/ask <question> - Ask about indexed documents',
585
+ '/status - Bot info',
586
+ '/search <query> - Search indexed documents',
587
+ '/docs - Show indexing stats',
588
+ '/skills - List available skills',
589
+ '/unpair - Remove pairing',
590
+ '/help - This message',
591
+ '',
592
+ 'Plain text messages are treated as questions.'
593
+ ];
594
+ if (isOwner(ctx.from.id, config)) {
595
+ cmds.splice(1, 0,
596
+ '/exec <cmd> - Run a shell command (owner)',
597
+ '/read <path> - Read a file or directory (owner)',
598
+ '/index <path> - Index a document (owner)',
599
+ 'Send a file to index it (owner)'
600
+ );
601
+ }
602
+ ctx.reply(cmds.join('\n'));
603
+ };
604
+ }
605
+
606
+ function handleIndex(config, indexer) {
607
+ return async (ctx) => {
608
+ if (!isPaired(ctx, config)) return;
609
+ if (!isOwner(ctx.from.id, config)) { ctx.reply('Owner only command.'); return; }
610
+ const text = ctx.message.text || '';
611
+ const filePath = text.replace(/^\/index\s*/, '').trim();
612
+ if (!filePath) { ctx.reply('Usage: /index <path>'); return; }
613
+ const expanded = filePath.replace(/^~/, process.env.HOME || process.env.USERPROFILE);
614
+ try {
615
+ ctx.reply(`Indexing: ${filePath}...`);
616
+ const count = await indexer.indexFile(expanded);
617
+ ctx.reply(`Indexed ${count} chunks from ${filePath}`);
618
+ } catch (err) {
619
+ ctx.reply(`Index error: ${err.message}`);
620
+ }
621
+ };
622
+ }
623
+
624
+ function handleDocument(config, indexer) {
625
+ return async (ctx) => {
626
+ if (!isPaired(ctx, config)) return;
627
+ if (!isOwner(ctx.from.id, config)) { ctx.reply('Owner only.'); return; }
628
+ const doc = ctx.message.document;
629
+ if (!doc) return;
630
+ const filename = doc.file_name || 'unknown';
631
+ const ext = filename.split('.').pop().toLowerCase();
632
+ const supported = ['pdf', 'docx', 'md', 'txt'];
633
+ if (!supported.includes(ext)) { ctx.reply(`Unsupported: .${ext}`); return; }
634
+ try {
635
+ ctx.reply(`Downloading and indexing: ${filename}...`);
636
+ const fileLink = await ctx.telegram.getFileLink(doc.file_id);
637
+ const response = await fetch(fileLink.href);
638
+ const buffer = Buffer.from(await response.arrayBuffer());
639
+ const count = await indexer.indexBuffer(buffer, filename);
640
+ ctx.reply(`Indexed ${count} chunks from ${filename}`);
641
+ logAudit({ action: 'index_upload', user_id: ctx.from.id, filename, chunks: count });
642
+ } catch (err) {
643
+ ctx.reply(`Index error: ${err.message}`);
644
+ logAudit({ action: 'index_error', user_id: ctx.from.id, filename, error: err.message });
645
+ }
646
+ };
647
+ }
648
+
649
+ function handleSearch(config, indexer) {
650
+ return (ctx) => {
651
+ if (!isPaired(ctx, config)) return;
652
+ const text = ctx.message.text || '';
653
+ const query = text.replace(/^\/search\s*/, '').trim();
654
+ if (!query) { ctx.reply('Usage: /search <query>'); return; }
655
+ const results = indexer.search(query, 5);
656
+ if (results.length === 0) { ctx.reply('No results found.'); return; }
657
+ const formatted = results.map((r, i) => {
658
+ const p = r.sectionPath.join(' > ') || r.name;
659
+ const preview = r.content.slice(0, 200).replace(/\n/g, ' ');
660
+ return `${i + 1}. [${r.documentType}] ${p}\n${preview}...`;
661
+ });
662
+ ctx.reply(formatted.join('\n\n'));
663
+ logAudit({ action: 'search', user_id: ctx.from.id, query, results: results.length });
664
+ };
665
+ }
666
+
667
+ function handleDocs(config, indexer) {
668
+ return (ctx) => {
669
+ if (!isPaired(ctx, config)) return;
670
+ const stats = indexer.getStats();
671
+ const lines = [`Indexed documents: ${stats.indexedFiles}`, `Total chunks: ${stats.totalChunks}`];
672
+ for (const [type, count] of Object.entries(stats.byType)) {
673
+ lines.push(` ${type}: ${count} chunks`);
674
+ }
675
+ ctx.reply(lines.join('\n') || 'No documents indexed yet.');
676
+ };
677
+ }
678
+
679
+ function handleMessage(config) {
680
+ return (ctx) => {
681
+ const userId = ctx.from.id;
682
+ const username = ctx.from.username || ctx.from.first_name;
683
+ if (!isPaired(ctx, config)) {
684
+ ctx.reply('You are not paired. Send /start <pairing_code> to pair.');
685
+ logAudit({ action: 'message', user_id: userId, username, status: 'unpaired' });
686
+ return;
687
+ }
688
+ const text = ctx.message.text;
689
+ if (text.startsWith('/')) return;
690
+ logAudit({ action: 'message', user_id: userId, username, text });
691
+ ctx.reply(`Echo: ${text}`);
692
+ };
693
+ }
694
+
695
+ module.exports = {
696
+ // Platform-agnostic
697
+ createMessageRouter,
698
+ // Legacy Telegraf handlers
699
+ handleStart,
700
+ handleStatus,
701
+ handleUnpair,
702
+ handleExec,
703
+ handleRead,
704
+ handleIndex,
705
+ handleDocument,
706
+ handleSearch,
707
+ handleDocs,
708
+ handleSkills,
709
+ handleHelp,
710
+ handleMessage,
711
+ isPaired
712
+ };