natureco-cli 5.4.21 → 5.5.1

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.
@@ -1,1977 +1,2107 @@
1
- const chalk = require('chalk');
2
- const fs = require('fs');
3
- const path = require('path');
4
- const os = require('os');
5
- const { spawn, execSync } = require('child_process');
6
- const pino = require('pino');
7
- const { loadBaileys } = require('../utils/baileys');
8
- const { ApiError } = require('../utils/errors');
9
-
10
- const PID_FILE = path.join(os.homedir(), '.natureco', 'gateway.pid');
11
- const LOG_FILE = path.join(os.homedir(), '.natureco', 'gateway.log');
12
-
13
- // Silent logger for Baileys
14
- const silentLogger = {
15
- level: 'silent',
16
- trace: () => {},
17
- debug: () => {},
18
- info: () => {},
19
- warn: () => {},
20
- error: () => {},
21
- fatal: () => {},
22
- child: () => silentLogger
23
- };
24
-
25
- // Create Baileys logger — real pino in worker mode, silent otherwise
26
- let _baileysLogger = null;
27
- function getBaileysLogger() {
28
- if (!_baileysLogger) {
29
- const isWorker = process.argv.includes('--gateway-worker');
30
- _baileysLogger = isWorker
31
- ? pino({ level: 'info', transport: { target: 'pino/file', options: { destination: LOG_FILE } } })
32
- : silentLogger;
33
- }
34
- return _baileysLogger;
35
- }
36
-
37
- // Log helper - only writes to console in worker, file writing handled by stdio redirect
38
- function log(module, message, color = 'white') {
39
- const timestamp = new Date().toISOString().replace('T', ' ').substring(0, 19);
40
- const logLine = `[${timestamp}] [${module}] ${message}`;
41
-
42
- // Console output with color (will be redirected to file by parent process)
43
- const colorFn = chalk[color] || chalk.white;
44
- console.log(colorFn(logLine));
45
-
46
- // Note: File writing removed - parent process redirects stdout/stderr to log file
47
- // This prevents duplicate log entries
48
- }
49
-
50
- // Number matching helper - compare last 10 digits only
51
- function numberMatches(incoming, allowed) {
52
- const clean = (n) => n.replace(/\D/g, '').slice(-10);
53
- return clean(incoming) === clean(allowed);
54
- }
55
-
56
- async function gatewayServer(action) {
57
- // Check if running as background worker
58
- if (process.argv.includes('--gateway-worker')) {
59
- process.stdin.resume();
60
- process.title = 'natureco-gateway';
61
- log('gateway', 'Worker process started, PID: ' + process.pid);
62
- return runGatewayWorker();
63
- }
64
-
65
- if (!action || action === 'start') {
66
- return startGateway();
67
- }
68
-
69
- if (action === 'stop') {
70
- return stopGateway();
71
- }
72
-
73
- if (action === 'status') {
74
- return statusGateway();
75
- }
76
-
77
- if (action === 'logs') {
78
- return showLogs();
79
- }
80
-
81
- console.log(chalk.red('\n❌ Unknown action\n'));
82
- console.log(chalk.gray('Available actions: start, stop, status, logs\n'));
83
- process.exit(1);
84
- }
85
-
86
- async function startGateway() {
87
- // Check if already running
88
- if (fs.existsSync(PID_FILE)) {
89
- const pid = fs.readFileSync(PID_FILE, 'utf-8').trim();
90
- try {
91
- process.kill(pid, 0);
92
- console.log(chalk.yellow('\n⚠️ Gateway already running\n'));
93
- console.log(chalk.gray(`PID: ${pid}`));
94
- console.log(chalk.gray('Stop with: natureco gateway stop\n'));
95
- return;
96
- } catch {
97
- // Process not running, remove stale PID file
98
- fs.unlinkSync(PID_FILE);
99
- }
100
- }
101
-
102
- console.log(chalk.green('\n🚀 Starting NatureCo Gateway...\n'));
103
-
104
- // Ensure log directory exists
105
- const dir = path.dirname(LOG_FILE);
106
- if (!fs.existsSync(dir)) {
107
- fs.mkdirSync(dir, { recursive: true });
108
- }
109
-
110
- // Open log file for stdio redirection
111
- const logFd = fs.openSync(LOG_FILE, 'a');
112
-
113
- // Start as detached background process
114
- const child = spawn(process.execPath, [require.resolve('./gateway-server'), '--gateway-worker'], {
115
- detached: true,
116
- stdio: ['ignore', logFd, logFd],
117
- windowsHide: true,
118
- cwd: os.homedir(),
119
- });
120
-
121
- child.unref();
122
-
123
- // Save PID
124
- if (!fs.existsSync(dir)) {
125
- fs.mkdirSync(dir, { recursive: true });
126
- }
127
- fs.writeFileSync(PID_FILE, String(child.pid), 'utf-8');
128
-
129
- console.log(chalk.green('✅ Gateway started in background'));
130
- console.log(chalk.cyan('PID:'), chalk.white(child.pid));
131
- console.log(chalk.cyan('Logs:'), chalk.white(LOG_FILE));
132
- console.log(chalk.gray('\nView logs: natureco gateway logs'));
133
- console.log(chalk.gray('Check status: natureco gateway status'));
134
- console.log(chalk.gray('Stop gateway: natureco gateway stop\n'));
135
-
136
- process.exit(0);
137
- }
138
-
139
- async function runGatewayWorker() {
140
- // This runs in the background
141
- const pkg = require('../../package.json');
142
- log('gateway', `Starting NatureCo Gateway v${pkg.version}...`, 'green');
143
-
144
- // Load config
145
- const { getConfig } = require('../utils/config');
146
- const config = getConfig();
147
-
148
- if (!config || !config.providerUrl) {
149
- log('gateway', 'Setup yapılmamış. Run "natureco setup" first.', 'red');
150
- process.exit(1);
151
- }
152
-
153
- // Store provider instances globally for HTTP endpoint
154
- global.whatsappSock = null;
155
- global.telegramBot = null;
156
- global.signalProvider = null;
157
-
158
- // Start WhatsApp if configured
159
- if (config.whatsappConnected && config.whatsappBotId) {
160
- const sessionDir = path.join(os.homedir(), '.natureco', 'whatsapp-sessions', config.whatsappBotId);
161
-
162
- if (fs.existsSync(sessionDir)) {
163
- const phone = config.whatsappPhone?.split(':')[0].replace('@s.whatsapp.net', '') || 'unknown';
164
- log('whatsapp', `starting provider (+${phone})`, 'cyan');
165
- startWhatsAppProvider(sessionDir, config);
166
- } else {
167
- log('whatsapp', 'session not found, skipping', 'yellow');
168
- }
169
- } else {
170
- log('whatsapp', 'not configured, skipping', 'gray');
171
- }
172
-
173
- // Start Telegram if configured
174
- if (config.telegramToken && config.telegramBotId) {
175
- log('telegram', 'starting bot...', 'cyan');
176
- startTelegramProvider(config);
177
- } else {
178
- log('telegram', 'not configured, skipping', 'gray');
179
- }
180
-
181
- // Start Signal if configured
182
- if (config.signalBotId && config.signalHttpUrl) {
183
- log('signal', `starting provider (${config.signalAccount || config.signalHttpUrl})`, 'cyan');
184
- startSignalProvider(config);
185
- } else if (config.signalBotId) {
186
- log('signal', 'not configured (no HTTP URL), skipping', 'gray');
187
- } else {
188
- log('signal', 'not configured, skipping', 'gray');
189
- }
190
- // Start IRC if configured
191
- if (config.ircBotId && config.ircHost && config.ircNick) {
192
- log('irc', `connecting to ${config.ircNick} @ ${config.ircHost}:${config.ircPort}`, 'cyan');
193
- startIrcProvider(config);
194
- } else if (config.ircBotId) {
195
- log('irc', 'not configured (missing host or nick), skipping', 'gray');
196
- } else {
197
- log('irc', 'not configured, skipping', 'gray');
198
- }
199
- // Start Mattermost if configured
200
- if (config.mattermostBotId && config.mattermostBaseUrl && config.mattermostToken) {
201
- log('mattermost', `connecting to ${config.mattermostBaseUrl}`, 'cyan');
202
- startMattermostProvider(config);
203
- } else if (config.mattermostBotId) {
204
- log('mattermost', 'not configured (missing URL or token), skipping', 'gray');
205
- } else {
206
- log('mattermost', 'not configured, skipping', 'gray');
207
- }
208
- // Start iMessage if configured
209
- if (config.imessageBotId) {
210
- if (process.platform === 'darwin') {
211
- log('imessage', 'starting provider (macOS)', 'cyan');
212
- startImessageProvider(config);
213
- } else {
214
- log('imessage', 'macOS only, skipping', 'yellow');
215
- }
216
- } else {
217
- log('imessage', 'not configured, skipping', 'gray');
218
- }
219
- // Start SMS provider if configured
220
- if (config.smsBotId && config.smsAccountSid && config.smsAuthToken) {
221
- log('sms', `starting provider (${config.smsFromNumber || 'Messaging Service'})`, 'cyan');
222
- startSmsProvider(config);
223
- } else if (config.smsBotId) {
224
- log('sms', 'not configured (missing Account SID), skipping', 'gray');
225
- } else {
226
- log('sms', 'not configured, skipping', 'gray');
227
- }
228
- if (config.webhooks?.length) log('webhooks', `${config.webhooks.length} route(s) configured`, 'gray');
229
-
230
- // Start HTTP server for message sending
231
- startHttpServer();
232
-
233
- // Load and start cron jobs
234
- startCronJobs(config);
235
-
236
- // Health check every 60 seconds
237
- setInterval(async () => {
238
- const alive = fs.existsSync(PID_FILE) && (() => { try { process.kill(process.pid, 0); return true; } catch { return false; } })();
239
- const wsOk = global.gatewayWs && global.gatewayWs.readyState === 1;
240
- if (alive && wsOk) {
241
- log('gateway', 'health check: OK', 'gray');
242
- } else {
243
- log('gateway', `health check: WARN (alive=${alive}, ws=${wsOk})`, 'yellow');
244
- }
245
- }, 60000);
246
-
247
- log('gateway', 'Gateway running in background', 'green');
248
-
249
- // Handle shutdown
250
- const shutdown = () => {
251
- log('gateway', 'Shutting down...', 'yellow');
252
- if (global.signalProvider?.ws) {
253
- try { global.signalProvider.ws.close(); } catch {}
254
- }
255
- if (signalPollingInterval) {
256
- clearInterval(signalPollingInterval);
257
- signalPollingInterval = null;
258
- }
259
- stopIrcProvider();
260
- stopMattermostProvider();
261
- stopImessageProvider();
262
- stopSmsProvider();
263
- if (fs.existsSync(PID_FILE)) {
264
- fs.unlinkSync(PID_FILE);
265
- }
266
- process.exit(0);
267
- };
268
-
269
- process.on('SIGINT', shutdown);
270
- process.on('SIGTERM', shutdown);
271
- }
272
-
273
- async function startWhatsAppProvider(sessionDir, config) {
274
- try {
275
- const { makeWASocket, useMultiFileAuthState, DisconnectReason, fetchLatestBaileysVersion, Browsers } = loadBaileys();
276
-
277
- const { state, saveCreds } = await useMultiFileAuthState(sessionDir);
278
- const { version } = await fetchLatestBaileysVersion();
279
-
280
- // Track last bot reply to prevent infinite loop
281
- let lastBotReply = '';
282
-
283
- const sock = makeWASocket({
284
- version,
285
- auth: state,
286
- printQRInTerminal: false,
287
- logger: getBaileysLogger(),
288
- browser: Browsers.ubuntu('Chrome'),
289
- connectTimeoutMs: 60000,
290
- defaultQueryTimeoutMs: 60000,
291
- keepAliveIntervalMs: 10000,
292
- retryRequestDelayMs: 2000,
293
- });
294
-
295
- // Connection update handler
296
- sock.ev.on('connection.update', async (update) => {
297
- const { connection, lastDisconnect } = update;
298
-
299
- if (connection === 'close') {
300
- const statusCode = lastDisconnect?.error?.output?.statusCode;
301
-
302
- // Remove all event listeners before reconnecting
303
- sock.ev.removeAllListeners('connection.update');
304
- sock.ev.removeAllListeners('messages.upsert');
305
- sock.ev.removeAllListeners('creds.update');
306
-
307
- if (statusCode === 515 || statusCode === 408) {
308
- log('whatsapp', 'connection lost, reconnecting in 10s...', 'yellow');
309
- setTimeout(() => startWhatsAppProvider(sessionDir, config), 10000);
310
- return;
311
- } else if (statusCode === 401) {
312
- log('whatsapp', 'session expired, please reconnect', 'red');
313
- } else {
314
- log('whatsapp', `disconnected (code: ${statusCode}), reconnecting in 10s...`, 'yellow');
315
- setTimeout(() => startWhatsAppProvider(sessionDir, config), 10000);
316
- }
317
- } else if (connection === 'open') {
318
- const phone = sock.user?.id?.split(':')[0].replace('@s.whatsapp.net', '') || 'unknown';
319
- log('whatsapp', `Connected successfully`, 'green');
320
- log('whatsapp', `Listening for inbound messages.`, 'cyan');
321
- log('whatsapp', `Phone: +${phone}`, 'gray');
322
-
323
- // Store socket globally for HTTP endpoint
324
- global.whatsappSock = sock;
325
- }
326
- });
327
-
328
- sock.ev.on('messages.upsert', async ({ messages }) => {
329
- log('whatsapp', `messages.upsert event triggered, ${messages.length} message(s)`, 'gray');
330
-
331
- for (const msg of messages) {
332
- // Log raw message info
333
- log('whatsapp', `Raw message - fromMe: ${msg.key.fromMe}, remoteJid: ${msg.key.remoteJid}`, 'gray');
334
-
335
- const remoteJid = msg.key.remoteJid || '';
336
- const isLID = remoteJid.includes('@lid');
337
-
338
- // Skip bot's own replies (fromMe=true and not LID, or matches last bot reply)
339
- const messageText = msg.message?.conversation ||
340
- msg.message?.extendedTextMessage?.text ||
341
- msg.message?.imageMessage?.caption ||
342
- msg.message?.videoMessage?.caption ||
343
- msg.message?.buttonsResponseMessage?.selectedDisplayText ||
344
- msg.message?.listResponseMessage?.title ||
345
- msg.message?.ephemeralMessage?.message?.conversation ||
346
- msg.message?.viewOnceMessage?.message?.conversation ||
347
- '';
348
-
349
- // Skip if this is the bot's own reply
350
- if (msg.key.fromMe && !isLID) {
351
- log('whatsapp', 'Skipping bot own reply (fromMe=true, not LID)', 'gray');
352
- continue;
353
- }
354
-
355
- // Skip if message matches last bot reply (prevent loop)
356
- if (messageText === lastBotReply && lastBotReply !== '') {
357
- log('whatsapp', 'Skipping duplicate bot reply (matches last sent)', 'gray');
358
- continue;
359
- }
360
-
361
- // Handle LID format (new WhatsApp format)
362
- if (isLID && !msg.key.fromMe) {
363
- log('whatsapp', 'LID format detected, fromMe=false, blocked', 'yellow');
364
- continue;
365
- }
366
-
367
- const sender = remoteJid.split('@')[0].split(':')[0];
368
- const allowedNumbers = config.whatsappAllowedNumbers || [];
369
-
370
- // Log incoming number
371
- log('whatsapp', `Incoming from: +${sender}, allowed: ${JSON.stringify(allowedNumbers)}`, 'gray');
372
-
373
- // Access control - skip if fromMe + LID (own conversation)
374
- if (!(msg.key.fromMe && isLID) && allowedNumbers.length > 0 && !allowedNumbers.some(n => numberMatches(n, sender))) {
375
- log('whatsapp', `blocked message from +${sender} (not in allowed list)`, 'yellow');
376
- continue;
377
- }
378
-
379
- if (!messageText) {
380
- log('whatsapp', `Message without text content. Keys: ${Object.keys(msg.message || {}).join(', ')}`, 'gray');
381
- continue;
382
- }
383
-
384
- const ownNumber = sock.user?.id?.split(':')[0].replace('@s.whatsapp.net', '') || 'unknown';
385
- log('whatsapp', `Inbound message +${sender} -> +${ownNumber} (${messageText.length} chars)`, 'cyan');
386
-
387
- try {
388
- const { sendMessage } = require('../utils/api');
389
- const { getMemoryPrompt, extractMemoryFromMessage, addMemoryEntry } = require('../utils/memory');
390
- const { getConfig } = require('../utils/config');
391
- const cfg = getConfig();
392
-
393
- log('whatsapp', 'Sending to AI provider...', 'cyan');
394
-
395
- // Use WhatsApp-specific conversation ID for persistent history
396
- const conversationId = `whatsapp_${sender.replace(/\D/g, '')}`;
397
-
398
- // Use same botId as terminal for shared memory
399
- const botId = 'universal-provider';
400
- const memoryPrompt = getMemoryPrompt(botId);
401
-
402
- // WhatsApp system prompt with memory
403
- let systemPrompt = `You are a helpful WhatsApp assistant. Keep responses concise and friendly. Use emojis when appropriate. If users ask for file operations or system commands, politely explain that those features are available in the terminal version.`;
404
-
405
- if (memoryPrompt) {
406
- systemPrompt += '\n\n' + memoryPrompt;
407
- }
408
-
409
- const response = await sendMessage(cfg.providerApiKey || cfg.apiKey || '', botId, messageText, conversationId, systemPrompt);
410
- const reply = response?.reply || response?.message || '';
411
-
412
- if (reply) {
413
- log('whatsapp', 'Sending reply...', 'cyan');
414
-
415
- await sock.sendMessage(msg.key.remoteJid, { text: reply });
416
-
417
- // Store last bot reply to prevent loop
418
- lastBotReply = reply;
419
-
420
- log('whatsapp', `Reply sent (${reply.length} chars)`, 'green');
421
-
422
- // Extract and save memory from user message
423
- const memoryEntries = extractMemoryFromMessage(messageText);
424
- for (const entry of memoryEntries) {
425
- addMemoryEntry(botId, entry.key, entry.value);
426
- log('whatsapp', `Memory saved: ${entry.key} = ${entry.value}`, 'gray');
427
- }
428
- } else {
429
- log('whatsapp', 'No reply from provider', 'yellow');
430
- }
431
- } catch (err) {
432
- log('whatsapp', `Provider error: ${err.message}`, 'red');
433
- }
434
- }
435
- });
436
-
437
- sock.ev.on('creds.update', saveCreds);
438
-
439
- } catch (err) {
440
- log('whatsapp', `Failed to start: ${err.message}`, 'red');
441
- log('whatsapp', 'Retrying in 10s...', 'yellow');
442
- setTimeout(() => startWhatsAppProvider(sessionDir, config), 10000);
443
- }
444
- }
445
-
446
- async function startTelegramProvider(config) {
447
- try {
448
- // Lazy load node-telegram-bot-api
449
- let TelegramBot;
450
- try {
451
- TelegramBot = require('node-telegram-bot-api');
452
- } catch (err) {
453
- log('telegram', 'node-telegram-bot-api not installed', 'red');
454
- log('telegram', 'Install with: npm install -g node-telegram-bot-api', 'yellow');
455
- return;
456
- }
457
-
458
- const bot = new TelegramBot(config.telegramToken, { polling: true });
459
-
460
- log('telegram', 'Bot started successfully', 'green');
461
- log('telegram', 'Listening for messages...', 'cyan');
462
-
463
- bot.on('message', async (msg) => {
464
- const chatId = msg.chat.id;
465
- const messageText = msg.text;
466
-
467
- if (!messageText) {
468
- log('telegram', `Message without text from chat ${chatId}`, 'gray');
469
- return;
470
- }
471
-
472
- // Access control - check allowed chats
473
- const allowedChats = config.telegramAllowedChats || [];
474
- if (allowedChats.length > 0 && !allowedChats.includes(String(chatId))) {
475
- log('telegram', `Blocked message from chat ${chatId} (not in allowed list)`, 'yellow');
476
- return;
477
- }
478
-
479
- log('telegram', `Inbound message from chat ${chatId} (${messageText.length} chars)`, 'cyan');
480
-
481
- try {
482
- const { sendMessage } = require('../utils/api');
483
- const { getMemoryPrompt, extractMemoryFromMessage, addMemoryEntry } = require('../utils/memory');
484
- const { getConfig } = require('../utils/config');
485
- const cfg = getConfig();
486
-
487
- log('telegram', 'Sending to AI provider...', 'cyan');
488
-
489
- // Use Telegram-specific conversation ID for persistent history
490
- const conversationId = `telegram_${chatId}`;
491
-
492
- // Use same botId as terminal for shared memory
493
- const botId = 'universal-provider';
494
- const memoryPrompt = getMemoryPrompt(botId);
495
-
496
- // Telegram system prompt with memory
497
- let systemPrompt = `You are a helpful Telegram assistant. Keep responses concise and friendly. Use emojis when appropriate. If users ask for file operations or system commands, politely explain that those features are available in the terminal version.`;
498
-
499
- if (memoryPrompt) {
500
- systemPrompt += '\n\n' + memoryPrompt;
501
- }
502
-
503
- const response = await sendMessage(cfg.providerApiKey || cfg.apiKey || '', botId, messageText, conversationId, systemPrompt);
504
- const reply = response?.reply || response?.message || '';
505
-
506
- if (reply) {
507
- log('telegram', 'Sending reply...', 'cyan');
508
-
509
- await bot.sendMessage(chatId, reply);
510
-
511
- log('telegram', `Reply sent (${reply.length} chars)`, 'green');
512
-
513
- // Extract and save memory from user message
514
- const memoryEntries = extractMemoryFromMessage(messageText);
515
- for (const entry of memoryEntries) {
516
- addMemoryEntry(botId, entry.key, entry.value);
517
- log('telegram', `Memory saved: ${entry.key} = ${entry.value}`, 'gray');
518
- }
519
- } else {
520
- log('telegram', 'No reply from provider', 'yellow');
521
- }
522
- } catch (err) {
523
- log('telegram', `Provider error: ${err.message}`, 'red');
524
-
525
- // Send error message to user
526
- try {
527
- await bot.sendMessage(chatId, '❌ Bir hata oluştu. Lütfen tekrar deneyin.');
528
- } catch (sendErr) {
529
- log('telegram', `Failed to send error message: ${sendErr.message}`, 'red');
530
- }
531
- }
532
- });
533
-
534
- bot.on('polling_error', (error) => {
535
- log('telegram', `Polling error: ${error.message}`, 'red');
536
- });
537
-
538
- // Store bot globally for HTTP endpoint
539
- global.telegramBot = bot;
540
-
541
- } catch (err) {
542
- log('telegram', `Failed to start: ${err.message}`, 'red');
543
- }
544
- }
545
-
546
- async function startSignalProvider(config) {
547
- const baseUrl = config.signalHttpUrl;
548
- const account = config.signalAccount;
549
- const mode = config.signalApiMode || 'auto';
550
-
551
- // Detect API mode
552
- let detectedMode = mode;
553
- if (mode === 'auto') {
554
- try {
555
- const nativeRes = await fetch(`${baseUrl}/api/v1/check`, { signal: AbortSignal.timeout(5000) });
556
- if (nativeRes.ok) {
557
- detectedMode = 'native';
558
- log('signal', 'detected native JSON-RPC API', 'gray');
559
- }
560
- } catch {}
561
- if (detectedMode === 'auto') {
562
- try {
563
- const containerRes = await fetch(`${baseUrl}/v1/about`, { signal: AbortSignal.timeout(5000) });
564
- if (containerRes.ok) {
565
- detectedMode = 'container';
566
- log('signal', 'detected container REST API', 'gray');
567
- }
568
- } catch {}
569
- }
570
- if (detectedMode === 'auto') {
571
- log('signal', 'API not reachable, will retry', 'yellow');
572
- }
573
- }
574
-
575
- if (detectedMode === 'auto' || detectedMode === 'none') {
576
- log('signal', 'API unreachable, will start daemon on demand', 'gray');
577
- return;
578
- }
579
-
580
- global.signalProvider = { baseUrl, account, mode: detectedMode };
581
-
582
- // Start event stream for incoming messages (native mode uses SSE)
583
- if (detectedMode === 'native') {
584
- startSignalEventStream(baseUrl, account, config);
585
- } else if (detectedMode === 'container') {
586
- startSignalWebSocket(baseUrl, account, config);
587
- }
588
-
589
- log('signal', 'provider ready', 'green');
590
- }
591
-
592
- async function startSignalEventStream(baseUrl, account, config) {
593
- const eventsUrl = `${baseUrl}/api/v1/events?account=${encodeURIComponent(account)}`;
594
-
595
- const connect = async () => {
596
- try {
597
- const response = await fetch(eventsUrl, {
598
- signal: AbortSignal.timeout(300000),
599
- headers: { 'Accept': 'text/event-stream' },
600
- });
601
-
602
- if (!response.ok) {
603
- throw new Error(`SSE HTTP ${response.status}`);
604
- }
605
-
606
- log('signal', 'SSE event stream connected', 'cyan');
607
- const reader = response.body.getReader();
608
- const decoder = new TextDecoder();
609
- let buffer = '';
610
-
611
- while (true) {
612
- const { done, value } = await reader.read();
613
- if (done) break;
614
-
615
- buffer += decoder.decode(value, { stream: true });
616
- const lines = buffer.split('\n');
617
- buffer = lines.pop() || '';
618
-
619
- let eventType = '';
620
- let eventData = '';
621
-
622
- for (const line of lines) {
623
- if (line.startsWith('event: ')) {
624
- eventType = line.slice(7).trim();
625
- } else if (line.startsWith('data: ')) {
626
- eventData = line.slice(6).trim();
627
- } else if (line === '' && eventData) {
628
- processSignalEvent(eventType, eventData, config);
629
- eventType = '';
630
- eventData = '';
631
- }
632
- }
633
- }
634
- } catch (err) {
635
- if (err.name === 'AbortError') {
636
- log('signal', 'SSE stream timeout, reconnecting...', 'yellow');
637
- } else {
638
- log('signal', `SSE error: ${err.message}, reconnecting in 10s...`, 'yellow');
639
- }
640
- }
641
-
642
- setTimeout(connect, 10000);
643
- };
644
-
645
- connect();
646
- }
647
-
648
- async function startSignalWebSocket(baseUrl, account, config) {
649
- const wsUrl = baseUrl.replace(/^http/, 'ws') + `/v1/receive/${encodeURIComponent(account)}`;
650
-
651
- const connect = async () => {
652
- try {
653
- let WebSocket;
654
- try {
655
- WebSocket = require('ws');
656
- } catch {
657
- log('signal', 'ws module not available, falling back to polling', 'yellow');
658
- startSignalPolling(baseUrl, account, config);
659
- return;
660
- }
661
-
662
- const ws = new WebSocket(wsUrl);
663
-
664
- ws.on('open', () => {
665
- log('signal', 'WebSocket event stream connected', 'cyan');
666
- });
667
-
668
- ws.on('message', (data) => {
669
- try {
670
- const envelope = JSON.parse(data.toString());
671
- processSignalEnvelope(envelope, config);
672
- } catch (err) {
673
- log('signal', `Failed to parse WS message: ${err.message}`, 'red');
674
- }
675
- });
676
-
677
- ws.on('close', (code) => {
678
- log('signal', `WebSocket closed (code ${code}), reconnecting in 10s...`, 'yellow');
679
- setTimeout(connect, 10000);
680
- });
681
-
682
- ws.on('error', (err) => {
683
- log('signal', `WebSocket error: ${err.message}`, 'red');
684
- ws.close();
685
- });
686
-
687
- global.signalProvider.ws = ws;
688
-
689
- } catch (err) {
690
- log('signal', `WebSocket failed: ${err.message}, retrying in 10s...`, 'yellow');
691
- setTimeout(connect, 10000);
692
- }
693
- };
694
-
695
- connect();
696
- }
697
-
698
- // Fallback polling for container mode when ws is not available
699
- let signalPollingInterval = null;
700
- function startSignalPolling(baseUrl, account, config) {
701
- if (signalPollingInterval) return;
702
- log('signal', 'starting polling fallback (30s interval)', 'gray');
703
- signalPollingInterval = setInterval(async () => {
704
- try {
705
- const res = await fetch(`${baseUrl}/v1/receive/${encodeURIComponent(account)}`, {
706
- signal: AbortSignal.timeout(15000),
707
- });
708
- if (res.ok) {
709
- const envelopes = await res.json();
710
- if (Array.isArray(envelopes)) {
711
- for (const envelope of envelopes) {
712
- processSignalEnvelope(envelope, config);
713
- }
714
- }
715
- }
716
- } catch {}
717
- }, 30000);
718
- }
719
-
720
- async function processSignalEvent(eventType, eventData, config) {
721
- try {
722
- const data = JSON.parse(eventData);
723
- if (data.envelope) {
724
- processSignalEnvelope(data.envelope, config);
725
- }
726
- } catch (err) {
727
- log('signal', `Event parse error: ${err.message}`, 'red');
728
- }
729
- }
730
-
731
- async function processSignalEnvelope(envelope, config) {
732
- try {
733
- const dataMessage = envelope.dataMessage || envelope.syncMessage?.sent?.message;
734
- if (!dataMessage) return;
735
-
736
- const sender = envelope.sourceUuid || envelope.sourceNumber || envelope.sourceName;
737
- if (!sender) return;
738
-
739
- // Skip self-messages
740
- if (envelope.sourceNumber === config.signalAccount) return;
741
-
742
- const messageText = dataMessage.message || '';
743
- if (!messageText.trim()) return;
744
-
745
- log('signal', `Inbound from ${sender}: "${messageText.slice(0, 80)}"`, 'cyan');
746
-
747
- // Check DM policy
748
- const dmPolicy = config.signalDmPolicy || 'pairing';
749
- if (dmPolicy === 'disabled') return;
750
-
751
- // Get AI response
752
- try {
753
- const { sendMessage } = require('../utils/api');
754
- const { getMemoryPrompt, extractMemoryFromMessage, addMemoryEntry } = require('../utils/memory');
755
- const { getConfig } = require('../utils/config');
756
- const cfg = getConfig();
757
-
758
- const conversationId = `signal_${sender}`;
759
- const botId = 'universal-provider';
760
- const memoryPrompt = getMemoryPrompt(botId);
761
-
762
- let systemPrompt = `You are a helpful Signal assistant. Keep responses concise.`;
763
- if (memoryPrompt) {
764
- systemPrompt += '\n\n' + memoryPrompt;
765
- }
766
-
767
- const response = await sendMessage(
768
- cfg.providerApiKey || cfg.apiKey || '', botId, messageText,
769
- conversationId, systemPrompt
770
- );
771
- const reply = response?.reply || response?.message || '';
772
-
773
- if (reply) {
774
- await sendSignalMessage(config, sender, reply);
775
- log('signal', `Reply sent to ${sender} (${reply.length} chars)`, 'green');
776
-
777
- const memoryEntries = extractMemoryFromMessage(messageText);
778
- for (const entry of memoryEntries) {
779
- addMemoryEntry(botId, entry.key, entry.value);
780
- }
781
- }
782
- } catch (err) {
783
- log('signal', `Response error: ${err.message}`, 'red');
784
- }
785
- } catch (err) {
786
- log('signal', `Envelope error: ${err.message}`, 'red');
787
- }
788
- }
789
-
790
- async function sendSignalMessage(config, recipient, message) {
791
- const baseUrl = config.signalHttpUrl;
792
- const account = config.signalAccount;
793
- const mode = config.signalApiMode || 'auto';
794
-
795
- if (mode === 'native' || mode === 'auto') {
796
- // Try native JSON-RPC first
797
- const rpcPayload = {
798
- jsonrpc: '2.0',
799
- method: 'send',
800
- params: {
801
- recipient: [recipient],
802
- message,
803
- account,
804
- },
805
- id: Date.now(),
806
- };
807
-
808
- try {
809
- const res = await fetch(`${baseUrl}/api/v1/rpc`, {
810
- method: 'POST',
811
- headers: { 'Content-Type': 'application/json' },
812
- body: JSON.stringify(rpcPayload),
813
- signal: AbortSignal.timeout(30000),
814
- });
815
- if (res.ok) return;
816
- } catch {}
817
- }
818
-
819
- // Fallback to container mode
820
- const containerPayload = {
821
- message,
822
- number: account,
823
- recipients: [recipient],
824
- text_mode: 'normal',
825
- };
826
-
827
- const res = await fetch(`${baseUrl}/v2/send`, {
828
- method: 'POST',
829
- headers: { 'Content-Type': 'application/json' },
830
- body: JSON.stringify(containerPayload),
831
- signal: AbortSignal.timeout(30000),
832
- });
833
-
834
- if (!res.ok) {
835
- const text = await res.text();
836
- throw new Error(`Signal send failed (HTTP ${res.status}): ${text.slice(0, 200)}`);
837
- }
838
- }
839
-
840
- // IRC provider
841
- let ircClient = null;
842
- let ircReconnectTimer = null;
843
-
844
- async function startIrcProvider(config) {
845
- if (ircClient) {
846
- log('irc', 'already connected', 'yellow');
847
- return;
848
- }
849
-
850
- const net = require('net');
851
- const tls = require('tls');
852
-
853
- const host = config.ircHost;
854
- const port = config.ircPort || 6697;
855
- const useTls = config.ircTls !== false;
856
- const nick = config.ircNick;
857
- const username = config.ircUsername || nick;
858
- const realname = config.ircRealname || 'NatureCo';
859
- const password = config.ircPassword || '';
860
- const channels = config.ircChannels || [];
861
- const nickservEnabled = config.ircNickservEnabled !== false;
862
- const nickservPassword = config.ircNickservPassword || '';
863
-
864
- let buffer = '';
865
- let currentNick = nick;
866
- let registered = false;
867
- let quitRequested = false;
868
-
869
- const connect = () => {
870
- if (quitRequested) return;
871
-
872
- registered = false;
873
- const socket = useTls
874
- ? tls.connect({ host, port, servername: host })
875
- : net.createConnection({ host, port });
876
-
877
- socket.setEncoding('utf8');
878
-
879
- const sendRaw = (line) => {
880
- socket.write(line + '\r\n');
881
- };
882
-
883
- socket.once('connect', () => {
884
- log('irc', `connected to ${host}:${port}`, 'green');
885
- if (password) sendRaw(`PASS ${password}`);
886
- sendRaw(`NICK ${currentNick}`);
887
- sendRaw(`USER ${username} 0 * :${realname}`);
888
- });
889
-
890
- socket.on('data', (chunk) => {
891
- buffer += chunk;
892
- const lines = buffer.split('\r\n');
893
- buffer = lines.pop() || '';
894
-
895
- for (const line of lines) {
896
- if (!line.trim()) continue;
897
-
898
- // PING handler
899
- if (line.startsWith('PING ')) {
900
- sendRaw('PONG ' + line.slice(5));
901
- continue;
902
- }
903
-
904
- // Parse IRC message
905
- const parsed = parseIrcLine(line);
906
- if (!parsed) continue;
907
-
908
- // Track nick changes
909
- if (parsed.command === 'NICK' && parsed.prefixNick === currentNick) {
910
- currentNick = parsed.trailing || parsed.params[0];
911
- }
912
-
913
- // Welcome server ready
914
- if (parsed.command === '001') {
915
- registered = true;
916
- log('irc', `registered as ${currentNick}`, 'cyan');
917
-
918
- // NickServ auth
919
- if (nickservEnabled && nickservPassword) {
920
- sendRaw(`PRIVMSG NickServ :IDENTIFY ${nickservPassword}`);
921
- log('irc', 'NickServ IDENTIFY sent', 'gray');
922
- }
923
-
924
- // Join channels
925
- for (const ch of channels) {
926
- sendRaw(`JOIN ${ch.startsWith('#') ? ch : '#' + ch}`);
927
- log('irc', `joining ${ch.startsWith('#') ? ch : '#' + ch}`, 'gray');
928
- }
929
-
930
- global.ircClient = { sendRaw, isReady: () => registered };
931
- continue;
932
- }
933
-
934
- // Handle PRIVMSG (incoming messages)
935
- if (parsed.command === 'PRIVMSG' && registered) {
936
- const senderNick = parsed.prefixNick;
937
- if (senderNick === currentNick) continue; // skip self
938
-
939
- const target = parsed.params[0];
940
- const text = parsed.trailing || '';
941
-
942
- if (!text.trim()) continue;
943
-
944
- const isChannel = target.startsWith('#');
945
- log('irc', `${isChannel ? target : senderNick}: ${text.slice(0, 100)}`, 'cyan');
946
-
947
- // In DM or channel message with mention
948
- const isDirect = !isChannel;
949
- if (!isDirect && !text.toLowerCase().includes(currentNick.toLowerCase())) continue;
950
-
951
- processIrcMessage(config, senderNick, target, text, socket);
952
- }
953
- }
954
- });
955
-
956
- socket.on('close', () => {
957
- global.ircClient = null;
958
- if (!quitRequested) {
959
- log('irc', 'connection lost, reconnecting in 10s...', 'yellow');
960
- ircReconnectTimer = setTimeout(connect, 10000);
961
- }
962
- });
963
-
964
- socket.on('error', (err) => {
965
- log('irc', `socket error: ${err.message}`, 'red');
966
- });
967
-
968
- ircClient = socket;
969
- };
970
-
971
- connect();
972
- }
973
- function parseIrcLine(line) {
974
- const result = { raw: line, prefix: null, prefixNick: null, command: '', params: [], trailing: '' };
975
- let rest = line;
976
-
977
- if (rest.startsWith(':')) {
978
- const spaceIdx = rest.indexOf(' ');
979
- if (spaceIdx === -1) return null;
980
- result.prefix = rest.slice(1, spaceIdx);
981
- const bangIdx = result.prefix.indexOf('!');
982
- result.prefixNick = bangIdx !== -1 ? result.prefix.slice(0, bangIdx) : result.prefix;
983
- rest = rest.slice(spaceIdx + 1);
984
- }
985
-
986
- const spaceIdx = rest.indexOf(' ');
987
- if (spaceIdx === -1) {
988
- result.command = rest;
989
- return result;
990
- }
991
- result.command = rest.slice(0, spaceIdx);
992
- rest = rest.slice(spaceIdx + 1);
993
-
994
- // Trailing
995
- const trailIdx = rest.indexOf(' :');
996
- if (trailIdx !== -1) {
997
- result.trailing = rest.slice(trailIdx + 2);
998
- rest = rest.slice(0, trailIdx);
999
- }
1000
-
1001
- result.params = rest.split(' ').filter(Boolean);
1002
- return result;
1003
- }
1004
-
1005
- async function processIrcMessage(config, sender, target, text, socket) {
1006
- try {
1007
- const { sendMessage } = require('../utils/api');
1008
- const { getMemoryPrompt } = require('../utils/memory');
1009
- const { getConfig } = require('../utils/config');
1010
- const cfg = getConfig();
1011
-
1012
- const conversationId = `irc_${sender}_${target}`;
1013
- const botId = 'universal-provider';
1014
- const memoryPrompt = getMemoryPrompt(botId);
1015
-
1016
- let systemPrompt = `You are a helpful IRC assistant. Keep responses concise. Use IRC-friendly formatting.`;
1017
- if (memoryPrompt) systemPrompt += '\n\n' + memoryPrompt;
1018
-
1019
- const response = await sendMessage(
1020
- cfg.providerApiKey || cfg.apiKey || '', botId, text,
1021
- conversationId, systemPrompt
1022
- );
1023
- const reply = response?.reply || response?.message || '';
1024
-
1025
- if (reply) {
1026
- const ircTarget = target.startsWith('#') ? target : sender;
1027
- sendIrcMessage(socket, ircTarget, reply);
1028
- log('irc', `Reply sent to ${ircTarget} (${reply.length} chars)`, 'green');
1029
- }
1030
- } catch (err) {
1031
- log('irc', `Response error: ${err.message}`, 'red');
1032
- }
1033
- }
1034
-
1035
- function sendIrcMessage(socket, target, text) {
1036
- if (!socket) return;
1037
- const maxLen = 400;
1038
- const lines = text.split('\n');
1039
- for (const line of lines) {
1040
- const cleanLine = line.replace(/\r/g, '').trim();
1041
- if (!cleanLine) continue;
1042
- if (cleanLine.length <= maxLen) {
1043
- socket.write(`PRIVMSG ${target} :${cleanLine}\r\n`);
1044
- } else {
1045
- const words = cleanLine.split(' ');
1046
- let chunk = '';
1047
- for (const word of words) {
1048
- if (chunk.length + word.length + 1 > maxLen) {
1049
- socket.write(`PRIVMSG ${target} :${chunk}\r\n`);
1050
- chunk = word;
1051
- } else {
1052
- chunk = chunk ? chunk + ' ' + word : word;
1053
- }
1054
- }
1055
- if (chunk) socket.write(`PRIVMSG ${target} :${chunk}\r\n`);
1056
- }
1057
- }
1058
- }
1059
-
1060
- function stopIrcProvider() {
1061
- if (ircReconnectTimer) {
1062
- clearTimeout(ircReconnectTimer);
1063
- ircReconnectTimer = null;
1064
- }
1065
- if (ircClient) {
1066
- try {
1067
- ircClient.write('QUIT :Gateway shutting down\r\n');
1068
- ircClient.end();
1069
- } catch {}
1070
- ircClient = null;
1071
- }
1072
- global.ircClient = null;
1073
- }
1074
-
1075
- // Mattermost provider
1076
- let mattermostWs = null;
1077
- let mattermostReconnectTimer = null;
1078
-
1079
- async function startMattermostProvider(config) {
1080
- const baseUrl = config.mattermostBaseUrl.replace(/\/+$/, '');
1081
- const token = config.mattermostToken;
1082
-
1083
- // Fetch bot user info
1084
- try {
1085
- const meRes = await fetch(`${baseUrl}/api/v4/users/me`, {
1086
- headers: { 'Authorization': `Bearer ${token}` },
1087
- signal: AbortSignal.timeout(10000),
1088
- });
1089
- if (!meRes.ok) {
1090
- log('mattermost', `API auth failed (HTTP ${meRes.status})`, 'red');
1091
- return;
1092
- }
1093
- const me = await meRes.json();
1094
- log('mattermost', `authenticated as @${me.username} (${me.id})`, 'green');
1095
-
1096
- global.mattermostProvider = { baseUrl, token, userId: me.id, username: me.username };
1097
-
1098
- // Connect WebSocket for real-time events
1099
- connectMattermostWebSocket(baseUrl, token, config);
1100
-
1101
- } catch (err) {
1102
- log('mattermost', `connection failed: ${err.message}`, 'red');
1103
- }
1104
- }
1105
-
1106
- function connectMattermostWebSocket(baseUrl, token, config) {
1107
- const wsUrl = baseUrl.replace(/^http/, 'ws') + '/api/v4/websocket';
1108
-
1109
- const connect = () => {
1110
- try {
1111
- let WebSocket;
1112
- try {
1113
- WebSocket = require('ws');
1114
- } catch {
1115
- log('mattermost', 'ws module not available', 'yellow');
1116
- return;
1117
- }
1118
-
1119
- const ws = new WebSocket(wsUrl);
1120
-
1121
- ws.on('open', () => {
1122
- log('mattermost', 'WebSocket connected', 'cyan');
1123
-
1124
- // Authenticate
1125
- ws.send(JSON.stringify({
1126
- seq: 1,
1127
- action: 'authentication_challenge',
1128
- data: { token },
1129
- }));
1130
- });
1131
-
1132
- ws.on('message', (data) => {
1133
- try {
1134
- const msg = JSON.parse(data.toString());
1135
-
1136
- if (msg.event === 'posted') {
1137
- handleMattermostPost(msg.data, msg.broadcast, config);
1138
- } else if (msg.event === 'hello') {
1139
- log('mattermost', 'WebSocket authenticated', 'green');
1140
- } else if (msg.event === 'user_added' || msg.event === 'user_removed') {
1141
- // no-op
1142
- }
1143
- } catch (err) {
1144
- log('mattermost', `message parse error: ${err.message}`, 'red');
1145
- }
1146
- });
1147
-
1148
- ws.on('close', (code) => {
1149
- log('mattermost', `WebSocket closed (code ${code})`, 'yellow');
1150
- mattermostWs = null;
1151
- mattermostReconnectTimer = setTimeout(connect, 10000);
1152
- });
1153
-
1154
- ws.on('error', (err) => {
1155
- log('mattermost', `WebSocket error: ${err.message}`, 'red');
1156
- ws.close();
1157
- });
1158
-
1159
- mattermostWs = ws;
1160
-
1161
- } catch (err) {
1162
- log('mattermost', `WebSocket connect failed: ${err.message}`, 'red');
1163
- mattermostReconnectTimer = setTimeout(connect, 30000);
1164
- }
1165
- };
1166
-
1167
- connect();
1168
- }
1169
-
1170
- async function handleMattermostPost(data, broadcast, config) {
1171
- try {
1172
- if (!data?.post) return;
1173
- const post = typeof data.post === 'string' ? JSON.parse(data.post) : data.post;
1174
-
1175
- // Skip bot's own messages
1176
- if (post.user_id === global.mattermostProvider?.userId) return;
1177
-
1178
- const channelId = broadcast?.channel_id || post.channel_id;
1179
- if (!channelId) return;
1180
-
1181
- const messageText = post.message || '';
1182
- if (!messageText.trim()) return;
1183
-
1184
- const senderId = post.user_id;
1185
- log('mattermost', `Inbound from user ${senderId}: "${messageText.slice(0, 80)}"`, 'cyan');
1186
-
1187
- // Skip system messages
1188
- if (post.props?.from_webhook) return;
1189
-
1190
- // Get AI response
1191
- const { sendMessage } = require('../utils/api');
1192
- const { getMemoryPrompt } = require('../utils/memory');
1193
- const { getConfig } = require('../utils/config');
1194
- const cfg = getConfig();
1195
-
1196
- const conversationId = `mattermost_${channelId}`;
1197
- const botId = 'universal-provider';
1198
- const memoryPrompt = getMemoryPrompt(botId);
1199
-
1200
- let systemPrompt = `You are a helpful Mattermost assistant. Keep responses concise.`;
1201
- if (memoryPrompt) systemPrompt += '\n\n' + memoryPrompt;
1202
-
1203
- const response = await sendMessage(
1204
- cfg.providerApiKey || cfg.apiKey || '', botId, messageText,
1205
- conversationId, systemPrompt
1206
- );
1207
- const reply = response?.reply || response?.message || '';
1208
-
1209
- if (reply) {
1210
- await sendMattermostMessage(config, channelId, reply);
1211
- log('mattermost', `Reply sent to channel ${channelId} (${reply.length} chars)`, 'green');
1212
- }
1213
- } catch (err) {
1214
- log('mattermost', `Post error: ${err.message}`, 'red');
1215
- }
1216
- }
1217
-
1218
- async function sendMattermostMessage(config, channelId, message) {
1219
- const baseUrl = config.mattermostBaseUrl.replace(/\/+$/, '');
1220
- const token = config.mattermostToken;
1221
-
1222
- const res = await fetch(`${baseUrl}/api/v4/posts`, {
1223
- method: 'POST',
1224
- headers: {
1225
- 'Content-Type': 'application/json',
1226
- 'Authorization': `Bearer ${token}`,
1227
- },
1228
- body: JSON.stringify({
1229
- channel_id: channelId,
1230
- message,
1231
- }),
1232
- signal: AbortSignal.timeout(15000),
1233
- });
1234
-
1235
- if (!res.ok) {
1236
- const text = await res.text();
1237
- throw new Error(`Mattermost send failed (HTTP ${res.status}): ${text.slice(0, 200)}`);
1238
- }
1239
- }
1240
-
1241
- function stopMattermostProvider() {
1242
- if (mattermostReconnectTimer) {
1243
- clearTimeout(mattermostReconnectTimer);
1244
- mattermostReconnectTimer = null;
1245
- }
1246
- if (mattermostWs) {
1247
- try { mattermostWs.close(); } catch {}
1248
- mattermostWs = null;
1249
- }
1250
- global.mattermostProvider = null;
1251
- }
1252
-
1253
- // iMessage provider
1254
- let imessagePollInterval = null;
1255
- let imessageLastMessageId = null;
1256
-
1257
- async function startImessageProvider(config) {
1258
- const imsgPath = config.imessageCliPath || findImsgBin();
1259
- if (!imsgPath || !fs.existsSync(imsgPath)) {
1260
- log('imessage', 'imsg binary not found, install with: brew install mbilker/imsg/imsg', 'red');
1261
- return;
1262
- }
1263
-
1264
- log('imessage', `using imsg: ${imsgPath}`, 'gray');
1265
- global.imessageProvider = { imsgPath };
1266
-
1267
- // Start polling for new messages
1268
- imessagePollInterval = setInterval(async () => {
1269
- try {
1270
- const result = execSync(`"${imsgPath}" messages --format json --limit 5 2>/dev/null`, {
1271
- encoding: 'utf-8', timeout: 10000, stdio: 'pipe',
1272
- });
1273
- const lines = result.trim().split('\n').filter(Boolean);
1274
- for (const line of lines) {
1275
- try {
1276
- const msg = JSON.parse(line);
1277
- await processImessageMessage(msg, config);
1278
- } catch {}
1279
- }
1280
- } catch {}
1281
- }, 15000);
1282
-
1283
- log('imessage', 'polling started (15s interval)', 'green');
1284
- }
1285
-
1286
- function findImsgBin() {
1287
- const config = require('../utils/config').getConfig();
1288
- if (config.imessageCliPath && fs.existsSync(config.imessageCliPath)) return config.imessageCliPath;
1289
- try {
1290
- const result = execSync('which imsg 2>/dev/null', { encoding: 'utf-8', timeout: 5000 });
1291
- const p = result.trim();
1292
- if (p && fs.existsSync(p)) return p;
1293
- } catch {}
1294
- return null;
1295
- }
1296
-
1297
- async function processImessageMessage(msg, config) {
1298
- try {
1299
- // Deduplicate
1300
- if (msg.id && msg.id === imessageLastMessageId) return;
1301
- if (msg.id) imessageLastMessageId = msg.id;
1302
-
1303
- // Skip outgoing messages (from us)
1304
- if (msg.is_from_me || msg.from_me) return;
1305
-
1306
- const sender = msg.sender || msg.from || msg.address || '';
1307
- const text = msg.text || msg.message || '';
1308
-
1309
- if (!text.trim() || !sender) return;
1310
-
1311
- log('imessage', `Inbound from ${sender}: "${text.slice(0, 80)}"`, 'cyan');
1312
-
1313
- const { sendMessage } = require('../utils/api');
1314
- const { getMemoryPrompt } = require('../utils/memory');
1315
- const { getConfig } = require('../utils/config');
1316
- const cfg = getConfig();
1317
-
1318
- const conversationId = `imessage_${sender}`;
1319
- const botId = 'universal-provider';
1320
- const memoryPrompt = getMemoryPrompt(botId);
1321
-
1322
- let systemPrompt = `You are a helpful iMessage assistant. Keep responses concise.`;
1323
- if (memoryPrompt) systemPrompt += '\n\n' + memoryPrompt;
1324
-
1325
- const response = await sendMessage(
1326
- cfg.providerApiKey || cfg.apiKey || '', botId, text,
1327
- conversationId, systemPrompt
1328
- );
1329
- const reply = response?.reply || response?.message || '';
1330
-
1331
- if (reply) {
1332
- execSync(`"${global.imessageProvider.imsgPath}" send --address "${sender}" --message "${reply.replace(/"/g, '\\"')}" 2>/dev/null`, {
1333
- timeout: 15000, stdio: 'pipe',
1334
- });
1335
- log('imessage', `Reply sent to ${sender} (${reply.length} chars)`, 'green');
1336
- }
1337
- } catch (err) {
1338
- log('imessage', `Message error: ${err.message}`, 'red');
1339
- }
1340
- }
1341
-
1342
- function stopImessageProvider() {
1343
- if (imessagePollInterval) {
1344
- clearInterval(imessagePollInterval);
1345
- imessagePollInterval = null;
1346
- }
1347
- global.imessageProvider = null;
1348
- }
1349
-
1350
- // SMS (Twilio) provider
1351
- let smsReplayCache = new Map();
1352
- const SMS_RATE_LIMIT_WINDOW = 60000;
1353
- const SMS_RATE_LIMIT_MAX = 30;
1354
- const smsRateLimitMap = new Map();
1355
-
1356
- async function startSmsProvider(config) {
1357
- log('sms', 'provider initialized for outbound sending', 'green');
1358
-
1359
- if (config.smsEnableWebhook !== false) {
1360
- log('sms', 'webhook endpoint will be registered on HTTP server', 'gray');
1361
- }
1362
-
1363
- global.smsProvider = {
1364
- accountSid: config.smsAccountSid,
1365
- authToken: config.smsAuthToken,
1366
- fromNumber: config.smsFromNumber,
1367
- messagingServiceSid: config.smsMessagingServiceSid || null,
1368
- };
1369
- }
1370
-
1371
- async function sendSmsMessage(config, to, message) {
1372
- const accountSid = config.smsAccountSid;
1373
- const authToken = config.smsAuthToken;
1374
- const from = config.smsFromNumber;
1375
- const messagingServiceSid = config.smsMessagingServiceSid;
1376
-
1377
- const base64Auth = Buffer.from(`${accountSid}:${authToken}`).toString('base64');
1378
-
1379
- const body = new URLSearchParams();
1380
- if (messagingServiceSid) {
1381
- body.append('MessagingServiceSid', messagingServiceSid);
1382
- } else {
1383
- body.append('From', from);
1384
- }
1385
- body.append('To', to);
1386
- body.append('Body', message);
1387
-
1388
- const res = await fetch(`https://api.twilio.com/2010-04-01/Accounts/${accountSid}/Messages.json`, {
1389
- method: 'POST',
1390
- headers: {
1391
- 'Authorization': `Basic ${base64Auth}`,
1392
- 'Content-Type': 'application/x-www-form-urlencoded',
1393
- },
1394
- body: body.toString(),
1395
- signal: AbortSignal.timeout(30000),
1396
- });
1397
-
1398
- if (!res.ok) {
1399
- const text = await res.text();
1400
- let detail = text.slice(0, 300);
1401
- try {
1402
- const errJson = JSON.parse(text);
1403
- detail = errJson.message || detail;
1404
- } catch {}
1405
- throw new Error(`Twilio send failed (HTTP ${res.status}): ${detail}`);
1406
- }
1407
-
1408
- return res.json();
1409
- }
1410
-
1411
- function verifyTwilioSignature(authToken, url, params, signature) {
1412
- const crypto = require('crypto');
1413
- // Sort params by key
1414
- const sorted = Object.keys(params).sort();
1415
- const data = url + sorted.map(k => k + params[k]).join('');
1416
- const expected = crypto.createHmac('sha1', authToken).update(data).digest('base64');
1417
- try {
1418
- return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
1419
- } catch {
1420
- return false;
1421
- }
1422
- }
1423
-
1424
- async function handleSmsWebhook(config, body, req) {
1425
- // Rate limiting
1426
- const ip = req.socket?.remoteAddress || 'unknown';
1427
- const now = Date.now();
1428
- const windowKey = `${ip}:${Math.floor(now / SMS_RATE_LIMIT_WINDOW)}`;
1429
- const count = (smsRateLimitMap.get(windowKey) || 0) + 1;
1430
- smsRateLimitMap.set(windowKey, count);
1431
- if (count > SMS_RATE_LIMIT_MAX) {
1432
- return { status: 429, body: { error: 'Too many requests' } };
1433
- }
1434
-
1435
- // Replay protection
1436
- const messageSid = body.MessageSid || '';
1437
- if (messageSid && smsReplayCache.has(messageSid)) {
1438
- return { status: 200, body: { ok: true, cached: true } };
1439
- }
1440
- if (messageSid) {
1441
- smsReplayCache.set(messageSid, true);
1442
- setTimeout(() => smsReplayCache.delete(messageSid), 600000);
1443
- if (smsReplayCache.size > 10000) {
1444
- const keys = [...smsReplayCache.keys()].slice(0, 2000);
1445
- keys.forEach(k => smsReplayCache.delete(k));
1446
- }
1447
- }
1448
-
1449
- // Signature validation
1450
- const signature = req.headers['x-twilio-signature'] || '';
1451
- if (signature && config.smsAuthToken) {
1452
- const fullUrl = `${req.headers['x-forwarded-proto'] || 'http'}://${req.headers.host}${req.url}`;
1453
- const params = {};
1454
- for (const [k, v] of Object.entries(body)) {
1455
- params[k] = String(v);
1456
- }
1457
- if (!verifyTwilioSignature(config.smsAuthToken, fullUrl, params, signature)) {
1458
- log('sms', 'invalid Twilio signature, rejecting', 'red');
1459
- return { status: 403, body: { error: 'Invalid signature' } };
1460
- }
1461
- }
1462
-
1463
- const from = body.From || '';
1464
- const to = body.To || '';
1465
- const text = body.Body || '';
1466
-
1467
- if (!from || !text.trim()) {
1468
- return { status: 200, body: { ok: true, ignored: true } };
1469
- }
1470
-
1471
- log('sms', `Inbound from ${from}: "${text.slice(0, 80)}"`, 'cyan');
1472
-
1473
- // Check DM policy
1474
- const dmPolicy = config.smsDmPolicy || 'allowlist';
1475
- if (dmPolicy === 'disabled') {
1476
- return { status: 200, body: { ok: true, ignored: true } };
1477
- }
1478
-
1479
- // Get AI response
1480
- try {
1481
- const { sendMessage } = require('../utils/api');
1482
- const { getMemoryPrompt } = require('../utils/memory');
1483
- const { getConfig } = require('../utils/config');
1484
- const cfg = getConfig();
1485
-
1486
- const conversationId = `sms_${from}`;
1487
- const botId = 'universal-provider';
1488
- const memoryPrompt = getMemoryPrompt(botId);
1489
-
1490
- let systemPrompt = `You are a helpful SMS assistant. Keep responses concise (SMS format).`;
1491
- if (memoryPrompt) systemPrompt += '\n\n' + memoryPrompt;
1492
-
1493
- const response = await sendMessage(
1494
- cfg.providerApiKey || cfg.apiKey || '', botId, text,
1495
- conversationId, systemPrompt
1496
- );
1497
- const reply = response?.reply || response?.message || '';
1498
-
1499
- if (reply) {
1500
- await sendSmsMessage(config, from, reply);
1501
- log('sms', `Reply sent to ${from} (${reply.length} chars)`, 'green');
1502
- }
1503
- } catch (err) {
1504
- log('sms', `Response error: ${err.message}`, 'red');
1505
- }
1506
-
1507
- return { status: 200, body: { ok: true } };
1508
- }
1509
-
1510
- function stopSmsProvider() {
1511
- smsReplayCache.clear();
1512
- smsRateLimitMap.clear();
1513
- global.smsProvider = null;
1514
- }
1515
-
1516
- function startHttpServer() {
1517
- const http = require('http');
1518
-
1519
- const server = http.createServer(async (req, res) => {
1520
- // CORS headers
1521
- res.setHeader('Access-Control-Allow-Origin', '*');
1522
- res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
1523
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
1524
-
1525
- if (req.method === 'OPTIONS') {
1526
- res.writeHead(200);
1527
- res.end();
1528
- return;
1529
- }
1530
-
1531
- if (req.method === 'POST' && req.url === '/webhooks/sms') {
1532
- // Twilio SMS webhook handler
1533
- const { getConfig } = require('../utils/config');
1534
- const cfg = getConfig();
1535
-
1536
- let body = '';
1537
- req.on('data', chunk => { body += chunk.toString(); });
1538
- req.on('end', async () => {
1539
- // Parse form-encoded body
1540
- const params = new URLSearchParams(body);
1541
- const formBody = {};
1542
- for (const [k, v] of params) formBody[k] = v;
1543
-
1544
- // Create a mock req-like object for signature validation
1545
- const mockReq = {
1546
- url: req.url,
1547
- headers: req.headers,
1548
- socket: req.socket,
1549
- };
1550
-
1551
- const result = await handleSmsWebhook(cfg, formBody, mockReq);
1552
- res.writeHead(result.status, { 'Content-Type': 'application/json' });
1553
- res.end(JSON.stringify(result.body));
1554
- });
1555
- return;
1556
- }
1557
-
1558
- if (req.method === 'POST' && req.url === '/send') {
1559
- let body = '';
1560
-
1561
- req.on('data', chunk => {
1562
- body += chunk.toString();
1563
- });
1564
-
1565
- req.on('end', async () => {
1566
- try {
1567
- const { channel, target, message } = JSON.parse(body);
1568
-
1569
- if (!channel || !target || !message) {
1570
- res.writeHead(400, { 'Content-Type': 'application/json' });
1571
- res.end(JSON.stringify({ error: 'Missing required fields: channel, target, message' }));
1572
- return;
1573
- }
1574
-
1575
- if (channel === 'whatsapp') {
1576
- if (!global.whatsappSock) {
1577
- res.writeHead(503, { 'Content-Type': 'application/json' });
1578
- res.end(JSON.stringify({ error: 'WhatsApp not connected' }));
1579
- return;
1580
- }
1581
-
1582
- const normalizedTarget = target.replace(/[^\d]/g, '');
1583
- const jid = `${normalizedTarget}@s.whatsapp.net`;
1584
-
1585
- await global.whatsappSock.sendMessage(jid, { text: message });
1586
-
1587
- log('http', `WhatsApp message sent to ${target}`, 'green');
1588
-
1589
- res.writeHead(200, { 'Content-Type': 'application/json' });
1590
- res.end(JSON.stringify({ success: true, channel: 'whatsapp', target }));
1591
-
1592
- } else if (channel === 'telegram') {
1593
- if (!global.telegramBot) {
1594
- res.writeHead(503, { 'Content-Type': 'application/json' });
1595
- res.end(JSON.stringify({ error: 'Telegram not connected' }));
1596
- return;
1597
- }
1598
-
1599
- const chatId = target.trim();
1600
- if (!/^-?\d+$/.test(chatId)) {
1601
- res.writeHead(400, { 'Content-Type': 'application/json' });
1602
- res.end(JSON.stringify({ error: 'Invalid Telegram chat ID' }));
1603
- return;
1604
- }
1605
-
1606
- await global.telegramBot.sendMessage(chatId, message);
1607
-
1608
- log('http', `Telegram message sent to ${target}`, 'green');
1609
-
1610
- res.writeHead(200, { 'Content-Type': 'application/json' });
1611
- res.end(JSON.stringify({ success: true, channel: 'telegram', target }));
1612
-
1613
- } else if (channel === 'signal') {
1614
- const { getConfig } = require('../utils/config');
1615
- const cfg = getConfig();
1616
- if (!cfg.signalHttpUrl) {
1617
- res.writeHead(503, { 'Content-Type': 'application/json' });
1618
- res.end(JSON.stringify({ error: 'Signal not connected' }));
1619
- return;
1620
- }
1621
- await sendSignalMessage(cfg, target, message);
1622
- log('http', `Signal message sent to ${target}`, 'green');
1623
- res.writeHead(200, { 'Content-Type': 'application/json' });
1624
- res.end(JSON.stringify({ success: true, channel: 'signal', target }));
1625
- } else if (channel === 'irc') {
1626
- if (!global.ircClient?.isReady()) {
1627
- res.writeHead(503, { 'Content-Type': 'application/json' });
1628
- res.end(JSON.stringify({ error: 'IRC not connected' }));
1629
- return;
1630
- }
1631
- sendIrcMessage(ircClient, target, message);
1632
- log('http', `IRC message sent to ${target}`, 'green');
1633
- res.writeHead(200, { 'Content-Type': 'application/json' });
1634
- res.end(JSON.stringify({ success: true, channel: 'irc', target }));
1635
- } else if (channel === 'mattermost') {
1636
- if (!global.mattermostProvider) {
1637
- res.writeHead(503, { 'Content-Type': 'application/json' });
1638
- res.end(JSON.stringify({ error: 'Mattermost not connected' }));
1639
- return;
1640
- }
1641
- await sendMattermostMessage(config, target, message);
1642
- log('http', `Mattermost message sent to channel ${target}`, 'green');
1643
- res.writeHead(200, { 'Content-Type': 'application/json' });
1644
- res.end(JSON.stringify({ success: true, channel: 'mattermost', target }));
1645
- } else if (channel === 'imessage') {
1646
- if (!global.imessageProvider) {
1647
- res.writeHead(503, { 'Content-Type': 'application/json' });
1648
- res.end(JSON.stringify({ error: 'iMessage not connected' }));
1649
- return;
1650
- }
1651
- try {
1652
- execSync(`"${global.imessageProvider.imsgPath}" send --address "${target.replace(/"/g, '\\"')}" --message "${message.replace(/"/g, '\\"')}" 2>/dev/null`, {
1653
- timeout: 15000, stdio: 'pipe',
1654
- });
1655
- log('http', `iMessage sent to ${target}`, 'green');
1656
- res.writeHead(200, { 'Content-Type': 'application/json' });
1657
- res.end(JSON.stringify({ success: true, channel: 'imessage', target }));
1658
- } catch (sendErr) {
1659
- res.writeHead(500, { 'Content-Type': 'application/json' });
1660
- res.end(JSON.stringify({ error: `iMessage send failed: ${sendErr.message}` }));
1661
- }
1662
- } else if (channel === 'sms') {
1663
- if (!global.smsProvider) {
1664
- res.writeHead(503, { 'Content-Type': 'application/json' });
1665
- res.end(JSON.stringify({ error: 'SMS not connected' }));
1666
- return;
1667
- }
1668
- await sendSmsMessage(config, target, message);
1669
- log('http', `SMS sent to ${target}`, 'green');
1670
- res.writeHead(200, { 'Content-Type': 'application/json' });
1671
- res.end(JSON.stringify({ success: true, channel: 'sms', target }));
1672
- } else {
1673
- res.writeHead(400, { 'Content-Type': 'application/json' });
1674
- res.end(JSON.stringify({ error: 'Invalid channel. Use "whatsapp" or "telegram"' }));
1675
- }
1676
-
1677
- } catch (err) {
1678
- log('http', `Error: ${err.message}`, 'red');
1679
- res.writeHead(500, { 'Content-Type': 'application/json' });
1680
- res.end(JSON.stringify({ error: err.message }));
1681
- }
1682
- });
1683
-
1684
- } else {
1685
- res.writeHead(404, { 'Content-Type': 'application/json' });
1686
- res.end(JSON.stringify({ error: 'Not found' }));
1687
- }
1688
- });
1689
-
1690
- server.listen(3847, '127.0.0.1', () => {
1691
- log('http', 'HTTP server listening on http://127.0.0.1:3847', 'green');
1692
- });
1693
-
1694
- server.on('error', (err) => {
1695
- log('http', `Server error: ${err.message}`, 'red');
1696
- });
1697
- }
1698
-
1699
- function startCronJobs(config) {
1700
- try {
1701
- const nodeCron = require('node-cron');
1702
- const CRONS_FILE = path.join(os.homedir(), '.natureco', 'crons.json');
1703
-
1704
- if (!fs.existsSync(CRONS_FILE)) {
1705
- log('cron', 'No cron jobs configured', 'gray');
1706
- return;
1707
- }
1708
-
1709
- const crons = JSON.parse(fs.readFileSync(CRONS_FILE, 'utf-8'));
1710
- const enabledCrons = crons.filter(c => c.enabled);
1711
-
1712
- if (enabledCrons.length === 0) {
1713
- log('cron', 'No enabled cron jobs', 'gray');
1714
- return;
1715
- }
1716
-
1717
- log('cron', `Loading ${enabledCrons.length} cron job(s)...`, 'cyan');
1718
-
1719
- enabledCrons.forEach(cronJob => {
1720
- try {
1721
- nodeCron.schedule(cronJob.schedule, async () => {
1722
- log('cron', `Triggered: ${cronJob.name}`, 'yellow');
1723
-
1724
- try {
1725
- // Get provider config
1726
- const { getConfig } = require('../utils/config');
1727
- const cfg = getConfig();
1728
-
1729
- if (!cfg.providerUrl || !cfg.providerApiKey) {
1730
- log('cron', 'Provider not configured', 'red');
1731
- return;
1732
- }
1733
-
1734
- const isAnthropic = cfg.providerUrl.includes('anthropic.com');
1735
-
1736
- log('cron', `Sending prompt to AI (no tools)...`, 'cyan');
1737
-
1738
- // Make direct API call without tools
1739
- let reply = '';
1740
-
1741
- if (isAnthropic) {
1742
- // Anthropic API
1743
- const response = await fetch(`${cfg.providerUrl}/v1/messages`, {
1744
- method: 'POST',
1745
- headers: {
1746
- 'x-api-key': cfg.providerApiKey,
1747
- 'anthropic-version': '2023-06-01',
1748
- 'Content-Type': 'application/json',
1749
- },
1750
- body: JSON.stringify({
1751
- model: cfg.providerModel || 'claude-3-5-sonnet-20241022',
1752
- max_tokens: 1000,
1753
- messages: [{ role: 'user', content: cronJob.prompt }]
1754
- }),
1755
- });
1756
-
1757
- if (!response.ok) {
1758
- const errorText = await response.text();
1759
- throw new ApiError(`Anthropic API error: ${response.status} - ${errorText}`, response.status);
1760
- }
1761
-
1762
- const data = await response.json();
1763
- reply = data.content.find(c => c.type === 'text')?.text || '';
1764
-
1765
- } else {
1766
- // OpenAI-compatible API
1767
- const response = await fetch(`${cfg.providerUrl}/chat/completions`, {
1768
- method: 'POST',
1769
- headers: {
1770
- 'Authorization': `Bearer ${cfg.providerApiKey}`,
1771
- 'Content-Type': 'application/json',
1772
- },
1773
- body: JSON.stringify({
1774
- model: cfg.providerModel || 'llama-3.3-70b-versatile',
1775
- messages: [{ role: 'user', content: cronJob.prompt }],
1776
- temperature: 0.7,
1777
- max_tokens: 1000,
1778
- }),
1779
- });
1780
-
1781
- if (!response.ok) {
1782
- const errorText = await response.text();
1783
- throw new ApiError(`Provider API error: ${response.status} - ${errorText}`, response.status);
1784
- }
1785
-
1786
- const data = await response.json();
1787
- reply = data.choices[0].message.content;
1788
- }
1789
-
1790
- if (!reply) {
1791
- log('cron', `No response from AI`, 'red');
1792
- return;
1793
- }
1794
-
1795
- log('cron', `AI response received (${reply.length} chars)`, 'green');
1796
-
1797
- // Send to target channel
1798
- if (cronJob.action === 'whatsapp') {
1799
- if (!global.whatsappSock) {
1800
- log('cron', `WhatsApp not connected, skipping`, 'red');
1801
- return;
1802
- }
1803
-
1804
- const normalizedTarget = cronJob.target.replace(/[^\d]/g, '');
1805
- const jid = `${normalizedTarget}@s.whatsapp.net`;
1806
-
1807
- await global.whatsappSock.sendMessage(jid, { text: reply });
1808
- log('cron', `Sent to WhatsApp: ${cronJob.target}`, 'green');
1809
-
1810
- } else if (cronJob.action === 'telegram') {
1811
- if (!global.telegramBot) {
1812
- log('cron', `Telegram not connected, skipping`, 'red');
1813
- return;
1814
- }
1815
-
1816
- await global.telegramBot.sendMessage(cronJob.target, reply);
1817
- log('cron', `Sent to Telegram: ${cronJob.target}`, 'green');
1818
- } else if (cronJob.action === 'signal') {
1819
- const { getConfig } = require('../utils/config');
1820
- const cfg = getConfig();
1821
- if (!cfg.signalHttpUrl) {
1822
- log('cron', 'Signal not connected, skipping', 'red');
1823
- return;
1824
- }
1825
- await sendSignalMessage(cfg, cronJob.target, reply);
1826
- log('cron', `Sent to Signal: ${cronJob.target}`, 'green');
1827
- } else if (cronJob.action === 'irc') {
1828
- if (!global.ircClient?.isReady()) {
1829
- log('cron', 'IRC not connected, skipping', 'red');
1830
- return;
1831
- }
1832
- sendIrcMessage(ircClient, cronJob.target, reply);
1833
- log('cron', `Sent to IRC: ${cronJob.target}`, 'green');
1834
- } else if (cronJob.action === 'mattermost') {
1835
- if (!global.mattermostProvider) {
1836
- log('cron', 'Mattermost not connected, skipping', 'red');
1837
- return;
1838
- }
1839
- await sendMattermostMessage(config, cronJob.target, reply);
1840
- log('cron', `Sent to Mattermost: ${cronJob.target}`, 'green');
1841
- } else if (cronJob.action === 'imessage') {
1842
- if (!global.imessageProvider) {
1843
- log('cron', 'iMessage not connected, skipping', 'red');
1844
- return;
1845
- }
1846
- execSync(`"${global.imessageProvider.imsgPath}" send --address "${cronJob.target.replace(/"/g, '\\"')}" --message "${reply.replace(/"/g, '\\"')}" 2>/dev/null`, {
1847
- timeout: 15000, stdio: 'pipe',
1848
- });
1849
- log('cron', `Sent to iMessage: ${cronJob.target}`, 'green');
1850
- } else if (cronJob.action === 'sms') {
1851
- if (!global.smsProvider) {
1852
- log('cron', 'SMS not connected, skipping', 'red');
1853
- return;
1854
- }
1855
- await sendSmsMessage(config, cronJob.target, reply);
1856
- log('cron', `Sent to SMS: ${cronJob.target}`, 'green');
1857
- }
1858
-
1859
- } catch (err) {
1860
- log('cron', `Error executing ${cronJob.name}: ${err.message}`, 'red');
1861
- }
1862
- });
1863
-
1864
- log('cron', `Scheduled: ${cronJob.name} (${cronJob.schedule})`, 'green');
1865
-
1866
- } catch (err) {
1867
- log('cron', `Failed to schedule ${cronJob.name}: ${err.message}`, 'red');
1868
- }
1869
- });
1870
-
1871
- } catch (err) {
1872
- log('cron', `Failed to load cron jobs: ${err.message}`, 'red');
1873
- }
1874
- }
1875
-
1876
- function stopGateway() {
1877
- if (!fs.existsSync(PID_FILE)) {
1878
- console.log(chalk.gray('\n⚠️ Gateway not running\n'));
1879
- return;
1880
- }
1881
-
1882
- const pid = parseInt(fs.readFileSync(PID_FILE, 'utf-8').trim());
1883
-
1884
- try {
1885
- // Try graceful shutdown first
1886
- process.kill(pid, 'SIGTERM');
1887
-
1888
- // Wait a bit and check if process is still running
1889
- setTimeout(() => {
1890
- try {
1891
- process.kill(pid, 0);
1892
- // Process still running, force kill
1893
- if (process.platform === 'win32') {
1894
- try {
1895
- execSync(`taskkill /F /PID ${pid}`, { stdio: 'ignore' });
1896
- console.log(chalk.green('\n✅ Gateway stopped (force killed)\n'));
1897
- } catch (err) {
1898
- console.log(chalk.red(`\n❌ Failed to stop gateway: ${err.message}\n`));
1899
- }
1900
- } else {
1901
- process.kill(pid, 'SIGKILL');
1902
- console.log(chalk.green('\n✅ Gateway stopped (force killed)\n'));
1903
- }
1904
- } catch {
1905
- // Process already stopped
1906
- console.log(chalk.green('\n✅ Gateway stopped\n'));
1907
- }
1908
-
1909
- // Remove PID file
1910
- if (fs.existsSync(PID_FILE)) {
1911
- fs.unlinkSync(PID_FILE);
1912
- }
1913
- }, 1000);
1914
-
1915
- } catch (err) {
1916
- // Process not found, try force kill on Windows
1917
- if (process.platform === 'win32') {
1918
- try {
1919
- execSync(`taskkill /F /PID ${pid}`, { stdio: 'ignore' });
1920
- console.log(chalk.green('\n✅ Gateway stopped\n'));
1921
- } catch {
1922
- console.log(chalk.yellow('\n⚠️ Gateway process not found\n'));
1923
- }
1924
- } else {
1925
- console.log(chalk.yellow('\n⚠️ Gateway process not found\n'));
1926
- }
1927
-
1928
- // Remove stale PID file
1929
- if (fs.existsSync(PID_FILE)) {
1930
- fs.unlinkSync(PID_FILE);
1931
- }
1932
- }
1933
- }
1934
-
1935
- function statusGateway() {
1936
- if (!fs.existsSync(PID_FILE)) {
1937
- console.log(chalk.gray('\n⚠️ Gateway not running\n'));
1938
- return;
1939
- }
1940
-
1941
- const pid = fs.readFileSync(PID_FILE, 'utf-8').trim();
1942
-
1943
- try {
1944
- process.kill(pid, 0);
1945
- console.log(chalk.green('\n✅ Gateway running\n'));
1946
- console.log(chalk.cyan('PID:'), chalk.white(pid));
1947
- console.log(chalk.cyan('Logs:'), chalk.white(LOG_FILE));
1948
-
1949
- // Show log tail if exists
1950
- if (fs.existsSync(LOG_FILE)) {
1951
- const logs = fs.readFileSync(LOG_FILE, 'utf-8').split('\n').filter(l => l.trim()).slice(-10).join('\n');
1952
- console.log(chalk.gray('\nRecent logs (last 10):'));
1953
- console.log(chalk.white(logs));
1954
- }
1955
- console.log('');
1956
- } catch {
1957
- console.log(chalk.gray('\n⚠️ Gateway not running (stale PID file)\n'));
1958
- fs.unlinkSync(PID_FILE);
1959
- }
1960
- }
1961
-
1962
- function showLogs() {
1963
- if (!fs.existsSync(LOG_FILE)) {
1964
- console.log(chalk.gray('\n⚠️ No logs found\n'));
1965
- return;
1966
- }
1967
-
1968
- const logs = fs.readFileSync(LOG_FILE, 'utf-8');
1969
- console.log(logs);
1970
- }
1971
-
1972
- // Direkt çalıştırılınca worker olarak başlat
1973
- if (require.main === module || process.argv.includes('--gateway-worker')) {
1974
- gatewayServer('--gateway-worker');
1975
- }
1976
-
1977
- module.exports = gatewayServer;
1
+ const chalk = require('chalk');
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const os = require('os');
5
+ const { spawn, execSync } = require('child_process');
6
+ const pino = require('pino');
7
+ const { loadBaileys } = require('../utils/baileys');
8
+ const { ApiError } = require('../utils/errors');
9
+
10
+ const PID_FILE = path.join(os.homedir(), '.natureco', 'gateway.pid');
11
+ const LOG_FILE = path.join(os.homedir(), '.natureco', 'gateway.log');
12
+
13
+ // Silent logger for Baileys
14
+ const silentLogger = {
15
+ level: 'silent',
16
+ trace: () => {},
17
+ debug: () => {},
18
+ info: () => {},
19
+ warn: () => {},
20
+ error: () => {},
21
+ fatal: () => {},
22
+ child: () => silentLogger
23
+ };
24
+
25
+ // Create Baileys logger — real pino in worker mode, silent otherwise
26
+ let _baileysLogger = null;
27
+ function getBaileysLogger() {
28
+ if (!_baileysLogger) {
29
+ const isWorker = process.argv.includes('--gateway-worker');
30
+ _baileysLogger = isWorker
31
+ ? pino({ level: 'info', transport: { target: 'pino/file', options: { destination: LOG_FILE } } })
32
+ : silentLogger;
33
+ }
34
+ return _baileysLogger;
35
+ }
36
+
37
+ // Log helper - only writes to console in worker, file writing handled by stdio redirect
38
+ function log(module, message, color = 'white') {
39
+ const timestamp = new Date().toISOString().replace('T', ' ').substring(0, 19);
40
+ const logLine = `[${timestamp}] [${module}] ${message}`;
41
+
42
+ // Console output with color (will be redirected to file by parent process)
43
+ const colorFn = chalk[color] || chalk.white;
44
+ console.log(colorFn(logLine));
45
+
46
+ // Note: File writing removed - parent process redirects stdout/stderr to log file
47
+ // This prevents duplicate log entries
48
+ }
49
+
50
+ // Number matching helper - compare last 10 digits only
51
+ function numberMatches(incoming, allowed) {
52
+ const clean = (n) => n.replace(/\D/g, '').slice(-10);
53
+ return clean(incoming) === clean(allowed);
54
+ }
55
+
56
+ async function gatewayServer(action) {
57
+ // Check if running as background worker
58
+ if (process.argv.includes('--gateway-worker')) {
59
+ process.stdin.resume();
60
+ process.title = 'natureco-gateway';
61
+ log('gateway', 'Worker process started, PID: ' + process.pid);
62
+ return runGatewayWorker();
63
+ }
64
+
65
+ if (!action || action === 'start') {
66
+ return startGateway();
67
+ }
68
+
69
+ if (action === 'stop') {
70
+ return stopGateway();
71
+ }
72
+
73
+ if (action === 'status') {
74
+ return statusGateway();
75
+ }
76
+
77
+ if (action === 'logs') {
78
+ return showLogs();
79
+ }
80
+
81
+ console.log(chalk.red('\n❌ Unknown action\n'));
82
+ console.log(chalk.gray('Available actions: start, stop, status, logs\n'));
83
+ process.exit(1);
84
+ }
85
+
86
+ async function startGateway() {
87
+ // Check if already running
88
+ if (fs.existsSync(PID_FILE)) {
89
+ const pid = fs.readFileSync(PID_FILE, 'utf-8').trim();
90
+ try {
91
+ process.kill(pid, 0);
92
+ console.log(chalk.yellow('\n⚠️ Gateway already running\n'));
93
+ console.log(chalk.gray(`PID: ${pid}`));
94
+ console.log(chalk.gray('Stop with: natureco gateway stop\n'));
95
+ return;
96
+ } catch {
97
+ // Process not running, remove stale PID file
98
+ fs.unlinkSync(PID_FILE);
99
+ }
100
+ }
101
+
102
+ console.log(chalk.green('\n🚀 Starting NatureCo Gateway...\n'));
103
+
104
+ // Ensure log directory exists
105
+ const dir = path.dirname(LOG_FILE);
106
+ if (!fs.existsSync(dir)) {
107
+ fs.mkdirSync(dir, { recursive: true });
108
+ }
109
+
110
+ // Open log file for stdio redirection
111
+ const logFd = fs.openSync(LOG_FILE, 'a');
112
+
113
+ // Start as detached background process
114
+ const child = spawn(process.execPath, [require.resolve('./gateway-server'), '--gateway-worker'], {
115
+ detached: true,
116
+ stdio: ['ignore', logFd, logFd],
117
+ windowsHide: true,
118
+ cwd: os.homedir(),
119
+ });
120
+
121
+ child.unref();
122
+
123
+ // Save PID
124
+ if (!fs.existsSync(dir)) {
125
+ fs.mkdirSync(dir, { recursive: true });
126
+ }
127
+ fs.writeFileSync(PID_FILE, String(child.pid), 'utf-8');
128
+
129
+ console.log(chalk.green('✅ Gateway started in background'));
130
+ console.log(chalk.cyan('PID:'), chalk.white(child.pid));
131
+ console.log(chalk.cyan('Logs:'), chalk.white(LOG_FILE));
132
+ console.log(chalk.gray('\nView logs: natureco gateway logs'));
133
+ console.log(chalk.gray('Check status: natureco gateway status'));
134
+ console.log(chalk.gray('Stop gateway: natureco gateway stop\n'));
135
+
136
+ process.exit(0);
137
+ }
138
+
139
+ async function runGatewayWorker() {
140
+ // This runs in the background
141
+ const pkg = require('../../package.json');
142
+ log('gateway', `Starting NatureCo Gateway v${pkg.version}...`, 'green');
143
+
144
+ // Load config
145
+ const { getConfig } = require('../utils/config');
146
+ const config = getConfig();
147
+
148
+ if (!config || !config.providerUrl) {
149
+ log('gateway', 'Setup yapılmamış. Run "natureco setup" first.', 'red');
150
+ process.exit(1);
151
+ }
152
+
153
+ // Store provider instances globally for HTTP endpoint
154
+ global.whatsappSock = null;
155
+ global.telegramBot = null;
156
+ global.signalProvider = null;
157
+
158
+ // Start WhatsApp if configured
159
+ if (config.whatsappConnected && config.whatsappBotId) {
160
+ const sessionDir = path.join(os.homedir(), '.natureco', 'whatsapp-sessions', config.whatsappBotId);
161
+
162
+ if (fs.existsSync(sessionDir)) {
163
+ const phone = config.whatsappPhone?.split(':')[0].replace('@s.whatsapp.net', '') || 'unknown';
164
+ log('whatsapp', `starting provider (+${phone})`, 'cyan');
165
+ startWhatsAppProvider(sessionDir, config);
166
+ } else {
167
+ log('whatsapp', 'session not found, skipping', 'yellow');
168
+ }
169
+ } else {
170
+ log('whatsapp', 'not configured, skipping', 'gray');
171
+ }
172
+
173
+ // Start Telegram if configured
174
+ if (config.telegramToken && config.telegramBotId) {
175
+ log('telegram', 'starting bot...', 'cyan');
176
+ startTelegramProvider(config);
177
+ } else {
178
+ log('telegram', 'not configured, skipping', 'gray');
179
+ }
180
+
181
+ // Start Signal if configured
182
+ if (config.signalBotId && config.signalHttpUrl) {
183
+ log('signal', `starting provider (${config.signalAccount || config.signalHttpUrl})`, 'cyan');
184
+ startSignalProvider(config);
185
+ } else if (config.signalBotId) {
186
+ log('signal', 'not configured (no HTTP URL), skipping', 'gray');
187
+ } else {
188
+ log('signal', 'not configured, skipping', 'gray');
189
+ }
190
+ // Start IRC if configured
191
+ if (config.ircBotId && config.ircHost && config.ircNick) {
192
+ log('irc', `connecting to ${config.ircNick} @ ${config.ircHost}:${config.ircPort}`, 'cyan');
193
+ startIrcProvider(config);
194
+ } else if (config.ircBotId) {
195
+ log('irc', 'not configured (missing host or nick), skipping', 'gray');
196
+ } else {
197
+ log('irc', 'not configured, skipping', 'gray');
198
+ }
199
+ // Start Mattermost if configured
200
+ if (config.mattermostBotId && config.mattermostBaseUrl && config.mattermostToken) {
201
+ log('mattermost', `connecting to ${config.mattermostBaseUrl}`, 'cyan');
202
+ startMattermostProvider(config);
203
+ } else if (config.mattermostBotId) {
204
+ log('mattermost', 'not configured (missing URL or token), skipping', 'gray');
205
+ } else {
206
+ log('mattermost', 'not configured, skipping', 'gray');
207
+ }
208
+ // Start iMessage if configured
209
+ if (config.imessageBotId) {
210
+ if (process.platform === 'darwin') {
211
+ log('imessage', 'starting provider (macOS)', 'cyan');
212
+ startImessageProvider(config);
213
+ } else {
214
+ log('imessage', 'macOS only, skipping', 'yellow');
215
+ }
216
+ } else {
217
+ log('imessage', 'not configured, skipping', 'gray');
218
+ }
219
+ // Start SMS provider if configured
220
+ if (config.smsBotId && config.smsAccountSid && config.smsAuthToken) {
221
+ log('sms', `starting provider (${config.smsFromNumber || 'Messaging Service'})`, 'cyan');
222
+ startSmsProvider(config);
223
+ } else if (config.smsBotId) {
224
+ log('sms', 'not configured (missing Account SID), skipping', 'gray');
225
+ } else {
226
+ log('sms', 'not configured, skipping', 'gray');
227
+ }
228
+ if (config.webhooks?.length) log('webhooks', `${config.webhooks.length} route(s) configured`, 'gray');
229
+
230
+ // Start HTTP server for message sending
231
+ startHttpServer();
232
+
233
+ // Load and start cron jobs
234
+ startCronJobs(config);
235
+
236
+ // Health check every 60 seconds
237
+ setInterval(async () => {
238
+ const alive = fs.existsSync(PID_FILE) && (() => { try { process.kill(process.pid, 0); return true; } catch { return false; } })();
239
+ const wsOk = global.gatewayWs && global.gatewayWs.readyState === 1;
240
+ if (alive && wsOk) {
241
+ log('gateway', 'health check: OK', 'gray');
242
+ } else {
243
+ log('gateway', `health check: WARN (alive=${alive}, ws=${wsOk})`, 'yellow');
244
+ }
245
+ }, 60000);
246
+
247
+ log('gateway', 'Gateway running in background', 'green');
248
+
249
+ // Handle shutdown
250
+ const shutdown = () => {
251
+ log('gateway', 'Shutting down...', 'yellow');
252
+ if (global.signalProvider?.ws) {
253
+ try { global.signalProvider.ws.close(); } catch {}
254
+ }
255
+ if (signalPollingInterval) {
256
+ clearInterval(signalPollingInterval);
257
+ signalPollingInterval = null;
258
+ }
259
+ stopIrcProvider();
260
+ stopMattermostProvider();
261
+ stopImessageProvider();
262
+ stopSmsProvider();
263
+ if (fs.existsSync(PID_FILE)) {
264
+ fs.unlinkSync(PID_FILE);
265
+ }
266
+ process.exit(0);
267
+ };
268
+
269
+ process.on('SIGINT', shutdown);
270
+ process.on('SIGTERM', shutdown);
271
+ }
272
+
273
+ async function startWhatsAppProvider(sessionDir, config) {
274
+ try {
275
+ const { makeWASocket, useMultiFileAuthState, DisconnectReason, fetchLatestBaileysVersion, Browsers } = loadBaileys();
276
+
277
+ const { state, saveCreds } = await useMultiFileAuthState(sessionDir);
278
+ const { version } = await fetchLatestBaileysVersion();
279
+
280
+ // Track last bot reply to prevent infinite loop
281
+ let lastBotReply = '';
282
+
283
+ const sock = makeWASocket({
284
+ version,
285
+ auth: state,
286
+ printQRInTerminal: false,
287
+ logger: getBaileysLogger(),
288
+ browser: Browsers.ubuntu('Chrome'),
289
+ connectTimeoutMs: 60000,
290
+ defaultQueryTimeoutMs: 60000,
291
+ keepAliveIntervalMs: 10000,
292
+ retryRequestDelayMs: 2000,
293
+ });
294
+
295
+ // Connection update handler
296
+ sock.ev.on('connection.update', async (update) => {
297
+ const { connection, lastDisconnect } = update;
298
+
299
+ if (connection === 'close') {
300
+ const statusCode = lastDisconnect?.error?.output?.statusCode;
301
+
302
+ // Remove all event listeners before reconnecting
303
+ sock.ev.removeAllListeners('connection.update');
304
+ sock.ev.removeAllListeners('messages.upsert');
305
+ sock.ev.removeAllListeners('creds.update');
306
+
307
+ if (statusCode === 515 || statusCode === 408) {
308
+ log('whatsapp', 'connection lost, reconnecting in 10s...', 'yellow');
309
+ setTimeout(() => startWhatsAppProvider(sessionDir, config), 10000);
310
+ return;
311
+ } else if (statusCode === 401) {
312
+ log('whatsapp', 'session expired, please reconnect', 'red');
313
+ } else {
314
+ log('whatsapp', `disconnected (code: ${statusCode}), reconnecting in 10s...`, 'yellow');
315
+ setTimeout(() => startWhatsAppProvider(sessionDir, config), 10000);
316
+ }
317
+ } else if (connection === 'open') {
318
+ const phone = sock.user?.id?.split(':')[0].replace('@s.whatsapp.net', '') || 'unknown';
319
+ log('whatsapp', `Connected successfully`, 'green');
320
+ log('whatsapp', `Listening for inbound messages.`, 'cyan');
321
+ log('whatsapp', `Phone: +${phone}`, 'gray');
322
+
323
+ // Store socket globally for HTTP endpoint
324
+ global.whatsappSock = sock;
325
+ }
326
+ });
327
+
328
+ sock.ev.on('messages.upsert', async ({ messages }) => {
329
+ log('whatsapp', `messages.upsert event triggered, ${messages.length} message(s)`, 'gray');
330
+
331
+ for (const msg of messages) {
332
+ // Log raw message info
333
+ log('whatsapp', `Raw message - fromMe: ${msg.key.fromMe}, remoteJid: ${msg.key.remoteJid}`, 'gray');
334
+
335
+ const remoteJid = msg.key.remoteJid || '';
336
+ const isLID = remoteJid.includes('@lid');
337
+
338
+ // Skip bot's own replies (fromMe=true and not LID, or matches last bot reply)
339
+ const messageText = msg.message?.conversation ||
340
+ msg.message?.extendedTextMessage?.text ||
341
+ msg.message?.imageMessage?.caption ||
342
+ msg.message?.videoMessage?.caption ||
343
+ msg.message?.buttonsResponseMessage?.selectedDisplayText ||
344
+ msg.message?.listResponseMessage?.title ||
345
+ msg.message?.ephemeralMessage?.message?.conversation ||
346
+ msg.message?.viewOnceMessage?.message?.conversation ||
347
+ '';
348
+
349
+ // Skip if this is the bot's own reply
350
+ if (msg.key.fromMe && !isLID) {
351
+ log('whatsapp', 'Skipping bot own reply (fromMe=true, not LID)', 'gray');
352
+ continue;
353
+ }
354
+
355
+ // Skip if message matches last bot reply (prevent loop)
356
+ if (messageText === lastBotReply && lastBotReply !== '') {
357
+ log('whatsapp', 'Skipping duplicate bot reply (matches last sent)', 'gray');
358
+ continue;
359
+ }
360
+
361
+ // Handle LID format (new WhatsApp format)
362
+ if (isLID && !msg.key.fromMe) {
363
+ log('whatsapp', 'LID format detected, fromMe=false, blocked', 'yellow');
364
+ continue;
365
+ }
366
+
367
+ const sender = remoteJid.split('@')[0].split(':')[0];
368
+ const allowedNumbers = config.whatsappAllowedNumbers || [];
369
+
370
+ // Log incoming number
371
+ log('whatsapp', `Incoming from: +${sender}, allowed: ${JSON.stringify(allowedNumbers)}`, 'gray');
372
+
373
+ // Access control - skip if fromMe + LID (own conversation)
374
+ if (!(msg.key.fromMe && isLID) && allowedNumbers.length > 0 && !allowedNumbers.some(n => numberMatches(n, sender))) {
375
+ log('whatsapp', `blocked message from +${sender} (not in allowed list)`, 'yellow');
376
+ continue;
377
+ }
378
+
379
+ if (!messageText) {
380
+ log('whatsapp', `Message without text content. Keys: ${Object.keys(msg.message || {}).join(', ')}`, 'gray');
381
+ continue;
382
+ }
383
+
384
+ const ownNumber = sock.user?.id?.split(':')[0].replace('@s.whatsapp.net', '') || 'unknown';
385
+ log('whatsapp', `Inbound message +${sender} -> +${ownNumber} (${messageText.length} chars)`, 'cyan');
386
+
387
+ try {
388
+ const { sendMessage } = require('../utils/api');
389
+ const { getMemoryPrompt, extractMemoryFromMessage, addMemoryEntry } = require('../utils/memory');
390
+ const { getConfig } = require('../utils/config');
391
+ const cfg = getConfig();
392
+
393
+ log('whatsapp', 'Sending to AI provider...', 'cyan');
394
+
395
+ // Use WhatsApp-specific conversation ID for persistent history
396
+ const conversationId = `whatsapp_${sender.replace(/\D/g, '')}`;
397
+
398
+ // Use same botId as terminal for shared memory
399
+ const botId = 'universal-provider';
400
+ const memoryPrompt = getMemoryPrompt(botId);
401
+
402
+ // WhatsApp system prompt with memory
403
+ let systemPrompt = `You are a helpful WhatsApp assistant. Keep responses concise and friendly. Use emojis when appropriate. If users ask for file operations or system commands, politely explain that those features are available in the terminal version.`;
404
+
405
+ if (memoryPrompt) {
406
+ systemPrompt += '\n\n' + memoryPrompt;
407
+ }
408
+
409
+ const response = await sendMessage(cfg.providerApiKey || cfg.apiKey || '', botId, messageText, conversationId, systemPrompt);
410
+ const reply = response?.reply || response?.message || '';
411
+
412
+ if (reply) {
413
+ log('whatsapp', 'Sending reply...', 'cyan');
414
+
415
+ await sock.sendMessage(msg.key.remoteJid, { text: reply });
416
+
417
+ // Store last bot reply to prevent loop
418
+ lastBotReply = reply;
419
+
420
+ log('whatsapp', `Reply sent (${reply.length} chars)`, 'green');
421
+
422
+ // Extract and save memory from user message
423
+ const memoryEntries = extractMemoryFromMessage(messageText);
424
+ for (const entry of memoryEntries) {
425
+ addMemoryEntry(botId, entry.key, entry.value);
426
+ log('whatsapp', `Memory saved: ${entry.key} = ${entry.value}`, 'gray');
427
+ }
428
+ } else {
429
+ log('whatsapp', 'No reply from provider', 'yellow');
430
+ }
431
+ } catch (err) {
432
+ log('whatsapp', `Provider error: ${err.message}`, 'red');
433
+ }
434
+ }
435
+ });
436
+
437
+ sock.ev.on('creds.update', saveCreds);
438
+
439
+ } catch (err) {
440
+ log('whatsapp', `Failed to start: ${err.message}`, 'red');
441
+ log('whatsapp', 'Retrying in 10s...', 'yellow');
442
+ setTimeout(() => startWhatsAppProvider(sessionDir, config), 10000);
443
+ }
444
+ }
445
+
446
+
447
+ async function startDiscordProvider(config) {
448
+ try {
449
+ let Discord;
450
+ try {
451
+ Discord = require('discord.js');
452
+ } catch (e) {
453
+ log('discord', 'discord.js not installed. Run: npm install discord.js', 'red');
454
+ return;
455
+ }
456
+
457
+ const { Client, GatewayIntentBits, Events } = Discord;
458
+ const client = new Client({
459
+ intents: [
460
+ GatewayIntentBits.Guilds,
461
+ GatewayIntentBits.GuildMessages,
462
+ GatewayIntentBits.MessageContent,
463
+ GatewayIntentBits.DirectMessages,
464
+ ]
465
+ });
466
+
467
+ const botId = config.discordBotId || `discord_${Date.now()}`;
468
+ global.discordClient = client;
469
+ global.discordBotId = botId;
470
+
471
+ client.once(Events.ClientReady, (c) => {
472
+ log('discord', `bot online as ${c.user.tag}`, 'green');
473
+ config.discordBotId = botId;
474
+ saveConfig(config);
475
+ });
476
+
477
+ client.on(Events.MessageCreate, async (message) => {
478
+ if (message.author.bot) return;
479
+ const chatId = message.channel.id;
480
+ const allowedChats = config.discordAllowedChats || [];
481
+ if (allowedChats.length > 0 && !allowedChats.includes(chatId)) return;
482
+
483
+ try {
484
+ const response = await callProviderForGateway(config, message.content, {
485
+ chatId, userId: message.author.id, channel: 'discord'
486
+ });
487
+ if (response) {
488
+ // Discord 2000 char limit
489
+ const chunks = response.match(/.{1,1900}/gs) || [response];
490
+ for (const chunk of chunks) {
491
+ await message.reply(chunk);
492
+ }
493
+ }
494
+ } catch (e) {
495
+ log('discord', `error: ${e.message}`, 'red');
496
+ }
497
+ });
498
+
499
+ await client.login(config.discordToken);
500
+ } catch (e) {
501
+ log('discord', `failed: ${e.message}`, 'red');
502
+ }
503
+ }
504
+
505
+ async function startSlackProvider(config) {
506
+ try {
507
+ const { WebClient } = require('@slack/web-api');
508
+ const client = new WebClient(config.slackToken);
509
+
510
+ const botId = config.slackBotId || `slack_${Date.now()}`;
511
+ global.slackClient = client;
512
+ global.slackBotId = botId;
513
+
514
+ const authResult = await client.auth.test();
515
+ log('slack', `bot online as ${authResult.user}`, 'green');
516
+ config.slackBotId = botId;
517
+ saveConfig(config);
518
+
519
+ // Slack RTM veya events API kullan
520
+ // Not: Tam real-time icin Bolt framework gerekli, su an basit polling
521
+ log('slack', 'polling mode active (use Bolt SDK for real-time)', 'gray');
522
+ } catch (e) {
523
+ log('slack', `failed: ${e.message}`, 'red');
524
+ }
525
+ }
526
+
527
+ async function callProviderForGateway(config, message, context) {
528
+ // Gateway icin LLM API cagir
529
+ const apiMessages = [{ role: 'user', content: message }];
530
+ return new Promise((resolve, reject) => {
531
+ const providerUrl = config.providerUrl;
532
+ const providerApiKey = config.providerApiKey;
533
+ const model = config.providerModel;
534
+
535
+ const isMM = providerUrl && (providerUrl.includes('minimax') || providerUrl.includes('minimaxi'));
536
+ const endpoint = isMM
537
+ ? `${providerUrl.replace(/\/+$/, '')}/v1/text/chatcompletion_v2`
538
+ : `${providerUrl.replace(/\/+$/, '')}/chat/completions`;
539
+
540
+ const data = JSON.stringify({
541
+ model, messages: apiMessages, temperature: 0.7, max_tokens: 2048
542
+ });
543
+
544
+ const url = new URL(endpoint);
545
+ const req = https.request({
546
+ hostname: url.hostname,
547
+ port: url.port || 443,
548
+ path: url.pathname,
549
+ method: 'POST',
550
+ headers: {
551
+ 'Authorization': `Bearer ${providerApiKey}`,
552
+ 'Content-Type': 'application/json',
553
+ 'Content-Length': Buffer.byteLength(data)
554
+ },
555
+ timeout: 30000
556
+ }, (res) => {
557
+ let body = '';
558
+ res.on('data', c => body += c);
559
+ res.on('end', () => {
560
+ try {
561
+ const parsed = JSON.parse(body);
562
+ const reply = parsed.choices?.[0]?.message?.content;
563
+ resolve(reply || 'Cevap uretilemedi');
564
+ } catch (e) {
565
+ reject(new Error('Parse hatasi: ' + e.message));
566
+ }
567
+ });
568
+ });
569
+ req.on('error', reject);
570
+ req.on('timeout', () => req.destroy());
571
+ req.write(data);
572
+ req.end();
573
+ });
574
+ }
575
+
576
+ async function startTelegramProvider(config) {
577
+ try {
578
+ // Lazy load node-telegram-bot-api
579
+ let TelegramBot;
580
+ try {
581
+ TelegramBot = require('node-telegram-bot-api');
582
+ } catch (err) {
583
+ log('telegram', 'node-telegram-bot-api not installed', 'red');
584
+ log('telegram', 'Install with: npm install -g node-telegram-bot-api', 'yellow');
585
+ return;
586
+ }
587
+
588
+ const bot = new TelegramBot(config.telegramToken, { polling: true });
589
+
590
+ log('telegram', 'Bot started successfully', 'green');
591
+ log('telegram', 'Listening for messages...', 'cyan');
592
+
593
+ bot.on('message', async (msg) => {
594
+ const chatId = msg.chat.id;
595
+ const messageText = msg.text;
596
+
597
+ if (!messageText) {
598
+ log('telegram', `Message without text from chat ${chatId}`, 'gray');
599
+ return;
600
+ }
601
+
602
+ // Access control - check allowed chats
603
+ const allowedChats = config.telegramAllowedChats || [];
604
+ if (allowedChats.length > 0 && !allowedChats.includes(String(chatId))) {
605
+ log('telegram', `Blocked message from chat ${chatId} (not in allowed list)`, 'yellow');
606
+ return;
607
+ }
608
+
609
+ log('telegram', `Inbound message from chat ${chatId} (${messageText.length} chars)`, 'cyan');
610
+
611
+ try {
612
+ const { sendMessage } = require('../utils/api');
613
+ const { getMemoryPrompt, extractMemoryFromMessage, addMemoryEntry } = require('../utils/memory');
614
+ const { getConfig } = require('../utils/config');
615
+ const cfg = getConfig();
616
+
617
+ log('telegram', 'Sending to AI provider...', 'cyan');
618
+
619
+ // Use Telegram-specific conversation ID for persistent history
620
+ const conversationId = `telegram_${chatId}`;
621
+
622
+ // Use same botId as terminal for shared memory
623
+ const botId = 'universal-provider';
624
+ const memoryPrompt = getMemoryPrompt(botId);
625
+
626
+ // Telegram system prompt with memory
627
+ let systemPrompt = `You are a helpful Telegram assistant. Keep responses concise and friendly. Use emojis when appropriate. If users ask for file operations or system commands, politely explain that those features are available in the terminal version.`;
628
+
629
+ if (memoryPrompt) {
630
+ systemPrompt += '\n\n' + memoryPrompt;
631
+ }
632
+
633
+ const response = await sendMessage(cfg.providerApiKey || cfg.apiKey || '', botId, messageText, conversationId, systemPrompt);
634
+ const reply = response?.reply || response?.message || '';
635
+
636
+ if (reply) {
637
+ log('telegram', 'Sending reply...', 'cyan');
638
+
639
+ await bot.sendMessage(chatId, reply);
640
+
641
+ log('telegram', `Reply sent (${reply.length} chars)`, 'green');
642
+
643
+ // Extract and save memory from user message
644
+ const memoryEntries = extractMemoryFromMessage(messageText);
645
+ for (const entry of memoryEntries) {
646
+ addMemoryEntry(botId, entry.key, entry.value);
647
+ log('telegram', `Memory saved: ${entry.key} = ${entry.value}`, 'gray');
648
+ }
649
+ } else {
650
+ log('telegram', 'No reply from provider', 'yellow');
651
+ }
652
+ } catch (err) {
653
+ log('telegram', `Provider error: ${err.message}`, 'red');
654
+
655
+ // Send error message to user
656
+ try {
657
+ await bot.sendMessage(chatId, ' Bir hata oluştu. Lütfen tekrar deneyin.');
658
+ } catch (sendErr) {
659
+ log('telegram', `Failed to send error message: ${sendErr.message}`, 'red');
660
+ }
661
+ }
662
+ });
663
+
664
+ bot.on('polling_error', (error) => {
665
+ log('telegram', `Polling error: ${error.message}`, 'red');
666
+ });
667
+
668
+ // Store bot globally for HTTP endpoint
669
+ global.telegramBot = bot;
670
+
671
+ } catch (err) {
672
+ log('telegram', `Failed to start: ${err.message}`, 'red');
673
+ }
674
+ }
675
+
676
+ async function startSignalProvider(config) {
677
+ const baseUrl = config.signalHttpUrl;
678
+ const account = config.signalAccount;
679
+ const mode = config.signalApiMode || 'auto';
680
+
681
+ // Detect API mode
682
+ let detectedMode = mode;
683
+ if (mode === 'auto') {
684
+ try {
685
+ const nativeRes = await fetch(`${baseUrl}/api/v1/check`, { signal: AbortSignal.timeout(5000) });
686
+ if (nativeRes.ok) {
687
+ detectedMode = 'native';
688
+ log('signal', 'detected native JSON-RPC API', 'gray');
689
+ }
690
+ } catch {}
691
+ if (detectedMode === 'auto') {
692
+ try {
693
+ const containerRes = await fetch(`${baseUrl}/v1/about`, { signal: AbortSignal.timeout(5000) });
694
+ if (containerRes.ok) {
695
+ detectedMode = 'container';
696
+ log('signal', 'detected container REST API', 'gray');
697
+ }
698
+ } catch {}
699
+ }
700
+ if (detectedMode === 'auto') {
701
+ log('signal', 'API not reachable, will retry', 'yellow');
702
+ }
703
+ }
704
+
705
+ if (detectedMode === 'auto' || detectedMode === 'none') {
706
+ log('signal', 'API unreachable, will start daemon on demand', 'gray');
707
+ return;
708
+ }
709
+
710
+ global.signalProvider = { baseUrl, account, mode: detectedMode };
711
+
712
+ // Start event stream for incoming messages (native mode uses SSE)
713
+ if (detectedMode === 'native') {
714
+ startSignalEventStream(baseUrl, account, config);
715
+ } else if (detectedMode === 'container') {
716
+ startSignalWebSocket(baseUrl, account, config);
717
+ }
718
+
719
+ log('signal', 'provider ready', 'green');
720
+ }
721
+
722
+ async function startSignalEventStream(baseUrl, account, config) {
723
+ const eventsUrl = `${baseUrl}/api/v1/events?account=${encodeURIComponent(account)}`;
724
+
725
+ const connect = async () => {
726
+ try {
727
+ const response = await fetch(eventsUrl, {
728
+ signal: AbortSignal.timeout(300000),
729
+ headers: { 'Accept': 'text/event-stream' },
730
+ });
731
+
732
+ if (!response.ok) {
733
+ throw new Error(`SSE HTTP ${response.status}`);
734
+ }
735
+
736
+ log('signal', 'SSE event stream connected', 'cyan');
737
+ const reader = response.body.getReader();
738
+ const decoder = new TextDecoder();
739
+ let buffer = '';
740
+
741
+ while (true) {
742
+ const { done, value } = await reader.read();
743
+ if (done) break;
744
+
745
+ buffer += decoder.decode(value, { stream: true });
746
+ const lines = buffer.split('\n');
747
+ buffer = lines.pop() || '';
748
+
749
+ let eventType = '';
750
+ let eventData = '';
751
+
752
+ for (const line of lines) {
753
+ if (line.startsWith('event: ')) {
754
+ eventType = line.slice(7).trim();
755
+ } else if (line.startsWith('data: ')) {
756
+ eventData = line.slice(6).trim();
757
+ } else if (line === '' && eventData) {
758
+ processSignalEvent(eventType, eventData, config);
759
+ eventType = '';
760
+ eventData = '';
761
+ }
762
+ }
763
+ }
764
+ } catch (err) {
765
+ if (err.name === 'AbortError') {
766
+ log('signal', 'SSE stream timeout, reconnecting...', 'yellow');
767
+ } else {
768
+ log('signal', `SSE error: ${err.message}, reconnecting in 10s...`, 'yellow');
769
+ }
770
+ }
771
+
772
+ setTimeout(connect, 10000);
773
+ };
774
+
775
+ connect();
776
+ }
777
+
778
+ async function startSignalWebSocket(baseUrl, account, config) {
779
+ const wsUrl = baseUrl.replace(/^http/, 'ws') + `/v1/receive/${encodeURIComponent(account)}`;
780
+
781
+ const connect = async () => {
782
+ try {
783
+ let WebSocket;
784
+ try {
785
+ WebSocket = require('ws');
786
+ } catch {
787
+ log('signal', 'ws module not available, falling back to polling', 'yellow');
788
+ startSignalPolling(baseUrl, account, config);
789
+ return;
790
+ }
791
+
792
+ const ws = new WebSocket(wsUrl);
793
+
794
+ ws.on('open', () => {
795
+ log('signal', 'WebSocket event stream connected', 'cyan');
796
+ });
797
+
798
+ ws.on('message', (data) => {
799
+ try {
800
+ const envelope = JSON.parse(data.toString());
801
+ processSignalEnvelope(envelope, config);
802
+ } catch (err) {
803
+ log('signal', `Failed to parse WS message: ${err.message}`, 'red');
804
+ }
805
+ });
806
+
807
+ ws.on('close', (code) => {
808
+ log('signal', `WebSocket closed (code ${code}), reconnecting in 10s...`, 'yellow');
809
+ setTimeout(connect, 10000);
810
+ });
811
+
812
+ ws.on('error', (err) => {
813
+ log('signal', `WebSocket error: ${err.message}`, 'red');
814
+ ws.close();
815
+ });
816
+
817
+ global.signalProvider.ws = ws;
818
+
819
+ } catch (err) {
820
+ log('signal', `WebSocket failed: ${err.message}, retrying in 10s...`, 'yellow');
821
+ setTimeout(connect, 10000);
822
+ }
823
+ };
824
+
825
+ connect();
826
+ }
827
+
828
+ // Fallback polling for container mode when ws is not available
829
+ let signalPollingInterval = null;
830
+ function startSignalPolling(baseUrl, account, config) {
831
+ if (signalPollingInterval) return;
832
+ log('signal', 'starting polling fallback (30s interval)', 'gray');
833
+ signalPollingInterval = setInterval(async () => {
834
+ try {
835
+ const res = await fetch(`${baseUrl}/v1/receive/${encodeURIComponent(account)}`, {
836
+ signal: AbortSignal.timeout(15000),
837
+ });
838
+ if (res.ok) {
839
+ const envelopes = await res.json();
840
+ if (Array.isArray(envelopes)) {
841
+ for (const envelope of envelopes) {
842
+ processSignalEnvelope(envelope, config);
843
+ }
844
+ }
845
+ }
846
+ } catch {}
847
+ }, 30000);
848
+ }
849
+
850
+ async function processSignalEvent(eventType, eventData, config) {
851
+ try {
852
+ const data = JSON.parse(eventData);
853
+ if (data.envelope) {
854
+ processSignalEnvelope(data.envelope, config);
855
+ }
856
+ } catch (err) {
857
+ log('signal', `Event parse error: ${err.message}`, 'red');
858
+ }
859
+ }
860
+
861
+ async function processSignalEnvelope(envelope, config) {
862
+ try {
863
+ const dataMessage = envelope.dataMessage || envelope.syncMessage?.sent?.message;
864
+ if (!dataMessage) return;
865
+
866
+ const sender = envelope.sourceUuid || envelope.sourceNumber || envelope.sourceName;
867
+ if (!sender) return;
868
+
869
+ // Skip self-messages
870
+ if (envelope.sourceNumber === config.signalAccount) return;
871
+
872
+ const messageText = dataMessage.message || '';
873
+ if (!messageText.trim()) return;
874
+
875
+ log('signal', `Inbound from ${sender}: "${messageText.slice(0, 80)}"`, 'cyan');
876
+
877
+ // Check DM policy
878
+ const dmPolicy = config.signalDmPolicy || 'pairing';
879
+ if (dmPolicy === 'disabled') return;
880
+
881
+ // Get AI response
882
+ try {
883
+ const { sendMessage } = require('../utils/api');
884
+ const { getMemoryPrompt, extractMemoryFromMessage, addMemoryEntry } = require('../utils/memory');
885
+ const { getConfig } = require('../utils/config');
886
+ const cfg = getConfig();
887
+
888
+ const conversationId = `signal_${sender}`;
889
+ const botId = 'universal-provider';
890
+ const memoryPrompt = getMemoryPrompt(botId);
891
+
892
+ let systemPrompt = `You are a helpful Signal assistant. Keep responses concise.`;
893
+ if (memoryPrompt) {
894
+ systemPrompt += '\n\n' + memoryPrompt;
895
+ }
896
+
897
+ const response = await sendMessage(
898
+ cfg.providerApiKey || cfg.apiKey || '', botId, messageText,
899
+ conversationId, systemPrompt
900
+ );
901
+ const reply = response?.reply || response?.message || '';
902
+
903
+ if (reply) {
904
+ await sendSignalMessage(config, sender, reply);
905
+ log('signal', `Reply sent to ${sender} (${reply.length} chars)`, 'green');
906
+
907
+ const memoryEntries = extractMemoryFromMessage(messageText);
908
+ for (const entry of memoryEntries) {
909
+ addMemoryEntry(botId, entry.key, entry.value);
910
+ }
911
+ }
912
+ } catch (err) {
913
+ log('signal', `Response error: ${err.message}`, 'red');
914
+ }
915
+ } catch (err) {
916
+ log('signal', `Envelope error: ${err.message}`, 'red');
917
+ }
918
+ }
919
+
920
+ async function sendSignalMessage(config, recipient, message) {
921
+ const baseUrl = config.signalHttpUrl;
922
+ const account = config.signalAccount;
923
+ const mode = config.signalApiMode || 'auto';
924
+
925
+ if (mode === 'native' || mode === 'auto') {
926
+ // Try native JSON-RPC first
927
+ const rpcPayload = {
928
+ jsonrpc: '2.0',
929
+ method: 'send',
930
+ params: {
931
+ recipient: [recipient],
932
+ message,
933
+ account,
934
+ },
935
+ id: Date.now(),
936
+ };
937
+
938
+ try {
939
+ const res = await fetch(`${baseUrl}/api/v1/rpc`, {
940
+ method: 'POST',
941
+ headers: { 'Content-Type': 'application/json' },
942
+ body: JSON.stringify(rpcPayload),
943
+ signal: AbortSignal.timeout(30000),
944
+ });
945
+ if (res.ok) return;
946
+ } catch {}
947
+ }
948
+
949
+ // Fallback to container mode
950
+ const containerPayload = {
951
+ message,
952
+ number: account,
953
+ recipients: [recipient],
954
+ text_mode: 'normal',
955
+ };
956
+
957
+ const res = await fetch(`${baseUrl}/v2/send`, {
958
+ method: 'POST',
959
+ headers: { 'Content-Type': 'application/json' },
960
+ body: JSON.stringify(containerPayload),
961
+ signal: AbortSignal.timeout(30000),
962
+ });
963
+
964
+ if (!res.ok) {
965
+ const text = await res.text();
966
+ throw new Error(`Signal send failed (HTTP ${res.status}): ${text.slice(0, 200)}`);
967
+ }
968
+ }
969
+
970
+ // IRC provider
971
+ let ircClient = null;
972
+ let ircReconnectTimer = null;
973
+
974
+ async function startIrcProvider(config) {
975
+ if (ircClient) {
976
+ log('irc', 'already connected', 'yellow');
977
+ return;
978
+ }
979
+
980
+ const net = require('net');
981
+ const tls = require('tls');
982
+
983
+ const host = config.ircHost;
984
+ const port = config.ircPort || 6697;
985
+ const useTls = config.ircTls !== false;
986
+ const nick = config.ircNick;
987
+ const username = config.ircUsername || nick;
988
+ const realname = config.ircRealname || 'NatureCo';
989
+ const password = config.ircPassword || '';
990
+ const channels = config.ircChannels || [];
991
+ const nickservEnabled = config.ircNickservEnabled !== false;
992
+ const nickservPassword = config.ircNickservPassword || '';
993
+
994
+ let buffer = '';
995
+ let currentNick = nick;
996
+ let registered = false;
997
+ let quitRequested = false;
998
+
999
+ const connect = () => {
1000
+ if (quitRequested) return;
1001
+
1002
+ registered = false;
1003
+ const socket = useTls
1004
+ ? tls.connect({ host, port, servername: host })
1005
+ : net.createConnection({ host, port });
1006
+
1007
+ socket.setEncoding('utf8');
1008
+
1009
+ const sendRaw = (line) => {
1010
+ socket.write(line + '\r\n');
1011
+ };
1012
+
1013
+ socket.once('connect', () => {
1014
+ log('irc', `connected to ${host}:${port}`, 'green');
1015
+ if (password) sendRaw(`PASS ${password}`);
1016
+ sendRaw(`NICK ${currentNick}`);
1017
+ sendRaw(`USER ${username} 0 * :${realname}`);
1018
+ });
1019
+
1020
+ socket.on('data', (chunk) => {
1021
+ buffer += chunk;
1022
+ const lines = buffer.split('\r\n');
1023
+ buffer = lines.pop() || '';
1024
+
1025
+ for (const line of lines) {
1026
+ if (!line.trim()) continue;
1027
+
1028
+ // PING handler
1029
+ if (line.startsWith('PING ')) {
1030
+ sendRaw('PONG ' + line.slice(5));
1031
+ continue;
1032
+ }
1033
+
1034
+ // Parse IRC message
1035
+ const parsed = parseIrcLine(line);
1036
+ if (!parsed) continue;
1037
+
1038
+ // Track nick changes
1039
+ if (parsed.command === 'NICK' && parsed.prefixNick === currentNick) {
1040
+ currentNick = parsed.trailing || parsed.params[0];
1041
+ }
1042
+
1043
+ // Welcome — server ready
1044
+ if (parsed.command === '001') {
1045
+ registered = true;
1046
+ log('irc', `registered as ${currentNick}`, 'cyan');
1047
+
1048
+ // NickServ auth
1049
+ if (nickservEnabled && nickservPassword) {
1050
+ sendRaw(`PRIVMSG NickServ :IDENTIFY ${nickservPassword}`);
1051
+ log('irc', 'NickServ IDENTIFY sent', 'gray');
1052
+ }
1053
+
1054
+ // Join channels
1055
+ for (const ch of channels) {
1056
+ sendRaw(`JOIN ${ch.startsWith('#') ? ch : '#' + ch}`);
1057
+ log('irc', `joining ${ch.startsWith('#') ? ch : '#' + ch}`, 'gray');
1058
+ }
1059
+
1060
+ global.ircClient = { sendRaw, isReady: () => registered };
1061
+ continue;
1062
+ }
1063
+
1064
+ // Handle PRIVMSG (incoming messages)
1065
+ if (parsed.command === 'PRIVMSG' && registered) {
1066
+ const senderNick = parsed.prefixNick;
1067
+ if (senderNick === currentNick) continue; // skip self
1068
+
1069
+ const target = parsed.params[0];
1070
+ const text = parsed.trailing || '';
1071
+
1072
+ if (!text.trim()) continue;
1073
+
1074
+ const isChannel = target.startsWith('#');
1075
+ log('irc', `${isChannel ? target : senderNick}: ${text.slice(0, 100)}`, 'cyan');
1076
+
1077
+ // In DM or channel message with mention
1078
+ const isDirect = !isChannel;
1079
+ if (!isDirect && !text.toLowerCase().includes(currentNick.toLowerCase())) continue;
1080
+
1081
+ processIrcMessage(config, senderNick, target, text, socket);
1082
+ }
1083
+ }
1084
+ });
1085
+
1086
+ socket.on('close', () => {
1087
+ global.ircClient = null;
1088
+ if (!quitRequested) {
1089
+ log('irc', 'connection lost, reconnecting in 10s...', 'yellow');
1090
+ ircReconnectTimer = setTimeout(connect, 10000);
1091
+ }
1092
+ });
1093
+
1094
+ socket.on('error', (err) => {
1095
+ log('irc', `socket error: ${err.message}`, 'red');
1096
+ });
1097
+
1098
+ ircClient = socket;
1099
+ };
1100
+
1101
+ connect();
1102
+ }
1103
+ function parseIrcLine(line) {
1104
+ const result = { raw: line, prefix: null, prefixNick: null, command: '', params: [], trailing: '' };
1105
+ let rest = line;
1106
+
1107
+ if (rest.startsWith(':')) {
1108
+ const spaceIdx = rest.indexOf(' ');
1109
+ if (spaceIdx === -1) return null;
1110
+ result.prefix = rest.slice(1, spaceIdx);
1111
+ const bangIdx = result.prefix.indexOf('!');
1112
+ result.prefixNick = bangIdx !== -1 ? result.prefix.slice(0, bangIdx) : result.prefix;
1113
+ rest = rest.slice(spaceIdx + 1);
1114
+ }
1115
+
1116
+ const spaceIdx = rest.indexOf(' ');
1117
+ if (spaceIdx === -1) {
1118
+ result.command = rest;
1119
+ return result;
1120
+ }
1121
+ result.command = rest.slice(0, spaceIdx);
1122
+ rest = rest.slice(spaceIdx + 1);
1123
+
1124
+ // Trailing
1125
+ const trailIdx = rest.indexOf(' :');
1126
+ if (trailIdx !== -1) {
1127
+ result.trailing = rest.slice(trailIdx + 2);
1128
+ rest = rest.slice(0, trailIdx);
1129
+ }
1130
+
1131
+ result.params = rest.split(' ').filter(Boolean);
1132
+ return result;
1133
+ }
1134
+
1135
+ async function processIrcMessage(config, sender, target, text, socket) {
1136
+ try {
1137
+ const { sendMessage } = require('../utils/api');
1138
+ const { getMemoryPrompt } = require('../utils/memory');
1139
+ const { getConfig } = require('../utils/config');
1140
+ const cfg = getConfig();
1141
+
1142
+ const conversationId = `irc_${sender}_${target}`;
1143
+ const botId = 'universal-provider';
1144
+ const memoryPrompt = getMemoryPrompt(botId);
1145
+
1146
+ let systemPrompt = `You are a helpful IRC assistant. Keep responses concise. Use IRC-friendly formatting.`;
1147
+ if (memoryPrompt) systemPrompt += '\n\n' + memoryPrompt;
1148
+
1149
+ const response = await sendMessage(
1150
+ cfg.providerApiKey || cfg.apiKey || '', botId, text,
1151
+ conversationId, systemPrompt
1152
+ );
1153
+ const reply = response?.reply || response?.message || '';
1154
+
1155
+ if (reply) {
1156
+ const ircTarget = target.startsWith('#') ? target : sender;
1157
+ sendIrcMessage(socket, ircTarget, reply);
1158
+ log('irc', `Reply sent to ${ircTarget} (${reply.length} chars)`, 'green');
1159
+ }
1160
+ } catch (err) {
1161
+ log('irc', `Response error: ${err.message}`, 'red');
1162
+ }
1163
+ }
1164
+
1165
+ function sendIrcMessage(socket, target, text) {
1166
+ if (!socket) return;
1167
+ const maxLen = 400;
1168
+ const lines = text.split('\n');
1169
+ for (const line of lines) {
1170
+ const cleanLine = line.replace(/\r/g, '').trim();
1171
+ if (!cleanLine) continue;
1172
+ if (cleanLine.length <= maxLen) {
1173
+ socket.write(`PRIVMSG ${target} :${cleanLine}\r\n`);
1174
+ } else {
1175
+ const words = cleanLine.split(' ');
1176
+ let chunk = '';
1177
+ for (const word of words) {
1178
+ if (chunk.length + word.length + 1 > maxLen) {
1179
+ socket.write(`PRIVMSG ${target} :${chunk}\r\n`);
1180
+ chunk = word;
1181
+ } else {
1182
+ chunk = chunk ? chunk + ' ' + word : word;
1183
+ }
1184
+ }
1185
+ if (chunk) socket.write(`PRIVMSG ${target} :${chunk}\r\n`);
1186
+ }
1187
+ }
1188
+ }
1189
+
1190
+ function stopIrcProvider() {
1191
+ if (ircReconnectTimer) {
1192
+ clearTimeout(ircReconnectTimer);
1193
+ ircReconnectTimer = null;
1194
+ }
1195
+ if (ircClient) {
1196
+ try {
1197
+ ircClient.write('QUIT :Gateway shutting down\r\n');
1198
+ ircClient.end();
1199
+ } catch {}
1200
+ ircClient = null;
1201
+ }
1202
+ global.ircClient = null;
1203
+ }
1204
+
1205
+ // Mattermost provider
1206
+ let mattermostWs = null;
1207
+ let mattermostReconnectTimer = null;
1208
+
1209
+ async function startMattermostProvider(config) {
1210
+ const baseUrl = config.mattermostBaseUrl.replace(/\/+$/, '');
1211
+ const token = config.mattermostToken;
1212
+
1213
+ // Fetch bot user info
1214
+ try {
1215
+ const meRes = await fetch(`${baseUrl}/api/v4/users/me`, {
1216
+ headers: { 'Authorization': `Bearer ${token}` },
1217
+ signal: AbortSignal.timeout(10000),
1218
+ });
1219
+ if (!meRes.ok) {
1220
+ log('mattermost', `API auth failed (HTTP ${meRes.status})`, 'red');
1221
+ return;
1222
+ }
1223
+ const me = await meRes.json();
1224
+ log('mattermost', `authenticated as @${me.username} (${me.id})`, 'green');
1225
+
1226
+ global.mattermostProvider = { baseUrl, token, userId: me.id, username: me.username };
1227
+
1228
+ // Connect WebSocket for real-time events
1229
+ connectMattermostWebSocket(baseUrl, token, config);
1230
+
1231
+ } catch (err) {
1232
+ log('mattermost', `connection failed: ${err.message}`, 'red');
1233
+ }
1234
+ }
1235
+
1236
+ function connectMattermostWebSocket(baseUrl, token, config) {
1237
+ const wsUrl = baseUrl.replace(/^http/, 'ws') + '/api/v4/websocket';
1238
+
1239
+ const connect = () => {
1240
+ try {
1241
+ let WebSocket;
1242
+ try {
1243
+ WebSocket = require('ws');
1244
+ } catch {
1245
+ log('mattermost', 'ws module not available', 'yellow');
1246
+ return;
1247
+ }
1248
+
1249
+ const ws = new WebSocket(wsUrl);
1250
+
1251
+ ws.on('open', () => {
1252
+ log('mattermost', 'WebSocket connected', 'cyan');
1253
+
1254
+ // Authenticate
1255
+ ws.send(JSON.stringify({
1256
+ seq: 1,
1257
+ action: 'authentication_challenge',
1258
+ data: { token },
1259
+ }));
1260
+ });
1261
+
1262
+ ws.on('message', (data) => {
1263
+ try {
1264
+ const msg = JSON.parse(data.toString());
1265
+
1266
+ if (msg.event === 'posted') {
1267
+ handleMattermostPost(msg.data, msg.broadcast, config);
1268
+ } else if (msg.event === 'hello') {
1269
+ log('mattermost', 'WebSocket authenticated', 'green');
1270
+ } else if (msg.event === 'user_added' || msg.event === 'user_removed') {
1271
+ // no-op
1272
+ }
1273
+ } catch (err) {
1274
+ log('mattermost', `message parse error: ${err.message}`, 'red');
1275
+ }
1276
+ });
1277
+
1278
+ ws.on('close', (code) => {
1279
+ log('mattermost', `WebSocket closed (code ${code})`, 'yellow');
1280
+ mattermostWs = null;
1281
+ mattermostReconnectTimer = setTimeout(connect, 10000);
1282
+ });
1283
+
1284
+ ws.on('error', (err) => {
1285
+ log('mattermost', `WebSocket error: ${err.message}`, 'red');
1286
+ ws.close();
1287
+ });
1288
+
1289
+ mattermostWs = ws;
1290
+
1291
+ } catch (err) {
1292
+ log('mattermost', `WebSocket connect failed: ${err.message}`, 'red');
1293
+ mattermostReconnectTimer = setTimeout(connect, 30000);
1294
+ }
1295
+ };
1296
+
1297
+ connect();
1298
+ }
1299
+
1300
+ async function handleMattermostPost(data, broadcast, config) {
1301
+ try {
1302
+ if (!data?.post) return;
1303
+ const post = typeof data.post === 'string' ? JSON.parse(data.post) : data.post;
1304
+
1305
+ // Skip bot's own messages
1306
+ if (post.user_id === global.mattermostProvider?.userId) return;
1307
+
1308
+ const channelId = broadcast?.channel_id || post.channel_id;
1309
+ if (!channelId) return;
1310
+
1311
+ const messageText = post.message || '';
1312
+ if (!messageText.trim()) return;
1313
+
1314
+ const senderId = post.user_id;
1315
+ log('mattermost', `Inbound from user ${senderId}: "${messageText.slice(0, 80)}"`, 'cyan');
1316
+
1317
+ // Skip system messages
1318
+ if (post.props?.from_webhook) return;
1319
+
1320
+ // Get AI response
1321
+ const { sendMessage } = require('../utils/api');
1322
+ const { getMemoryPrompt } = require('../utils/memory');
1323
+ const { getConfig } = require('../utils/config');
1324
+ const cfg = getConfig();
1325
+
1326
+ const conversationId = `mattermost_${channelId}`;
1327
+ const botId = 'universal-provider';
1328
+ const memoryPrompt = getMemoryPrompt(botId);
1329
+
1330
+ let systemPrompt = `You are a helpful Mattermost assistant. Keep responses concise.`;
1331
+ if (memoryPrompt) systemPrompt += '\n\n' + memoryPrompt;
1332
+
1333
+ const response = await sendMessage(
1334
+ cfg.providerApiKey || cfg.apiKey || '', botId, messageText,
1335
+ conversationId, systemPrompt
1336
+ );
1337
+ const reply = response?.reply || response?.message || '';
1338
+
1339
+ if (reply) {
1340
+ await sendMattermostMessage(config, channelId, reply);
1341
+ log('mattermost', `Reply sent to channel ${channelId} (${reply.length} chars)`, 'green');
1342
+ }
1343
+ } catch (err) {
1344
+ log('mattermost', `Post error: ${err.message}`, 'red');
1345
+ }
1346
+ }
1347
+
1348
+ async function sendMattermostMessage(config, channelId, message) {
1349
+ const baseUrl = config.mattermostBaseUrl.replace(/\/+$/, '');
1350
+ const token = config.mattermostToken;
1351
+
1352
+ const res = await fetch(`${baseUrl}/api/v4/posts`, {
1353
+ method: 'POST',
1354
+ headers: {
1355
+ 'Content-Type': 'application/json',
1356
+ 'Authorization': `Bearer ${token}`,
1357
+ },
1358
+ body: JSON.stringify({
1359
+ channel_id: channelId,
1360
+ message,
1361
+ }),
1362
+ signal: AbortSignal.timeout(15000),
1363
+ });
1364
+
1365
+ if (!res.ok) {
1366
+ const text = await res.text();
1367
+ throw new Error(`Mattermost send failed (HTTP ${res.status}): ${text.slice(0, 200)}`);
1368
+ }
1369
+ }
1370
+
1371
+ function stopMattermostProvider() {
1372
+ if (mattermostReconnectTimer) {
1373
+ clearTimeout(mattermostReconnectTimer);
1374
+ mattermostReconnectTimer = null;
1375
+ }
1376
+ if (mattermostWs) {
1377
+ try { mattermostWs.close(); } catch {}
1378
+ mattermostWs = null;
1379
+ }
1380
+ global.mattermostProvider = null;
1381
+ }
1382
+
1383
+ // iMessage provider
1384
+ let imessagePollInterval = null;
1385
+ let imessageLastMessageId = null;
1386
+
1387
+ async function startImessageProvider(config) {
1388
+ const imsgPath = config.imessageCliPath || findImsgBin();
1389
+ if (!imsgPath || !fs.existsSync(imsgPath)) {
1390
+ log('imessage', 'imsg binary not found, install with: brew install mbilker/imsg/imsg', 'red');
1391
+ return;
1392
+ }
1393
+
1394
+ log('imessage', `using imsg: ${imsgPath}`, 'gray');
1395
+ global.imessageProvider = { imsgPath };
1396
+
1397
+ // Start polling for new messages
1398
+ imessagePollInterval = setInterval(async () => {
1399
+ try {
1400
+ const result = execSync(`"${imsgPath}" messages --format json --limit 5 2>/dev/null`, {
1401
+ encoding: 'utf-8', timeout: 10000, stdio: 'pipe',
1402
+ });
1403
+ const lines = result.trim().split('\n').filter(Boolean);
1404
+ for (const line of lines) {
1405
+ try {
1406
+ const msg = JSON.parse(line);
1407
+ await processImessageMessage(msg, config);
1408
+ } catch {}
1409
+ }
1410
+ } catch {}
1411
+ }, 15000);
1412
+
1413
+ log('imessage', 'polling started (15s interval)', 'green');
1414
+ }
1415
+
1416
+ function findImsgBin() {
1417
+ const config = require('../utils/config').getConfig();
1418
+ if (config.imessageCliPath && fs.existsSync(config.imessageCliPath)) return config.imessageCliPath;
1419
+ try {
1420
+ const result = execSync('which imsg 2>/dev/null', { encoding: 'utf-8', timeout: 5000 });
1421
+ const p = result.trim();
1422
+ if (p && fs.existsSync(p)) return p;
1423
+ } catch {}
1424
+ return null;
1425
+ }
1426
+
1427
+ async function processImessageMessage(msg, config) {
1428
+ try {
1429
+ // Deduplicate
1430
+ if (msg.id && msg.id === imessageLastMessageId) return;
1431
+ if (msg.id) imessageLastMessageId = msg.id;
1432
+
1433
+ // Skip outgoing messages (from us)
1434
+ if (msg.is_from_me || msg.from_me) return;
1435
+
1436
+ const sender = msg.sender || msg.from || msg.address || '';
1437
+ const text = msg.text || msg.message || '';
1438
+
1439
+ if (!text.trim() || !sender) return;
1440
+
1441
+ log('imessage', `Inbound from ${sender}: "${text.slice(0, 80)}"`, 'cyan');
1442
+
1443
+ const { sendMessage } = require('../utils/api');
1444
+ const { getMemoryPrompt } = require('../utils/memory');
1445
+ const { getConfig } = require('../utils/config');
1446
+ const cfg = getConfig();
1447
+
1448
+ const conversationId = `imessage_${sender}`;
1449
+ const botId = 'universal-provider';
1450
+ const memoryPrompt = getMemoryPrompt(botId);
1451
+
1452
+ let systemPrompt = `You are a helpful iMessage assistant. Keep responses concise.`;
1453
+ if (memoryPrompt) systemPrompt += '\n\n' + memoryPrompt;
1454
+
1455
+ const response = await sendMessage(
1456
+ cfg.providerApiKey || cfg.apiKey || '', botId, text,
1457
+ conversationId, systemPrompt
1458
+ );
1459
+ const reply = response?.reply || response?.message || '';
1460
+
1461
+ if (reply) {
1462
+ execSync(`"${global.imessageProvider.imsgPath}" send --address "${sender}" --message "${reply.replace(/"/g, '\\"')}" 2>/dev/null`, {
1463
+ timeout: 15000, stdio: 'pipe',
1464
+ });
1465
+ log('imessage', `Reply sent to ${sender} (${reply.length} chars)`, 'green');
1466
+ }
1467
+ } catch (err) {
1468
+ log('imessage', `Message error: ${err.message}`, 'red');
1469
+ }
1470
+ }
1471
+
1472
+ function stopImessageProvider() {
1473
+ if (imessagePollInterval) {
1474
+ clearInterval(imessagePollInterval);
1475
+ imessagePollInterval = null;
1476
+ }
1477
+ global.imessageProvider = null;
1478
+ }
1479
+
1480
+ // SMS (Twilio) provider
1481
+ let smsReplayCache = new Map();
1482
+ const SMS_RATE_LIMIT_WINDOW = 60000;
1483
+ const SMS_RATE_LIMIT_MAX = 30;
1484
+ const smsRateLimitMap = new Map();
1485
+
1486
+ async function startSmsProvider(config) {
1487
+ log('sms', 'provider initialized for outbound sending', 'green');
1488
+
1489
+ if (config.smsEnableWebhook !== false) {
1490
+ log('sms', 'webhook endpoint will be registered on HTTP server', 'gray');
1491
+ }
1492
+
1493
+ global.smsProvider = {
1494
+ accountSid: config.smsAccountSid,
1495
+ authToken: config.smsAuthToken,
1496
+ fromNumber: config.smsFromNumber,
1497
+ messagingServiceSid: config.smsMessagingServiceSid || null,
1498
+ };
1499
+ }
1500
+
1501
+ async function sendSmsMessage(config, to, message) {
1502
+ const accountSid = config.smsAccountSid;
1503
+ const authToken = config.smsAuthToken;
1504
+ const from = config.smsFromNumber;
1505
+ const messagingServiceSid = config.smsMessagingServiceSid;
1506
+
1507
+ const base64Auth = Buffer.from(`${accountSid}:${authToken}`).toString('base64');
1508
+
1509
+ const body = new URLSearchParams();
1510
+ if (messagingServiceSid) {
1511
+ body.append('MessagingServiceSid', messagingServiceSid);
1512
+ } else {
1513
+ body.append('From', from);
1514
+ }
1515
+ body.append('To', to);
1516
+ body.append('Body', message);
1517
+
1518
+ const res = await fetch(`https://api.twilio.com/2010-04-01/Accounts/${accountSid}/Messages.json`, {
1519
+ method: 'POST',
1520
+ headers: {
1521
+ 'Authorization': `Basic ${base64Auth}`,
1522
+ 'Content-Type': 'application/x-www-form-urlencoded',
1523
+ },
1524
+ body: body.toString(),
1525
+ signal: AbortSignal.timeout(30000),
1526
+ });
1527
+
1528
+ if (!res.ok) {
1529
+ const text = await res.text();
1530
+ let detail = text.slice(0, 300);
1531
+ try {
1532
+ const errJson = JSON.parse(text);
1533
+ detail = errJson.message || detail;
1534
+ } catch {}
1535
+ throw new Error(`Twilio send failed (HTTP ${res.status}): ${detail}`);
1536
+ }
1537
+
1538
+ return res.json();
1539
+ }
1540
+
1541
+ function verifyTwilioSignature(authToken, url, params, signature) {
1542
+ const crypto = require('crypto');
1543
+ // Sort params by key
1544
+ const sorted = Object.keys(params).sort();
1545
+ const data = url + sorted.map(k => k + params[k]).join('');
1546
+ const expected = crypto.createHmac('sha1', authToken).update(data).digest('base64');
1547
+ try {
1548
+ return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
1549
+ } catch {
1550
+ return false;
1551
+ }
1552
+ }
1553
+
1554
+ async function handleSmsWebhook(config, body, req) {
1555
+ // Rate limiting
1556
+ const ip = req.socket?.remoteAddress || 'unknown';
1557
+ const now = Date.now();
1558
+ const windowKey = `${ip}:${Math.floor(now / SMS_RATE_LIMIT_WINDOW)}`;
1559
+ const count = (smsRateLimitMap.get(windowKey) || 0) + 1;
1560
+ smsRateLimitMap.set(windowKey, count);
1561
+ if (count > SMS_RATE_LIMIT_MAX) {
1562
+ return { status: 429, body: { error: 'Too many requests' } };
1563
+ }
1564
+
1565
+ // Replay protection
1566
+ const messageSid = body.MessageSid || '';
1567
+ if (messageSid && smsReplayCache.has(messageSid)) {
1568
+ return { status: 200, body: { ok: true, cached: true } };
1569
+ }
1570
+ if (messageSid) {
1571
+ smsReplayCache.set(messageSid, true);
1572
+ setTimeout(() => smsReplayCache.delete(messageSid), 600000);
1573
+ if (smsReplayCache.size > 10000) {
1574
+ const keys = [...smsReplayCache.keys()].slice(0, 2000);
1575
+ keys.forEach(k => smsReplayCache.delete(k));
1576
+ }
1577
+ }
1578
+
1579
+ // Signature validation
1580
+ const signature = req.headers['x-twilio-signature'] || '';
1581
+ if (signature && config.smsAuthToken) {
1582
+ const fullUrl = `${req.headers['x-forwarded-proto'] || 'http'}://${req.headers.host}${req.url}`;
1583
+ const params = {};
1584
+ for (const [k, v] of Object.entries(body)) {
1585
+ params[k] = String(v);
1586
+ }
1587
+ if (!verifyTwilioSignature(config.smsAuthToken, fullUrl, params, signature)) {
1588
+ log('sms', 'invalid Twilio signature, rejecting', 'red');
1589
+ return { status: 403, body: { error: 'Invalid signature' } };
1590
+ }
1591
+ }
1592
+
1593
+ const from = body.From || '';
1594
+ const to = body.To || '';
1595
+ const text = body.Body || '';
1596
+
1597
+ if (!from || !text.trim()) {
1598
+ return { status: 200, body: { ok: true, ignored: true } };
1599
+ }
1600
+
1601
+ log('sms', `Inbound from ${from}: "${text.slice(0, 80)}"`, 'cyan');
1602
+
1603
+ // Check DM policy
1604
+ const dmPolicy = config.smsDmPolicy || 'allowlist';
1605
+ if (dmPolicy === 'disabled') {
1606
+ return { status: 200, body: { ok: true, ignored: true } };
1607
+ }
1608
+
1609
+ // Get AI response
1610
+ try {
1611
+ const { sendMessage } = require('../utils/api');
1612
+ const { getMemoryPrompt } = require('../utils/memory');
1613
+ const { getConfig } = require('../utils/config');
1614
+ const cfg = getConfig();
1615
+
1616
+ const conversationId = `sms_${from}`;
1617
+ const botId = 'universal-provider';
1618
+ const memoryPrompt = getMemoryPrompt(botId);
1619
+
1620
+ let systemPrompt = `You are a helpful SMS assistant. Keep responses concise (SMS format).`;
1621
+ if (memoryPrompt) systemPrompt += '\n\n' + memoryPrompt;
1622
+
1623
+ const response = await sendMessage(
1624
+ cfg.providerApiKey || cfg.apiKey || '', botId, text,
1625
+ conversationId, systemPrompt
1626
+ );
1627
+ const reply = response?.reply || response?.message || '';
1628
+
1629
+ if (reply) {
1630
+ await sendSmsMessage(config, from, reply);
1631
+ log('sms', `Reply sent to ${from} (${reply.length} chars)`, 'green');
1632
+ }
1633
+ } catch (err) {
1634
+ log('sms', `Response error: ${err.message}`, 'red');
1635
+ }
1636
+
1637
+ return { status: 200, body: { ok: true } };
1638
+ }
1639
+
1640
+ function stopSmsProvider() {
1641
+ smsReplayCache.clear();
1642
+ smsRateLimitMap.clear();
1643
+ global.smsProvider = null;
1644
+ }
1645
+
1646
+ function startHttpServer() {
1647
+ const http = require('http');
1648
+
1649
+ const server = http.createServer(async (req, res) => {
1650
+ // CORS headers
1651
+ res.setHeader('Access-Control-Allow-Origin', '*');
1652
+ res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
1653
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
1654
+
1655
+ if (req.method === 'OPTIONS') {
1656
+ res.writeHead(200);
1657
+ res.end();
1658
+ return;
1659
+ }
1660
+
1661
+ if (req.method === 'POST' && req.url === '/webhooks/sms') {
1662
+ // Twilio SMS webhook handler
1663
+ const { getConfig } = require('../utils/config');
1664
+ const cfg = getConfig();
1665
+
1666
+ let body = '';
1667
+ req.on('data', chunk => { body += chunk.toString(); });
1668
+ req.on('end', async () => {
1669
+ // Parse form-encoded body
1670
+ const params = new URLSearchParams(body);
1671
+ const formBody = {};
1672
+ for (const [k, v] of params) formBody[k] = v;
1673
+
1674
+ // Create a mock req-like object for signature validation
1675
+ const mockReq = {
1676
+ url: req.url,
1677
+ headers: req.headers,
1678
+ socket: req.socket,
1679
+ };
1680
+
1681
+ const result = await handleSmsWebhook(cfg, formBody, mockReq);
1682
+ res.writeHead(result.status, { 'Content-Type': 'application/json' });
1683
+ res.end(JSON.stringify(result.body));
1684
+ });
1685
+ return;
1686
+ }
1687
+
1688
+ if (req.method === 'POST' && req.url === '/send') {
1689
+ let body = '';
1690
+
1691
+ req.on('data', chunk => {
1692
+ body += chunk.toString();
1693
+ });
1694
+
1695
+ req.on('end', async () => {
1696
+ try {
1697
+ const { channel, target, message } = JSON.parse(body);
1698
+
1699
+ if (!channel || !target || !message) {
1700
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1701
+ res.end(JSON.stringify({ error: 'Missing required fields: channel, target, message' }));
1702
+ return;
1703
+ }
1704
+
1705
+ if (channel === 'whatsapp') {
1706
+ if (!global.whatsappSock) {
1707
+ res.writeHead(503, { 'Content-Type': 'application/json' });
1708
+ res.end(JSON.stringify({ error: 'WhatsApp not connected' }));
1709
+ return;
1710
+ }
1711
+
1712
+ const normalizedTarget = target.replace(/[^\d]/g, '');
1713
+ const jid = `${normalizedTarget}@s.whatsapp.net`;
1714
+
1715
+ await global.whatsappSock.sendMessage(jid, { text: message });
1716
+
1717
+ log('http', `WhatsApp message sent to ${target}`, 'green');
1718
+
1719
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1720
+ res.end(JSON.stringify({ success: true, channel: 'whatsapp', target }));
1721
+
1722
+ } else if (channel === 'telegram') {
1723
+ if (!global.telegramBot) {
1724
+ res.writeHead(503, { 'Content-Type': 'application/json' });
1725
+ res.end(JSON.stringify({ error: 'Telegram not connected' }));
1726
+ return;
1727
+ }
1728
+
1729
+ const chatId = target.trim();
1730
+ if (!/^-?\d+$/.test(chatId)) {
1731
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1732
+ res.end(JSON.stringify({ error: 'Invalid Telegram chat ID' }));
1733
+ return;
1734
+ }
1735
+
1736
+ await global.telegramBot.sendMessage(chatId, message);
1737
+
1738
+ log('http', `Telegram message sent to ${target}`, 'green');
1739
+
1740
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1741
+ res.end(JSON.stringify({ success: true, channel: 'telegram', target }));
1742
+
1743
+ } else if (channel === 'signal') {
1744
+ const { getConfig } = require('../utils/config');
1745
+ const cfg = getConfig();
1746
+ if (!cfg.signalHttpUrl) {
1747
+ res.writeHead(503, { 'Content-Type': 'application/json' });
1748
+ res.end(JSON.stringify({ error: 'Signal not connected' }));
1749
+ return;
1750
+ }
1751
+ await sendSignalMessage(cfg, target, message);
1752
+ log('http', `Signal message sent to ${target}`, 'green');
1753
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1754
+ res.end(JSON.stringify({ success: true, channel: 'signal', target }));
1755
+ } else if (channel === 'irc') {
1756
+ if (!global.ircClient?.isReady()) {
1757
+ res.writeHead(503, { 'Content-Type': 'application/json' });
1758
+ res.end(JSON.stringify({ error: 'IRC not connected' }));
1759
+ return;
1760
+ }
1761
+ sendIrcMessage(ircClient, target, message);
1762
+ log('http', `IRC message sent to ${target}`, 'green');
1763
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1764
+ res.end(JSON.stringify({ success: true, channel: 'irc', target }));
1765
+ } else if (channel === 'mattermost') {
1766
+ if (!global.mattermostProvider) {
1767
+ res.writeHead(503, { 'Content-Type': 'application/json' });
1768
+ res.end(JSON.stringify({ error: 'Mattermost not connected' }));
1769
+ return;
1770
+ }
1771
+ await sendMattermostMessage(config, target, message);
1772
+ log('http', `Mattermost message sent to channel ${target}`, 'green');
1773
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1774
+ res.end(JSON.stringify({ success: true, channel: 'mattermost', target }));
1775
+ } else if (channel === 'imessage') {
1776
+ if (!global.imessageProvider) {
1777
+ res.writeHead(503, { 'Content-Type': 'application/json' });
1778
+ res.end(JSON.stringify({ error: 'iMessage not connected' }));
1779
+ return;
1780
+ }
1781
+ try {
1782
+ execSync(`"${global.imessageProvider.imsgPath}" send --address "${target.replace(/"/g, '\\"')}" --message "${message.replace(/"/g, '\\"')}" 2>/dev/null`, {
1783
+ timeout: 15000, stdio: 'pipe',
1784
+ });
1785
+ log('http', `iMessage sent to ${target}`, 'green');
1786
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1787
+ res.end(JSON.stringify({ success: true, channel: 'imessage', target }));
1788
+ } catch (sendErr) {
1789
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1790
+ res.end(JSON.stringify({ error: `iMessage send failed: ${sendErr.message}` }));
1791
+ }
1792
+ } else if (channel === 'sms') {
1793
+ if (!global.smsProvider) {
1794
+ res.writeHead(503, { 'Content-Type': 'application/json' });
1795
+ res.end(JSON.stringify({ error: 'SMS not connected' }));
1796
+ return;
1797
+ }
1798
+ await sendSmsMessage(config, target, message);
1799
+ log('http', `SMS sent to ${target}`, 'green');
1800
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1801
+ res.end(JSON.stringify({ success: true, channel: 'sms', target }));
1802
+ } else {
1803
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1804
+ res.end(JSON.stringify({ error: 'Invalid channel. Use "whatsapp" or "telegram"' }));
1805
+ }
1806
+
1807
+ } catch (err) {
1808
+ log('http', `Error: ${err.message}`, 'red');
1809
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1810
+ res.end(JSON.stringify({ error: err.message }));
1811
+ }
1812
+ });
1813
+
1814
+ } else {
1815
+ res.writeHead(404, { 'Content-Type': 'application/json' });
1816
+ res.end(JSON.stringify({ error: 'Not found' }));
1817
+ }
1818
+ });
1819
+
1820
+ server.listen(3847, '127.0.0.1', () => {
1821
+ log('http', 'HTTP server listening on http://127.0.0.1:3847', 'green');
1822
+ });
1823
+
1824
+ server.on('error', (err) => {
1825
+ log('http', `Server error: ${err.message}`, 'red');
1826
+ });
1827
+ }
1828
+
1829
+ function startCronJobs(config) {
1830
+ try {
1831
+ const nodeCron = require('node-cron');
1832
+ const CRONS_FILE = path.join(os.homedir(), '.natureco', 'crons.json');
1833
+
1834
+ if (!fs.existsSync(CRONS_FILE)) {
1835
+ log('cron', 'No cron jobs configured', 'gray');
1836
+ return;
1837
+ }
1838
+
1839
+ const crons = JSON.parse(fs.readFileSync(CRONS_FILE, 'utf-8'));
1840
+ const enabledCrons = crons.filter(c => c.enabled);
1841
+
1842
+ if (enabledCrons.length === 0) {
1843
+ log('cron', 'No enabled cron jobs', 'gray');
1844
+ return;
1845
+ }
1846
+
1847
+ log('cron', `Loading ${enabledCrons.length} cron job(s)...`, 'cyan');
1848
+
1849
+ enabledCrons.forEach(cronJob => {
1850
+ try {
1851
+ nodeCron.schedule(cronJob.schedule, async () => {
1852
+ log('cron', `Triggered: ${cronJob.name}`, 'yellow');
1853
+
1854
+ try {
1855
+ // Get provider config
1856
+ const { getConfig } = require('../utils/config');
1857
+ const cfg = getConfig();
1858
+
1859
+ if (!cfg.providerUrl || !cfg.providerApiKey) {
1860
+ log('cron', 'Provider not configured', 'red');
1861
+ return;
1862
+ }
1863
+
1864
+ const isAnthropic = cfg.providerUrl.includes('anthropic.com');
1865
+
1866
+ log('cron', `Sending prompt to AI (no tools)...`, 'cyan');
1867
+
1868
+ // Make direct API call without tools
1869
+ let reply = '';
1870
+
1871
+ if (isAnthropic) {
1872
+ // Anthropic API
1873
+ const response = await fetch(`${cfg.providerUrl}/v1/messages`, {
1874
+ method: 'POST',
1875
+ headers: {
1876
+ 'x-api-key': cfg.providerApiKey,
1877
+ 'anthropic-version': '2023-06-01',
1878
+ 'Content-Type': 'application/json',
1879
+ },
1880
+ body: JSON.stringify({
1881
+ model: cfg.providerModel || 'claude-3-5-sonnet-20241022',
1882
+ max_tokens: 1000,
1883
+ messages: [{ role: 'user', content: cronJob.prompt }]
1884
+ }),
1885
+ });
1886
+
1887
+ if (!response.ok) {
1888
+ const errorText = await response.text();
1889
+ throw new ApiError(`Anthropic API error: ${response.status} - ${errorText}`, response.status);
1890
+ }
1891
+
1892
+ const data = await response.json();
1893
+ reply = data.content.find(c => c.type === 'text')?.text || '';
1894
+
1895
+ } else {
1896
+ // OpenAI-compatible API
1897
+ const response = await fetch(`${cfg.providerUrl}/chat/completions`, {
1898
+ method: 'POST',
1899
+ headers: {
1900
+ 'Authorization': `Bearer ${cfg.providerApiKey}`,
1901
+ 'Content-Type': 'application/json',
1902
+ },
1903
+ body: JSON.stringify({
1904
+ model: cfg.providerModel || 'llama-3.3-70b-versatile',
1905
+ messages: [{ role: 'user', content: cronJob.prompt }],
1906
+ temperature: 0.7,
1907
+ max_tokens: 1000,
1908
+ }),
1909
+ });
1910
+
1911
+ if (!response.ok) {
1912
+ const errorText = await response.text();
1913
+ throw new ApiError(`Provider API error: ${response.status} - ${errorText}`, response.status);
1914
+ }
1915
+
1916
+ const data = await response.json();
1917
+ reply = data.choices[0].message.content;
1918
+ }
1919
+
1920
+ if (!reply) {
1921
+ log('cron', `No response from AI`, 'red');
1922
+ return;
1923
+ }
1924
+
1925
+ log('cron', `AI response received (${reply.length} chars)`, 'green');
1926
+
1927
+ // Send to target channel
1928
+ if (cronJob.action === 'whatsapp') {
1929
+ if (!global.whatsappSock) {
1930
+ log('cron', `WhatsApp not connected, skipping`, 'red');
1931
+ return;
1932
+ }
1933
+
1934
+ const normalizedTarget = cronJob.target.replace(/[^\d]/g, '');
1935
+ const jid = `${normalizedTarget}@s.whatsapp.net`;
1936
+
1937
+ await global.whatsappSock.sendMessage(jid, { text: reply });
1938
+ log('cron', `Sent to WhatsApp: ${cronJob.target}`, 'green');
1939
+
1940
+ } else if (cronJob.action === 'telegram') {
1941
+ if (!global.telegramBot) {
1942
+ log('cron', `Telegram not connected, skipping`, 'red');
1943
+ return;
1944
+ }
1945
+
1946
+ await global.telegramBot.sendMessage(cronJob.target, reply);
1947
+ log('cron', `Sent to Telegram: ${cronJob.target}`, 'green');
1948
+ } else if (cronJob.action === 'signal') {
1949
+ const { getConfig } = require('../utils/config');
1950
+ const cfg = getConfig();
1951
+ if (!cfg.signalHttpUrl) {
1952
+ log('cron', 'Signal not connected, skipping', 'red');
1953
+ return;
1954
+ }
1955
+ await sendSignalMessage(cfg, cronJob.target, reply);
1956
+ log('cron', `Sent to Signal: ${cronJob.target}`, 'green');
1957
+ } else if (cronJob.action === 'irc') {
1958
+ if (!global.ircClient?.isReady()) {
1959
+ log('cron', 'IRC not connected, skipping', 'red');
1960
+ return;
1961
+ }
1962
+ sendIrcMessage(ircClient, cronJob.target, reply);
1963
+ log('cron', `Sent to IRC: ${cronJob.target}`, 'green');
1964
+ } else if (cronJob.action === 'mattermost') {
1965
+ if (!global.mattermostProvider) {
1966
+ log('cron', 'Mattermost not connected, skipping', 'red');
1967
+ return;
1968
+ }
1969
+ await sendMattermostMessage(config, cronJob.target, reply);
1970
+ log('cron', `Sent to Mattermost: ${cronJob.target}`, 'green');
1971
+ } else if (cronJob.action === 'imessage') {
1972
+ if (!global.imessageProvider) {
1973
+ log('cron', 'iMessage not connected, skipping', 'red');
1974
+ return;
1975
+ }
1976
+ execSync(`"${global.imessageProvider.imsgPath}" send --address "${cronJob.target.replace(/"/g, '\\"')}" --message "${reply.replace(/"/g, '\\"')}" 2>/dev/null`, {
1977
+ timeout: 15000, stdio: 'pipe',
1978
+ });
1979
+ log('cron', `Sent to iMessage: ${cronJob.target}`, 'green');
1980
+ } else if (cronJob.action === 'sms') {
1981
+ if (!global.smsProvider) {
1982
+ log('cron', 'SMS not connected, skipping', 'red');
1983
+ return;
1984
+ }
1985
+ await sendSmsMessage(config, cronJob.target, reply);
1986
+ log('cron', `Sent to SMS: ${cronJob.target}`, 'green');
1987
+ }
1988
+
1989
+ } catch (err) {
1990
+ log('cron', `Error executing ${cronJob.name}: ${err.message}`, 'red');
1991
+ }
1992
+ });
1993
+
1994
+ log('cron', `Scheduled: ${cronJob.name} (${cronJob.schedule})`, 'green');
1995
+
1996
+ } catch (err) {
1997
+ log('cron', `Failed to schedule ${cronJob.name}: ${err.message}`, 'red');
1998
+ }
1999
+ });
2000
+
2001
+ } catch (err) {
2002
+ log('cron', `Failed to load cron jobs: ${err.message}`, 'red');
2003
+ }
2004
+ }
2005
+
2006
+ function stopGateway() {
2007
+ if (!fs.existsSync(PID_FILE)) {
2008
+ console.log(chalk.gray('\n⚠️ Gateway not running\n'));
2009
+ return;
2010
+ }
2011
+
2012
+ const pid = parseInt(fs.readFileSync(PID_FILE, 'utf-8').trim());
2013
+
2014
+ try {
2015
+ // Try graceful shutdown first
2016
+ process.kill(pid, 'SIGTERM');
2017
+
2018
+ // Wait a bit and check if process is still running
2019
+ setTimeout(() => {
2020
+ try {
2021
+ process.kill(pid, 0);
2022
+ // Process still running, force kill
2023
+ if (process.platform === 'win32') {
2024
+ try {
2025
+ execSync(`taskkill /F /PID ${pid}`, { stdio: 'ignore' });
2026
+ console.log(chalk.green('\n✅ Gateway stopped (force killed)\n'));
2027
+ } catch (err) {
2028
+ console.log(chalk.red(`\n❌ Failed to stop gateway: ${err.message}\n`));
2029
+ }
2030
+ } else {
2031
+ process.kill(pid, 'SIGKILL');
2032
+ console.log(chalk.green('\n✅ Gateway stopped (force killed)\n'));
2033
+ }
2034
+ } catch {
2035
+ // Process already stopped
2036
+ console.log(chalk.green('\n✅ Gateway stopped\n'));
2037
+ }
2038
+
2039
+ // Remove PID file
2040
+ if (fs.existsSync(PID_FILE)) {
2041
+ fs.unlinkSync(PID_FILE);
2042
+ }
2043
+ }, 1000);
2044
+
2045
+ } catch (err) {
2046
+ // Process not found, try force kill on Windows
2047
+ if (process.platform === 'win32') {
2048
+ try {
2049
+ execSync(`taskkill /F /PID ${pid}`, { stdio: 'ignore' });
2050
+ console.log(chalk.green('\n✅ Gateway stopped\n'));
2051
+ } catch {
2052
+ console.log(chalk.yellow('\n⚠️ Gateway process not found\n'));
2053
+ }
2054
+ } else {
2055
+ console.log(chalk.yellow('\n⚠️ Gateway process not found\n'));
2056
+ }
2057
+
2058
+ // Remove stale PID file
2059
+ if (fs.existsSync(PID_FILE)) {
2060
+ fs.unlinkSync(PID_FILE);
2061
+ }
2062
+ }
2063
+ }
2064
+
2065
+ function statusGateway() {
2066
+ if (!fs.existsSync(PID_FILE)) {
2067
+ console.log(chalk.gray('\n⚠️ Gateway not running\n'));
2068
+ return;
2069
+ }
2070
+
2071
+ const pid = fs.readFileSync(PID_FILE, 'utf-8').trim();
2072
+
2073
+ try {
2074
+ process.kill(pid, 0);
2075
+ console.log(chalk.green('\n✅ Gateway running\n'));
2076
+ console.log(chalk.cyan('PID:'), chalk.white(pid));
2077
+ console.log(chalk.cyan('Logs:'), chalk.white(LOG_FILE));
2078
+
2079
+ // Show log tail if exists
2080
+ if (fs.existsSync(LOG_FILE)) {
2081
+ const logs = fs.readFileSync(LOG_FILE, 'utf-8').split('\n').filter(l => l.trim()).slice(-10).join('\n');
2082
+ console.log(chalk.gray('\nRecent logs (last 10):'));
2083
+ console.log(chalk.white(logs));
2084
+ }
2085
+ console.log('');
2086
+ } catch {
2087
+ console.log(chalk.gray('\n⚠️ Gateway not running (stale PID file)\n'));
2088
+ fs.unlinkSync(PID_FILE);
2089
+ }
2090
+ }
2091
+
2092
+ function showLogs() {
2093
+ if (!fs.existsSync(LOG_FILE)) {
2094
+ console.log(chalk.gray('\n⚠️ No logs found\n'));
2095
+ return;
2096
+ }
2097
+
2098
+ const logs = fs.readFileSync(LOG_FILE, 'utf-8');
2099
+ console.log(logs);
2100
+ }
2101
+
2102
+ // Direkt çalıştırılınca worker olarak başlat
2103
+ if (require.main === module || process.argv.includes('--gateway-worker')) {
2104
+ gatewayServer('--gateway-worker');
2105
+ }
2106
+
2107
+ module.exports = gatewayServer;