natureco-cli 2.23.28 → 2.23.30

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.
Files changed (96) hide show
  1. package/README.md +94 -11
  2. package/bin/natureco.js +470 -10
  3. package/package.json +10 -6
  4. package/src/commands/admin-rpc.js +219 -0
  5. package/src/commands/agent.js +89 -0
  6. package/src/commands/approvals.js +53 -0
  7. package/src/commands/backup.js +124 -0
  8. package/src/commands/bonjour.js +167 -0
  9. package/src/commands/capability.js +64 -0
  10. package/src/commands/channels.js +94 -4
  11. package/src/commands/chat.js +11 -25
  12. package/src/commands/clickclack.js +130 -0
  13. package/src/commands/commitments.js +32 -0
  14. package/src/commands/completion.js +76 -0
  15. package/src/commands/config.js +111 -68
  16. package/src/commands/configure.js +93 -0
  17. package/src/commands/crestodian.js +92 -0
  18. package/src/commands/daemon.js +60 -0
  19. package/src/commands/device-pair.js +248 -0
  20. package/src/commands/devices.js +110 -0
  21. package/src/commands/directory.js +47 -0
  22. package/src/commands/dns.js +58 -0
  23. package/src/commands/docs.js +43 -0
  24. package/src/commands/doctor.js +121 -16
  25. package/src/commands/exec-policy.js +71 -0
  26. package/src/commands/gateway-server.js +1175 -30
  27. package/src/commands/gateway.js +11 -20
  28. package/src/commands/health.js +18 -0
  29. package/src/commands/help.js +6 -0
  30. package/src/commands/imessage.js +169 -0
  31. package/src/commands/infer.js +73 -0
  32. package/src/commands/irc.js +119 -0
  33. package/src/commands/mattermost.js +164 -0
  34. package/src/commands/memory-cmd.js +134 -1
  35. package/src/commands/message.js +30 -4
  36. package/src/commands/migrate.js +213 -2
  37. package/src/commands/models.js +584 -216
  38. package/src/commands/node.js +98 -0
  39. package/src/commands/nodes.js +106 -0
  40. package/src/commands/oc-path.js +200 -0
  41. package/src/commands/onboard.js +70 -0
  42. package/src/commands/open-prose.js +67 -0
  43. package/src/commands/plugins.js +415 -172
  44. package/src/commands/policy.js +176 -0
  45. package/src/commands/proxy.js +155 -0
  46. package/src/commands/qr.js +28 -0
  47. package/src/commands/sandbox.js +125 -0
  48. package/src/commands/secrets.js +118 -0
  49. package/src/commands/security.js +149 -1
  50. package/src/commands/setup.js +114 -10
  51. package/src/commands/signal.js +495 -0
  52. package/src/commands/skills.js +20 -29
  53. package/src/commands/sms.js +168 -0
  54. package/src/commands/system.js +53 -0
  55. package/src/commands/tasks.js +328 -79
  56. package/src/commands/terminal.js +21 -0
  57. package/src/commands/thread-ownership.js +157 -0
  58. package/src/commands/transcripts.js +72 -0
  59. package/src/commands/voice.js +82 -0
  60. package/src/commands/vydra.js +98 -0
  61. package/src/commands/webhooks.js +79 -0
  62. package/src/commands/whatsapp.js +7 -21
  63. package/src/commands/workboard.js +207 -0
  64. package/src/tools/audio_understanding.js +154 -0
  65. package/src/tools/bash.js +63 -29
  66. package/src/tools/browser.js +112 -0
  67. package/src/tools/canvas.js +104 -0
  68. package/src/tools/document_extract.js +84 -0
  69. package/src/tools/duckduckgo.js +54 -0
  70. package/src/tools/exa_search.js +66 -0
  71. package/src/tools/firecrawl.js +104 -0
  72. package/src/tools/image_generation.js +99 -0
  73. package/src/tools/llm_task.js +118 -0
  74. package/src/tools/media_understanding.js +128 -0
  75. package/src/tools/music_generation.js +113 -0
  76. package/src/tools/parallel_search.js +77 -0
  77. package/src/tools/phone_control.js +80 -0
  78. package/src/tools/phone_control_enhanced.js +184 -0
  79. package/src/tools/searxng.js +61 -0
  80. package/src/tools/speech_to_text.js +135 -0
  81. package/src/tools/text_to_speech.js +105 -0
  82. package/src/tools/thread_ownership.js +88 -0
  83. package/src/tools/video_generation.js +72 -0
  84. package/src/tools/web_readability.js +104 -0
  85. package/src/utils/api.js +3 -20
  86. package/src/utils/approvals.js +297 -0
  87. package/src/utils/background.js +223 -66
  88. package/src/utils/baileys.js +21 -0
  89. package/src/utils/config.js +141 -10
  90. package/src/utils/errors.js +148 -0
  91. package/src/utils/inquirer-wrapper.js +1 -2
  92. package/src/utils/memory.js +200 -0
  93. package/src/utils/path-utils.js +13 -13
  94. package/src/utils/plugin-registry.js +238 -0
  95. package/src/utils/secrets.js +177 -0
  96. package/src/utils/skills.js +10 -23
@@ -4,13 +4,12 @@ const path = require('path');
4
4
  const os = require('os');
5
5
  const { spawn, execSync } = require('child_process');
6
6
  const pino = require('pino');
7
+ const { loadBaileys } = require('../utils/baileys');
8
+ const { ApiError } = require('../utils/errors');
7
9
 
8
10
  const PID_FILE = path.join(os.homedir(), '.natureco', 'gateway.pid');
9
11
  const LOG_FILE = path.join(os.homedir(), '.natureco', 'gateway.log');
10
12
 
11
- // WhatsApp imports
12
- let makeWASocket, useMultiFileAuthState, DisconnectReason, fetchLatestBaileysVersion, Browsers;
13
-
14
13
  // Silent logger for Baileys
15
14
  const silentLogger = {
16
15
  level: 'silent',
@@ -23,18 +22,6 @@ const silentLogger = {
23
22
  child: () => silentLogger
24
23
  };
25
24
 
26
- // Lazy load Baileys
27
- function loadBaileys() {
28
- if (!makeWASocket) {
29
- const baileys = require('@whiskeysockets/baileys');
30
- makeWASocket = baileys.default;
31
- useMultiFileAuthState = baileys.useMultiFileAuthState;
32
- DisconnectReason = baileys.DisconnectReason;
33
- fetchLatestBaileysVersion = baileys.fetchLatestBaileysVersion;
34
- Browsers = baileys.Browsers;
35
- }
36
- }
37
-
38
25
  // Create Baileys logger — real pino in worker mode, silent otherwise
39
26
  let _baileysLogger = null;
40
27
  function getBaileysLogger() {
@@ -151,7 +138,8 @@ async function startGateway() {
151
138
 
152
139
  async function runGatewayWorker() {
153
140
  // This runs in the background
154
- log('gateway', 'Starting NatureCo Gateway v2.11.3...', 'green');
141
+ const pkg = require('../../package.json');
142
+ log('gateway', `Starting NatureCo Gateway v${pkg.version}...`, 'green');
155
143
 
156
144
  // Load config
157
145
  const { getConfig } = require('../utils/config');
@@ -165,6 +153,7 @@ async function runGatewayWorker() {
165
153
  // Store provider instances globally for HTTP endpoint
166
154
  global.whatsappSock = null;
167
155
  global.telegramBot = null;
156
+ global.signalProvider = null;
168
157
 
169
158
  // Start WhatsApp if configured
170
159
  if (config.whatsappConnected && config.whatsappBotId) {
@@ -188,6 +177,55 @@ async function runGatewayWorker() {
188
177
  } else {
189
178
  log('telegram', 'not configured, skipping', 'gray');
190
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');
191
229
 
192
230
  // Start HTTP server for message sending
193
231
  startHttpServer();
@@ -196,33 +234,45 @@ async function runGatewayWorker() {
196
234
  startCronJobs(config);
197
235
 
198
236
  // Health check every 60 seconds
199
- setInterval(() => {
200
- log('gateway', 'health check: OK', 'gray');
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
+ }
201
245
  }, 60000);
202
246
 
203
247
  log('gateway', 'Gateway running in background', 'green');
204
248
 
205
249
  // Handle shutdown
206
- process.on('SIGINT', () => {
250
+ const shutdown = () => {
207
251
  log('gateway', 'Shutting down...', 'yellow');
208
- if (fs.existsSync(PID_FILE)) {
209
- fs.unlinkSync(PID_FILE);
252
+ if (global.signalProvider?.ws) {
253
+ try { global.signalProvider.ws.close(); } catch {}
210
254
  }
211
- process.exit(0);
212
- });
213
-
214
- process.on('SIGTERM', () => {
215
- log('gateway', 'Shutting down...', 'yellow');
255
+ if (signalPollingInterval) {
256
+ clearInterval(signalPollingInterval);
257
+ signalPollingInterval = null;
258
+ }
259
+ stopIrcProvider();
260
+ stopMattermostProvider();
261
+ stopImessageProvider();
262
+ stopSmsProvider();
216
263
  if (fs.existsSync(PID_FILE)) {
217
264
  fs.unlinkSync(PID_FILE);
218
265
  }
219
266
  process.exit(0);
220
- });
267
+ };
268
+
269
+ process.on('SIGINT', shutdown);
270
+ process.on('SIGTERM', shutdown);
221
271
  }
222
272
 
223
273
  async function startWhatsAppProvider(sessionDir, config) {
224
274
  try {
225
- loadBaileys();
275
+ const { makeWASocket, useMultiFileAuthState, DisconnectReason, fetchLatestBaileysVersion, Browsers } = loadBaileys();
226
276
 
227
277
  const { state, saveCreds } = await useMultiFileAuthState(sessionDir);
228
278
  const { version } = await fetchLatestBaileysVersion();
@@ -493,6 +543,976 @@ async function startTelegramProvider(config) {
493
543
  }
494
544
  }
495
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
+
496
1516
  function startHttpServer() {
497
1517
  const http = require('http');
498
1518
 
@@ -508,6 +1528,33 @@ function startHttpServer() {
508
1528
  return;
509
1529
  }
510
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
+
511
1558
  if (req.method === 'POST' && req.url === '/send') {
512
1559
  let body = '';
513
1560
 
@@ -563,6 +1610,65 @@ function startHttpServer() {
563
1610
  res.writeHead(200, { 'Content-Type': 'application/json' });
564
1611
  res.end(JSON.stringify({ success: true, channel: 'telegram', target }));
565
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 }));
566
1672
  } else {
567
1673
  res.writeHead(400, { 'Content-Type': 'application/json' });
568
1674
  res.end(JSON.stringify({ error: 'Invalid channel. Use "whatsapp" or "telegram"' }));
@@ -650,7 +1756,7 @@ function startCronJobs(config) {
650
1756
 
651
1757
  if (!response.ok) {
652
1758
  const errorText = await response.text();
653
- throw new Error(`Anthropic API error: ${response.status} - ${errorText}`);
1759
+ throw new ApiError(`Anthropic API error: ${response.status} - ${errorText}`, response.status);
654
1760
  }
655
1761
 
656
1762
  const data = await response.json();
@@ -674,7 +1780,7 @@ function startCronJobs(config) {
674
1780
 
675
1781
  if (!response.ok) {
676
1782
  const errorText = await response.text();
677
- throw new Error(`Provider API error: ${response.status} - ${errorText}`);
1783
+ throw new ApiError(`Provider API error: ${response.status} - ${errorText}`, response.status);
678
1784
  }
679
1785
 
680
1786
  const data = await response.json();
@@ -709,6 +1815,45 @@ function startCronJobs(config) {
709
1815
 
710
1816
  await global.telegramBot.sendMessage(cronJob.target, reply);
711
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');
712
1857
  }
713
1858
 
714
1859
  } catch (err) {