natureco-cli 2.23.29 → 2.23.31
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.
- package/README.md +94 -11
- package/bin/natureco.js +495 -94
- package/package.json +1 -1
- package/src/commands/acp.js +39 -0
- package/src/commands/admin-rpc.js +302 -0
- package/src/commands/agent.js +280 -0
- package/src/commands/agents.js +114 -30
- package/src/commands/approvals.js +214 -0
- package/src/commands/backup.js +124 -0
- package/src/commands/bonjour.js +167 -0
- package/src/commands/browser.js +815 -0
- package/src/commands/capability.js +237 -0
- package/src/commands/channels.js +422 -267
- package/src/commands/chat.js +5 -8
- package/src/commands/clawbot.js +19 -0
- package/src/commands/clickclack.js +130 -0
- package/src/commands/code.js +3 -2
- package/src/commands/commitments.js +148 -0
- package/src/commands/completion.js +84 -0
- package/src/commands/config.js +219 -30
- package/src/commands/configure.js +110 -0
- package/src/commands/crestodian.js +92 -0
- package/src/commands/cron.js +239 -19
- package/src/commands/daemon.js +90 -0
- package/src/commands/dashboard.js +47 -374
- package/src/commands/device-pair.js +248 -0
- package/src/commands/devices.js +137 -0
- package/src/commands/directory.js +179 -0
- package/src/commands/dns.js +196 -0
- package/src/commands/docs.js +136 -0
- package/src/commands/doctor.js +143 -492
- package/src/commands/exec-policy.js +80 -0
- package/src/commands/gateway-server.js +1155 -24
- package/src/commands/gateway.js +492 -249
- package/src/commands/health.js +148 -0
- package/src/commands/help.js +24 -25
- package/src/commands/hooks.js +141 -87
- package/src/commands/imessage.js +128 -14
- package/src/commands/infer.js +1474 -0
- package/src/commands/irc.js +64 -15
- package/src/commands/logs.js +122 -99
- package/src/commands/mattermost.js +114 -12
- package/src/commands/mcp.js +121 -309
- package/src/commands/memory-cmd.js +134 -1
- package/src/commands/memory.js +128 -0
- package/src/commands/message.js +720 -134
- package/src/commands/migrate.js +213 -2
- package/src/commands/models.js +39 -1
- package/src/commands/node.js +98 -0
- package/src/commands/nodes.js +362 -0
- package/src/commands/oc-path.js +200 -0
- package/src/commands/onboard.js +129 -0
- package/src/commands/open-prose.js +67 -0
- package/src/commands/pairing.js +108 -107
- package/src/commands/path.js +206 -0
- package/src/commands/plugins.js +35 -1
- package/src/commands/policy.js +176 -0
- package/src/commands/proxy.js +306 -0
- package/src/commands/qr.js +70 -0
- package/src/commands/reset.js +101 -94
- package/src/commands/sandbox.js +125 -0
- package/src/commands/secrets.js +201 -0
- package/src/commands/sessions.js +110 -51
- package/src/commands/setup.js +102 -543
- package/src/commands/signal.js +447 -18
- package/src/commands/skills.js +67 -1
- package/src/commands/sms.js +123 -19
- package/src/commands/status.js +101 -127
- package/src/commands/system.js +53 -0
- package/src/commands/tasks.js +208 -100
- package/src/commands/terminal.js +139 -0
- package/src/commands/thread-ownership.js +157 -0
- package/src/commands/transcripts.js +95 -0
- package/src/commands/tui.js +41 -0
- package/src/commands/uninstall.js +73 -92
- package/src/commands/update.js +146 -91
- package/src/commands/voice.js +82 -0
- package/src/commands/vydra.js +98 -0
- package/src/commands/webhooks.js +58 -66
- package/src/commands/wiki.js +783 -0
- package/src/commands/workboard.js +207 -0
- package/src/tools/audio_understanding.js +154 -0
- package/src/tools/browser.js +112 -0
- package/src/tools/canvas.js +104 -0
- package/src/tools/document_extract.js +84 -0
- package/src/tools/duckduckgo.js +54 -0
- package/src/tools/exa_search.js +66 -0
- package/src/tools/firecrawl.js +104 -0
- package/src/tools/image_generation.js +99 -0
- package/src/tools/llm_task.js +118 -0
- package/src/tools/media_understanding.js +128 -0
- package/src/tools/music_generation.js +113 -0
- package/src/tools/parallel_search.js +77 -0
- package/src/tools/phone_control.js +80 -0
- package/src/tools/phone_control_enhanced.js +184 -0
- package/src/tools/searxng.js +61 -0
- package/src/tools/speech_to_text.js +135 -0
- package/src/tools/text_to_speech.js +105 -0
- package/src/tools/thread_ownership.js +88 -0
- package/src/tools/video_generation.js +72 -0
- package/src/tools/web_readability.js +104 -0
- package/src/utils/agents-md.js +85 -0
- package/src/utils/api.js +39 -40
- package/src/utils/format.js +144 -0
- package/src/utils/headless.js +2 -1
- package/src/utils/memory.js +200 -0
- package/src/utils/parallel-tools.js +106 -0
- package/src/utils/sub-agent.js +148 -0
- package/src/utils/token-budget.js +304 -0
- package/src/utils/tool-runner.js +7 -5
- package/src/utils/web-fetch.js +107 -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
|
-
//
|
|
181
|
-
if (config.signalBotId
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
if (config.
|
|
185
|
-
|
|
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
|
-
|
|
250
|
+
const shutdown = () => {
|
|
209
251
|
log('gateway', 'Shutting down...', 'yellow');
|
|
210
|
-
if (
|
|
211
|
-
|
|
252
|
+
if (global.signalProvider?.ws) {
|
|
253
|
+
try { global.signalProvider.ws.close(); } catch {}
|
|
212
254
|
}
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
570
|
-
|
|
571
|
-
|
|
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
|
-
|
|
574
|
-
|
|
575
|
-
|
|
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
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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) {
|