@plosson/agentio 0.4.2 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,461 @@
1
+ import { join } from 'path';
2
+ import { readFile, writeFile, unlink } from 'fs/promises';
3
+ import { existsSync, openSync, closeSync, constants } from 'fs';
4
+ import { spawn } from 'child_process';
5
+ import type { ServiceName } from '../types/config';
6
+ import type { GatewayConfig, DaemonState, DEFAULT_GATEWAY_CONFIG } from './types';
7
+ import { CONFIG_DIR, loadConfig } from '../config/config-manager';
8
+ import { getCredentials } from '../auth/token-store';
9
+ import { initDatabase, closeDatabase, insertInboxMessage, inboxMessageExists, getPendingOutboxMessages, updateOutboxStatus, cleanupInbox, cleanupOutbox } from './store';
10
+ import { startApiServer, stopApiServer } from './api';
11
+ import { configureWebhook, queueWebhookNotification, flushWebhook, stopWebhook } from './webhook';
12
+ import type { ServiceAdapter, AdapterInboundMessage } from './adapters/types';
13
+ import { TelegramAdapter } from './adapters/telegram';
14
+ import { WhatsAppAdapter } from './adapters/whatsapp';
15
+ import type { TelegramCredentials } from '../types/telegram';
16
+ import type { WhatsAppCredentials } from '../types/whatsapp';
17
+
18
+ const PID_FILE = join(CONFIG_DIR, 'gateway.pid');
19
+ const LOG_FILE = join(CONFIG_DIR, 'gateway.log');
20
+
21
+ let isRunning = false;
22
+ let shutdownRequested = false;
23
+ let adapters: Map<ServiceName, ServiceAdapter> = new Map();
24
+ let outboxInterval: ReturnType<typeof setInterval> | null = null;
25
+ let cleanupInterval: ReturnType<typeof setInterval> | null = null;
26
+
27
+ /**
28
+ * Get the gateway configuration from config.json
29
+ */
30
+ export async function getGatewayConfig(): Promise<GatewayConfig> {
31
+ const config = await loadConfig();
32
+ return (config as unknown as { gateway?: GatewayConfig }).gateway ?? {};
33
+ }
34
+
35
+ /**
36
+ * Check if daemon is running
37
+ */
38
+ export async function isDaemonRunning(): Promise<{ running: boolean; pid?: number }> {
39
+ if (!existsSync(PID_FILE)) {
40
+ return { running: false };
41
+ }
42
+
43
+ try {
44
+ const pidStr = await readFile(PID_FILE, 'utf-8');
45
+ const pid = parseInt(pidStr.trim(), 10);
46
+
47
+ // Check if process is still running
48
+ try {
49
+ process.kill(pid, 0); // Doesn't kill, just checks
50
+ return { running: true, pid };
51
+ } catch {
52
+ // Process not running, clean up stale PID file
53
+ await unlink(PID_FILE).catch(() => {});
54
+ return { running: false };
55
+ }
56
+ } catch {
57
+ return { running: false };
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Write PID file
63
+ */
64
+ async function writePidFile(): Promise<void> {
65
+ await writeFile(PID_FILE, process.pid.toString(), { mode: 0o600 });
66
+ }
67
+
68
+ /**
69
+ * Remove PID file
70
+ */
71
+ async function removePidFile(): Promise<void> {
72
+ await unlink(PID_FILE).catch(() => {});
73
+ }
74
+
75
+ /**
76
+ * Handle inbound message from adapter
77
+ */
78
+ function handleInboundMessage(service: ServiceName, profile: string, message: AdapterInboundMessage): void {
79
+ // Check for duplicates
80
+ if (inboxMessageExists(service, profile, message.platformId)) {
81
+ return;
82
+ }
83
+
84
+ // Insert into inbox
85
+ const inboxMessage = insertInboxMessage({
86
+ service,
87
+ profile,
88
+ conversationId: message.conversationId,
89
+ platformId: message.platformId,
90
+ senderId: message.senderId,
91
+ senderName: message.senderName,
92
+ senderHandle: message.senderHandle,
93
+ content: message.content,
94
+ mediaType: message.mediaType,
95
+ mediaPath: message.mediaUrl, // Store URL for now, download later if needed
96
+ receivedAt: message.receivedAt,
97
+ replyToId: message.replyToId,
98
+ metadata: message.metadata,
99
+ });
100
+
101
+ console.log(`[inbox] New message: ${service}:${profile} from ${message.senderName || message.senderId}`);
102
+
103
+ // Queue webhook notification
104
+ queueWebhookNotification({
105
+ id: inboxMessage.id,
106
+ service,
107
+ profile,
108
+ sender: message.senderName || message.senderHandle || message.senderId,
109
+ preview: (message.content || '[media]').slice(0, 100),
110
+ });
111
+ }
112
+
113
+ /**
114
+ * Process outbox queue
115
+ */
116
+ async function processOutbox(): Promise<void> {
117
+ const pendingMessages = getPendingOutboxMessages({ limit: 10 });
118
+
119
+ for (const message of pendingMessages) {
120
+ const adapter = adapters.get(message.service);
121
+ if (!adapter) {
122
+ updateOutboxStatus(message.id, 'failed', { error: 'No adapter for service' });
123
+ continue;
124
+ }
125
+
126
+ if (!adapter.isConnected(message.profile)) {
127
+ // Skip, will retry later
128
+ continue;
129
+ }
130
+
131
+ // Mark as sending
132
+ updateOutboxStatus(message.id, 'sending');
133
+
134
+ try {
135
+ const result = await adapter.send(message.profile, {
136
+ conversationId: message.conversationId,
137
+ content: message.content,
138
+ mediaPath: message.mediaPath,
139
+ mediaType: message.mediaType,
140
+ replyToPlatformId: message.replyToPlatformId,
141
+ metadata: message.metadata,
142
+ });
143
+
144
+ if (result.success) {
145
+ updateOutboxStatus(message.id, 'sent', { platformId: result.platformId });
146
+ console.log(`[outbox] Sent: ${message.service}:${message.profile} -> ${message.conversationId}`);
147
+ } else {
148
+ updateOutboxStatus(message.id, 'failed', { error: result.error });
149
+ console.error(`[outbox] Failed: ${message.service}:${message.profile} - ${result.error}`);
150
+ }
151
+ } catch (error) {
152
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
153
+ updateOutboxStatus(message.id, 'failed', { error: errorMessage });
154
+ console.error(`[outbox] Error: ${message.service}:${message.profile} - ${errorMessage}`);
155
+ }
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Run retention cleanup
161
+ */
162
+ async function runCleanup(config: GatewayConfig): Promise<void> {
163
+ const retention = config.retention ?? {};
164
+
165
+ if (retention.doneMessagesDays && retention.doneMessagesDays > 0) {
166
+ const deleted = cleanupInbox(retention.doneMessagesDays);
167
+ if (deleted > 0) {
168
+ console.log(`[cleanup] Deleted ${deleted} old inbox messages`);
169
+ }
170
+ }
171
+
172
+ if (retention.sentMessagesDays && retention.sentMessagesDays > 0) {
173
+ const deleted = cleanupOutbox(retention.sentMessagesDays);
174
+ if (deleted > 0) {
175
+ console.log(`[cleanup] Deleted ${deleted} old outbox messages`);
176
+ }
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Initialize adapters based on configured profiles
182
+ */
183
+ async function initializeAdapters(): Promise<void> {
184
+ const config = await loadConfig();
185
+
186
+ // Initialize Telegram adapter if profiles exist
187
+ const telegramProfiles = config.profiles.telegram || [];
188
+ if (telegramProfiles.length > 0) {
189
+ const telegramAdapter = new TelegramAdapter();
190
+ telegramAdapter.onMessage = (profile, message) => {
191
+ handleInboundMessage('telegram', profile, message);
192
+ };
193
+
194
+ for (const profile of telegramProfiles) {
195
+ try {
196
+ const credentials = await getCredentials<TelegramCredentials>('telegram', profile);
197
+ if (credentials) {
198
+ await telegramAdapter.connect(profile, credentials);
199
+ } else {
200
+ console.error(`[telegram] No credentials for profile: ${profile}`);
201
+ }
202
+ } catch (error) {
203
+ console.error(`[telegram] Failed to connect ${profile}:`, error instanceof Error ? error.message : error);
204
+ }
205
+ }
206
+
207
+ adapters.set('telegram', telegramAdapter);
208
+ }
209
+
210
+ // Initialize WhatsApp adapter if profiles exist
211
+ const whatsappProfiles = config.profiles.whatsapp || [];
212
+ if (whatsappProfiles.length > 0) {
213
+ const whatsappAdapter = new WhatsAppAdapter();
214
+ whatsappAdapter.onMessage = (profile, message) => {
215
+ handleInboundMessage('whatsapp', profile, message);
216
+ };
217
+
218
+ for (const profile of whatsappProfiles) {
219
+ try {
220
+ const credentials = await getCredentials<WhatsAppCredentials>('whatsapp', profile);
221
+ if (credentials) {
222
+ await whatsappAdapter.connect(profile, credentials);
223
+ } else {
224
+ console.error(`[whatsapp] No credentials for profile: ${profile}`);
225
+ }
226
+ } catch (error) {
227
+ console.error(`[whatsapp] Failed to connect ${profile}:`, error instanceof Error ? error.message : error);
228
+ }
229
+ }
230
+
231
+ adapters.set('whatsapp', whatsappAdapter);
232
+ }
233
+ }
234
+
235
+ /**
236
+ * Shutdown all adapters
237
+ */
238
+ async function shutdownAdapters(): Promise<void> {
239
+ for (const [service, adapter] of adapters) {
240
+ try {
241
+ await adapter.disconnectAll();
242
+ console.log(`[${service}] Disconnected all profiles`);
243
+ } catch (error) {
244
+ console.error(`[${service}] Shutdown error:`, error instanceof Error ? error.message : error);
245
+ }
246
+ }
247
+ adapters.clear();
248
+ }
249
+
250
+ /**
251
+ * Start the gateway daemon
252
+ */
253
+ export async function startDaemon(options: { foreground?: boolean } = {}): Promise<void> {
254
+ // Check if already running
255
+ const status = await isDaemonRunning();
256
+ if (status.running) {
257
+ throw new Error(`Gateway already running (PID ${status.pid})`);
258
+ }
259
+
260
+ if (!options.foreground) {
261
+ // Fork to background
262
+ // Find the script path from argv or use import.meta to get current file location
263
+ let scriptPath: string;
264
+
265
+ // import.meta.path gives us the current file path, navigate to index.ts
266
+ const currentFile = import.meta.path || import.meta.url.replace('file://', '');
267
+ const srcDir = join(currentFile, '..', '..');
268
+ scriptPath = join(srcDir, 'index.ts');
269
+
270
+ // Verify the path exists, fallback to cwd-based path
271
+ if (!existsSync(scriptPath)) {
272
+ scriptPath = join(process.cwd(), 'src', 'index.ts');
273
+ }
274
+
275
+ // Open log file for appending - child writes directly to file
276
+ const logFd = openSync(LOG_FILE, constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND, 0o644);
277
+
278
+ const child = spawn(process.execPath, [scriptPath, 'gateway', 'start', '--foreground'], {
279
+ detached: true,
280
+ stdio: ['ignore', logFd, logFd],
281
+ env: process.env,
282
+ });
283
+
284
+ child.unref();
285
+
286
+ // Close the fd in parent - child has its own copy
287
+ closeSync(logFd);
288
+
289
+ console.log(`Gateway started in background (PID ${child.pid})`);
290
+ console.log(`Logs: ${LOG_FILE}`);
291
+ return;
292
+ }
293
+
294
+ // Running in foreground
295
+ isRunning = true;
296
+ console.log(`Gateway starting (PID ${process.pid})`);
297
+
298
+ // Handle shutdown signals
299
+ const shutdown = async (signal: string) => {
300
+ if (shutdownRequested) return;
301
+ shutdownRequested = true;
302
+
303
+ console.log(`\nReceived ${signal}, shutting down...`);
304
+
305
+ // Stop intervals
306
+ if (outboxInterval) clearInterval(outboxInterval);
307
+ if (cleanupInterval) clearInterval(cleanupInterval);
308
+
309
+ // Flush webhooks
310
+ await flushWebhook();
311
+ stopWebhook();
312
+
313
+ // Shutdown adapters
314
+ await shutdownAdapters();
315
+
316
+ // Stop API server
317
+ stopApiServer();
318
+
319
+ // Close database
320
+ closeDatabase();
321
+
322
+ // Remove PID file
323
+ await removePidFile();
324
+
325
+ console.log('Gateway stopped');
326
+ process.exit(0);
327
+ };
328
+
329
+ process.on('SIGINT', () => shutdown('SIGINT'));
330
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
331
+
332
+ try {
333
+ // Write PID file
334
+ await writePidFile();
335
+
336
+ // Load config
337
+ const gatewayConfig = await getGatewayConfig();
338
+
339
+ // Initialize database
340
+ await initDatabase();
341
+ console.log('Database initialized');
342
+
343
+ // Configure webhook
344
+ if (gatewayConfig.webhook?.url) {
345
+ configureWebhook(gatewayConfig.webhook);
346
+ console.log(`Webhook configured: ${gatewayConfig.webhook.url}`);
347
+ }
348
+
349
+ // Initialize adapters
350
+ await initializeAdapters();
351
+
352
+ // Start API server
353
+ startApiServer(gatewayConfig.api, adapters);
354
+
355
+ // Start outbox processor (every 2 seconds)
356
+ outboxInterval = setInterval(processOutbox, 2000);
357
+
358
+ // Start cleanup job (every hour)
359
+ cleanupInterval = setInterval(() => runCleanup(gatewayConfig), 60 * 60 * 1000);
360
+
361
+ console.log('Gateway ready');
362
+
363
+ // Keep running
364
+ await new Promise(() => {}); // Wait forever
365
+ } catch (error) {
366
+ console.error('Gateway error:', error instanceof Error ? error.message : error);
367
+ await removePidFile();
368
+ process.exit(1);
369
+ }
370
+ }
371
+
372
+ /**
373
+ * Stop the gateway daemon
374
+ */
375
+ export async function stopDaemon(): Promise<void> {
376
+ const status = await isDaemonRunning();
377
+ if (!status.running || !status.pid) {
378
+ console.log('Gateway is not running');
379
+ return;
380
+ }
381
+
382
+ try {
383
+ process.kill(status.pid, 'SIGTERM');
384
+ console.log(`Sent SIGTERM to gateway (PID ${status.pid})`);
385
+
386
+ // Wait for process to stop
387
+ for (let i = 0; i < 30; i++) {
388
+ await new Promise((resolve) => setTimeout(resolve, 100));
389
+ try {
390
+ process.kill(status.pid, 0);
391
+ } catch {
392
+ console.log('Gateway stopped');
393
+ return;
394
+ }
395
+ }
396
+
397
+ // Force kill if still running
398
+ try {
399
+ process.kill(status.pid, 'SIGKILL');
400
+ console.log('Gateway force killed');
401
+ } catch {
402
+ console.log('Gateway stopped');
403
+ }
404
+ } catch (error) {
405
+ console.error('Failed to stop gateway:', error instanceof Error ? error.message : error);
406
+ }
407
+ }
408
+
409
+ /**
410
+ * Get daemon status
411
+ */
412
+ export async function getDaemonStatus(): Promise<{
413
+ running: boolean;
414
+ pid?: number;
415
+ adapters?: { service: string; profile: string; connected: boolean }[];
416
+ }> {
417
+ const status = await isDaemonRunning();
418
+ if (!status.running) {
419
+ return { running: false };
420
+ }
421
+
422
+ // Try to get status from API
423
+ const gatewayConfig = await getGatewayConfig();
424
+ const port = gatewayConfig.api?.port ?? 7890;
425
+ const host = gatewayConfig.api?.host ?? '127.0.0.1';
426
+
427
+ try {
428
+ const response = await fetch(`http://${host}:${port}/status`, {
429
+ headers: gatewayConfig.api?.secret ? { Authorization: `Bearer ${gatewayConfig.api.secret}` } : {},
430
+ });
431
+
432
+ if (response.ok) {
433
+ const data = await response.json() as { adapters: { service: string; profile: string; connected: boolean }[] };
434
+ return {
435
+ running: true,
436
+ pid: status.pid,
437
+ adapters: data.adapters,
438
+ };
439
+ }
440
+ } catch {
441
+ // API not responding, but process exists
442
+ }
443
+
444
+ return { running: true, pid: status.pid };
445
+ }
446
+
447
+ /**
448
+ * Reload daemon configuration
449
+ */
450
+ export async function reloadDaemon(): Promise<void> {
451
+ const status = await isDaemonRunning();
452
+ if (!status.running || !status.pid) {
453
+ throw new Error('Gateway is not running');
454
+ }
455
+
456
+ // Send SIGHUP to trigger reload
457
+ process.kill(status.pid, 'SIGHUP');
458
+ console.log(`Sent reload signal to gateway (PID ${status.pid})`);
459
+ }
460
+
461
+ export { PID_FILE, LOG_FILE };