natureco-cli 2.23.29 → 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 (71) hide show
  1. package/README.md +94 -11
  2. package/bin/natureco.js +402 -4
  3. package/package.json +1 -1
  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/clickclack.js +130 -0
  11. package/src/commands/commitments.js +32 -0
  12. package/src/commands/completion.js +76 -0
  13. package/src/commands/configure.js +93 -0
  14. package/src/commands/crestodian.js +92 -0
  15. package/src/commands/daemon.js +60 -0
  16. package/src/commands/device-pair.js +248 -0
  17. package/src/commands/devices.js +110 -0
  18. package/src/commands/directory.js +47 -0
  19. package/src/commands/dns.js +58 -0
  20. package/src/commands/docs.js +43 -0
  21. package/src/commands/exec-policy.js +71 -0
  22. package/src/commands/gateway-server.js +1155 -24
  23. package/src/commands/health.js +18 -0
  24. package/src/commands/imessage.js +128 -14
  25. package/src/commands/infer.js +73 -0
  26. package/src/commands/irc.js +64 -15
  27. package/src/commands/mattermost.js +114 -12
  28. package/src/commands/memory-cmd.js +134 -1
  29. package/src/commands/message.js +9 -3
  30. package/src/commands/migrate.js +213 -2
  31. package/src/commands/node.js +98 -0
  32. package/src/commands/nodes.js +106 -0
  33. package/src/commands/oc-path.js +200 -0
  34. package/src/commands/onboard.js +70 -0
  35. package/src/commands/open-prose.js +67 -0
  36. package/src/commands/policy.js +176 -0
  37. package/src/commands/proxy.js +155 -0
  38. package/src/commands/qr.js +28 -0
  39. package/src/commands/sandbox.js +125 -0
  40. package/src/commands/secrets.js +118 -0
  41. package/src/commands/setup.js +113 -7
  42. package/src/commands/signal.js +447 -18
  43. package/src/commands/sms.js +123 -19
  44. package/src/commands/system.js +53 -0
  45. package/src/commands/terminal.js +21 -0
  46. package/src/commands/thread-ownership.js +157 -0
  47. package/src/commands/transcripts.js +72 -0
  48. package/src/commands/voice.js +82 -0
  49. package/src/commands/vydra.js +98 -0
  50. package/src/commands/workboard.js +207 -0
  51. package/src/tools/audio_understanding.js +154 -0
  52. package/src/tools/browser.js +112 -0
  53. package/src/tools/canvas.js +104 -0
  54. package/src/tools/document_extract.js +84 -0
  55. package/src/tools/duckduckgo.js +54 -0
  56. package/src/tools/exa_search.js +66 -0
  57. package/src/tools/firecrawl.js +104 -0
  58. package/src/tools/image_generation.js +99 -0
  59. package/src/tools/llm_task.js +118 -0
  60. package/src/tools/media_understanding.js +128 -0
  61. package/src/tools/music_generation.js +113 -0
  62. package/src/tools/parallel_search.js +77 -0
  63. package/src/tools/phone_control.js +80 -0
  64. package/src/tools/phone_control_enhanced.js +184 -0
  65. package/src/tools/searxng.js +61 -0
  66. package/src/tools/speech_to_text.js +135 -0
  67. package/src/tools/text_to_speech.js +105 -0
  68. package/src/tools/thread_ownership.js +88 -0
  69. package/src/tools/video_generation.js +72 -0
  70. package/src/tools/web_readability.js +104 -0
  71. package/src/utils/memory.js +200 -0
@@ -153,6 +153,7 @@ async function runGatewayWorker() {
153
153
  // Store provider instances globally for HTTP endpoint
154
154
  global.whatsappSock = null;
155
155
  global.telegramBot = null;
156
+ global.signalProvider = null;
156
157
 
157
158
  // Start WhatsApp if configured
158
159
  if (config.whatsappConnected && config.whatsappBotId) {
@@ -177,12 +178,53 @@ async function runGatewayWorker() {
177
178
  log('telegram', 'not configured, skipping', 'gray');
178
179
  }
179
180
 
180
- // Log new channel statuses
181
- if (config.signalBotId) log('signal', `configured (${config.signalAccount || config.signalHttpUrl})`, 'gray');
182
- if (config.ircBotId) log('irc', `configured (${config.ircNick} @ ${config.ircHost})`, 'gray');
183
- if (config.mattermostBotId) log('mattermost', `configured (${config.mattermostBaseUrl})`, 'gray');
184
- if (config.imessageBotId) log('imessage', 'configured (macOS only)', 'gray');
185
- if (config.smsBotId) log('sms', `configured (${config.smsFromNumber})`, 'gray');
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
+ }
186
228
  if (config.webhooks?.length) log('webhooks', `${config.webhooks.length} route(s) configured`, 'gray');
187
229
 
188
230
  // Start HTTP server for message sending
@@ -205,21 +247,27 @@ async function runGatewayWorker() {
205
247
  log('gateway', 'Gateway running in background', 'green');
206
248
 
207
249
  // Handle shutdown
208
- process.on('SIGINT', () => {
250
+ const shutdown = () => {
209
251
  log('gateway', 'Shutting down...', 'yellow');
210
- if (fs.existsSync(PID_FILE)) {
211
- fs.unlinkSync(PID_FILE);
252
+ if (global.signalProvider?.ws) {
253
+ try { global.signalProvider.ws.close(); } catch {}
212
254
  }
213
- process.exit(0);
214
- });
215
-
216
- process.on('SIGTERM', () => {
217
- log('gateway', 'Shutting down...', 'yellow');
255
+ if (signalPollingInterval) {
256
+ clearInterval(signalPollingInterval);
257
+ signalPollingInterval = null;
258
+ }
259
+ stopIrcProvider();
260
+ stopMattermostProvider();
261
+ stopImessageProvider();
262
+ stopSmsProvider();
218
263
  if (fs.existsSync(PID_FILE)) {
219
264
  fs.unlinkSync(PID_FILE);
220
265
  }
221
266
  process.exit(0);
222
- });
267
+ };
268
+
269
+ process.on('SIGINT', shutdown);
270
+ process.on('SIGTERM', shutdown);
223
271
  }
224
272
 
225
273
  async function startWhatsAppProvider(sessionDir, config) {
@@ -495,6 +543,976 @@ async function startTelegramProvider(config) {
495
543
  }
496
544
  }
497
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
+
498
1516
  function startHttpServer() {
499
1517
  const http = require('http');
500
1518
 
@@ -510,6 +1528,33 @@ function startHttpServer() {
510
1528
  return;
511
1529
  }
512
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
+
513
1558
  if (req.method === 'POST' && req.url === '/send') {
514
1559
  let body = '';
515
1560
 
@@ -566,17 +1611,64 @@ function startHttpServer() {
566
1611
  res.end(JSON.stringify({ success: true, channel: 'telegram', target }));
567
1612
 
568
1613
  } else if (channel === 'signal') {
569
- log('http', `Signal message send requested (${target}) — requires signal-cli REST', 'yellow');
570
- res.writeHead(501, { 'Content-Type': 'application/json' });
571
- res.end(JSON.stringify({ error: 'Signal outbound via HTTP not yet implemented' }));
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 }));
572
1625
  } else if (channel === 'irc') {
573
- log('http', `IRC message send requested (${target})`, 'yellow');
574
- res.writeHead(501, { 'Content-Type': 'application/json' });
575
- res.end(JSON.stringify({ error: 'IRC outbound via HTTP not yet implemented' }));
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
+ }
576
1662
  } else if (channel === 'sms') {
577
- log('http', `SMS message send requested (${target})`, 'yellow');
578
- res.writeHead(501, { 'Content-Type': 'application/json' });
579
- res.end(JSON.stringify({ error: 'SMS outbound via HTTP not yet implemented' }));
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 }));
580
1672
  } else {
581
1673
  res.writeHead(400, { 'Content-Type': 'application/json' });
582
1674
  res.end(JSON.stringify({ error: 'Invalid channel. Use "whatsapp" or "telegram"' }));
@@ -723,6 +1815,45 @@ function startCronJobs(config) {
723
1815
 
724
1816
  await global.telegramBot.sendMessage(cronJob.target, reply);
725
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');
726
1857
  }
727
1858
 
728
1859
  } catch (err) {