@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,637 @@
1
+ import { Database } from 'bun:sqlite';
2
+ import { join } from 'path';
3
+ import { randomUUID } from 'crypto';
4
+ import { ensureConfigDir, CONFIG_DIR } from '../config/config-manager';
5
+ import type { ServiceName } from '../types/config';
6
+ import type {
7
+ InboundMessage,
8
+ OutboundMessage,
9
+ InboxStatus,
10
+ OutboxStatus,
11
+ MediaType,
12
+ } from './types';
13
+
14
+ const DATABASE_FILE = join(CONFIG_DIR, 'gateway.db');
15
+
16
+ let db: Database | null = null;
17
+
18
+ /**
19
+ * Initialize the database and create tables if needed
20
+ */
21
+ export async function initDatabase(): Promise<Database> {
22
+ if (db) return db;
23
+
24
+ await ensureConfigDir();
25
+
26
+ db = new Database(DATABASE_FILE);
27
+
28
+ // Enable WAL mode for better concurrent access
29
+ db.run('PRAGMA journal_mode = WAL');
30
+
31
+ // Create inbox table
32
+ db.run(`
33
+ CREATE TABLE IF NOT EXISTS inbox (
34
+ id TEXT PRIMARY KEY,
35
+ service TEXT NOT NULL,
36
+ profile TEXT NOT NULL,
37
+ conversation_id TEXT NOT NULL,
38
+ platform_id TEXT NOT NULL,
39
+
40
+ sender_id TEXT NOT NULL,
41
+ sender_name TEXT,
42
+ sender_handle TEXT,
43
+
44
+ content TEXT,
45
+ media_type TEXT,
46
+ media_path TEXT,
47
+
48
+ received_at INTEGER NOT NULL,
49
+ status TEXT DEFAULT 'pending',
50
+ done_at INTEGER,
51
+
52
+ reply_to_id TEXT,
53
+ metadata TEXT,
54
+
55
+ UNIQUE(service, profile, platform_id)
56
+ )
57
+ `);
58
+
59
+ // Create outbox table
60
+ db.run(`
61
+ CREATE TABLE IF NOT EXISTS outbox (
62
+ id TEXT PRIMARY KEY,
63
+ service TEXT NOT NULL,
64
+ profile TEXT NOT NULL,
65
+ conversation_id TEXT NOT NULL,
66
+
67
+ content TEXT,
68
+ media_path TEXT,
69
+ media_type TEXT,
70
+
71
+ reply_to_platform_id TEXT,
72
+
73
+ queued_at INTEGER NOT NULL,
74
+ status TEXT DEFAULT 'pending',
75
+ sent_at INTEGER,
76
+ error TEXT,
77
+ platform_id TEXT,
78
+
79
+ metadata TEXT
80
+ )
81
+ `);
82
+
83
+ // Create indexes
84
+ db.run('CREATE INDEX IF NOT EXISTS idx_inbox_status ON inbox(service, profile, status)');
85
+ db.run('CREATE INDEX IF NOT EXISTS idx_inbox_received ON inbox(received_at)');
86
+ db.run('CREATE INDEX IF NOT EXISTS idx_outbox_status ON outbox(service, profile, status)');
87
+ db.run('CREATE INDEX IF NOT EXISTS idx_outbox_queued ON outbox(queued_at)');
88
+
89
+ // WhatsApp auth state tables (for Baileys)
90
+ // Stores authentication credentials (keys, registration info)
91
+ db.run(`
92
+ CREATE TABLE IF NOT EXISTS whatsapp_auth_creds (
93
+ profile TEXT PRIMARY KEY,
94
+ data TEXT NOT NULL,
95
+ updated_at INTEGER NOT NULL
96
+ )
97
+ `);
98
+
99
+ // Stores session keys (pre-keys, sender keys, etc.)
100
+ db.run(`
101
+ CREATE TABLE IF NOT EXISTS whatsapp_auth_keys (
102
+ profile TEXT NOT NULL,
103
+ type TEXT NOT NULL,
104
+ key_id TEXT NOT NULL,
105
+ data TEXT NOT NULL,
106
+ PRIMARY KEY (profile, type, key_id)
107
+ )
108
+ `);
109
+
110
+ db.run('CREATE INDEX IF NOT EXISTS idx_whatsapp_keys ON whatsapp_auth_keys(profile, type)');
111
+
112
+ return db;
113
+ }
114
+
115
+ /**
116
+ * Get the database instance (must call initDatabase first)
117
+ */
118
+ export function getDatabase(): Database {
119
+ if (!db) {
120
+ throw new Error('Database not initialized. Call initDatabase() first.');
121
+ }
122
+ return db;
123
+ }
124
+
125
+ /**
126
+ * Close the database connection
127
+ */
128
+ export function closeDatabase(): void {
129
+ if (db) {
130
+ db.close();
131
+ db = null;
132
+ }
133
+ }
134
+
135
+ // ============ INBOX OPERATIONS ============
136
+
137
+ interface InboxRow {
138
+ id: string;
139
+ service: string;
140
+ profile: string;
141
+ conversation_id: string;
142
+ platform_id: string;
143
+ sender_id: string;
144
+ sender_name: string | null;
145
+ sender_handle: string | null;
146
+ content: string | null;
147
+ media_type: string | null;
148
+ media_path: string | null;
149
+ received_at: number;
150
+ status: string;
151
+ done_at: number | null;
152
+ reply_to_id: string | null;
153
+ metadata: string | null;
154
+ }
155
+
156
+ function rowToInboundMessage(row: InboxRow): InboundMessage {
157
+ return {
158
+ id: row.id,
159
+ service: row.service as ServiceName,
160
+ profile: row.profile,
161
+ conversationId: row.conversation_id,
162
+ platformId: row.platform_id,
163
+ senderId: row.sender_id,
164
+ senderName: row.sender_name ?? undefined,
165
+ senderHandle: row.sender_handle ?? undefined,
166
+ content: row.content ?? undefined,
167
+ mediaType: row.media_type as MediaType | undefined,
168
+ mediaPath: row.media_path ?? undefined,
169
+ receivedAt: row.received_at,
170
+ status: row.status as InboxStatus,
171
+ doneAt: row.done_at ?? undefined,
172
+ replyToId: row.reply_to_id ?? undefined,
173
+ metadata: row.metadata ? JSON.parse(row.metadata) : undefined,
174
+ };
175
+ }
176
+
177
+ /**
178
+ * Insert a new message into the inbox
179
+ */
180
+ export function insertInboxMessage(message: Omit<InboundMessage, 'id' | 'status' | 'doneAt'>): InboundMessage {
181
+ const db = getDatabase();
182
+ const id = randomUUID();
183
+
184
+ db.run(
185
+ `INSERT INTO inbox (id, service, profile, conversation_id, platform_id, sender_id, sender_name, sender_handle, content, media_type, media_path, received_at, status, reply_to_id, metadata)
186
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)`,
187
+ [
188
+ id,
189
+ message.service,
190
+ message.profile,
191
+ message.conversationId,
192
+ message.platformId,
193
+ message.senderId,
194
+ message.senderName ?? null,
195
+ message.senderHandle ?? null,
196
+ message.content ?? null,
197
+ message.mediaType ?? null,
198
+ message.mediaPath ?? null,
199
+ message.receivedAt,
200
+ message.replyToId ?? null,
201
+ message.metadata ? JSON.stringify(message.metadata) : null,
202
+ ]
203
+ );
204
+
205
+ return {
206
+ ...message,
207
+ id,
208
+ status: 'pending',
209
+ };
210
+ }
211
+
212
+ /**
213
+ * Check if a message already exists (by platform ID)
214
+ */
215
+ export function inboxMessageExists(service: ServiceName, profile: string, platformId: string): boolean {
216
+ const db = getDatabase();
217
+ const row = db.query(
218
+ 'SELECT 1 FROM inbox WHERE service = ? AND profile = ? AND platform_id = ?'
219
+ ).get(service, profile, platformId);
220
+ return !!row;
221
+ }
222
+
223
+ /**
224
+ * Get messages from inbox
225
+ */
226
+ export function getInboxMessages(options: {
227
+ service?: ServiceName;
228
+ profile?: string;
229
+ conversationId?: string;
230
+ status?: InboxStatus;
231
+ limit?: number;
232
+ }): InboundMessage[] {
233
+ const db = getDatabase();
234
+ const conditions: string[] = [];
235
+ const params: (string | number)[] = [];
236
+
237
+ if (options.service) {
238
+ conditions.push('service = ?');
239
+ params.push(options.service);
240
+ }
241
+ if (options.profile) {
242
+ conditions.push('profile = ?');
243
+ params.push(options.profile);
244
+ }
245
+ if (options.conversationId) {
246
+ conditions.push('conversation_id = ?');
247
+ params.push(options.conversationId);
248
+ }
249
+ if (options.status) {
250
+ conditions.push('status = ?');
251
+ params.push(options.status);
252
+ }
253
+
254
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
255
+ const limit = options.limit ? `LIMIT ${options.limit}` : '';
256
+
257
+ const rows = db.query<InboxRow, (string | number)[]>(
258
+ `SELECT * FROM inbox ${where} ORDER BY received_at ASC ${limit}`
259
+ ).all(...params);
260
+
261
+ return rows.map(rowToInboundMessage);
262
+ }
263
+
264
+ /**
265
+ * Get a single inbox message by ID (supports partial ID prefix matching)
266
+ */
267
+ export function getInboxMessage(id: string): InboundMessage | null {
268
+ const db = getDatabase();
269
+
270
+ // Full UUID is 36 characters, if shorter try prefix match
271
+ if (id.length < 36) {
272
+ const rows = db.query<InboxRow, [string]>(
273
+ 'SELECT * FROM inbox WHERE id LIKE ? LIMIT 2'
274
+ ).all(`${id}%`);
275
+
276
+ // Only return if exactly one match (avoid ambiguity)
277
+ if (rows.length === 1) {
278
+ return rowToInboundMessage(rows[0]);
279
+ }
280
+ return null;
281
+ }
282
+
283
+ const row = db.query<InboxRow, [string]>(
284
+ 'SELECT * FROM inbox WHERE id = ?'
285
+ ).get(id);
286
+
287
+ return row ? rowToInboundMessage(row) : null;
288
+ }
289
+
290
+ /**
291
+ * Mark an inbox message as done (supports partial ID prefix matching)
292
+ */
293
+ export function ackInboxMessage(id: string): boolean {
294
+ const db = getDatabase();
295
+
296
+ // Full UUID is 36 characters, if shorter resolve full ID first
297
+ let fullId = id;
298
+ if (id.length < 36) {
299
+ const message = getInboxMessage(id);
300
+ if (!message) return false;
301
+ fullId = message.id;
302
+ }
303
+
304
+ const result = db.run(
305
+ 'UPDATE inbox SET status = ?, done_at = ? WHERE id = ? AND status = ?',
306
+ ['done', Date.now(), fullId, 'pending']
307
+ );
308
+ return result.changes > 0;
309
+ }
310
+
311
+ /**
312
+ * Get inbox statistics
313
+ */
314
+ export function getInboxStats(options: {
315
+ service?: ServiceName;
316
+ profile?: string;
317
+ }): { pending: number; done: number; total: number } {
318
+ const db = getDatabase();
319
+ const conditions: string[] = [];
320
+ const params: string[] = [];
321
+
322
+ if (options.service) {
323
+ conditions.push('service = ?');
324
+ params.push(options.service);
325
+ }
326
+ if (options.profile) {
327
+ conditions.push('profile = ?');
328
+ params.push(options.profile);
329
+ }
330
+
331
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
332
+
333
+ const row = db.query<{ pending: number; done: number; total: number }, string[]>(
334
+ `SELECT
335
+ SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending,
336
+ SUM(CASE WHEN status = 'done' THEN 1 ELSE 0 END) as done,
337
+ COUNT(*) as total
338
+ FROM inbox ${where}`
339
+ ).get(...params);
340
+
341
+ return row ?? { pending: 0, done: 0, total: 0 };
342
+ }
343
+
344
+ /**
345
+ * Delete old processed messages (retention cleanup)
346
+ */
347
+ export function cleanupInbox(daysOld: number): number {
348
+ if (daysOld <= 0) return 0;
349
+ const db = getDatabase();
350
+ const cutoff = Date.now() - (daysOld * 24 * 60 * 60 * 1000);
351
+ const result = db.run(
352
+ 'DELETE FROM inbox WHERE status = ? AND done_at < ?',
353
+ ['done', cutoff]
354
+ );
355
+ return result.changes;
356
+ }
357
+
358
+ // ============ OUTBOX OPERATIONS ============
359
+
360
+ interface OutboxRow {
361
+ id: string;
362
+ service: string;
363
+ profile: string;
364
+ conversation_id: string;
365
+ content: string | null;
366
+ media_path: string | null;
367
+ media_type: string | null;
368
+ reply_to_platform_id: string | null;
369
+ queued_at: number;
370
+ status: string;
371
+ sent_at: number | null;
372
+ error: string | null;
373
+ platform_id: string | null;
374
+ metadata: string | null;
375
+ }
376
+
377
+ function rowToOutboundMessage(row: OutboxRow): OutboundMessage {
378
+ return {
379
+ id: row.id,
380
+ service: row.service as ServiceName,
381
+ profile: row.profile,
382
+ conversationId: row.conversation_id,
383
+ content: row.content ?? undefined,
384
+ mediaPath: row.media_path ?? undefined,
385
+ mediaType: row.media_type as MediaType | undefined,
386
+ replyToPlatformId: row.reply_to_platform_id ?? undefined,
387
+ queuedAt: row.queued_at,
388
+ status: row.status as OutboxStatus,
389
+ sentAt: row.sent_at ?? undefined,
390
+ error: row.error ?? undefined,
391
+ platformId: row.platform_id ?? undefined,
392
+ metadata: row.metadata ? JSON.parse(row.metadata) : undefined,
393
+ };
394
+ }
395
+
396
+ /**
397
+ * Queue a new message for sending
398
+ */
399
+ export function queueOutboxMessage(message: Omit<OutboundMessage, 'id' | 'status' | 'queuedAt'>): OutboundMessage {
400
+ const db = getDatabase();
401
+ const id = randomUUID();
402
+ const queuedAt = Date.now();
403
+
404
+ db.run(
405
+ `INSERT INTO outbox (id, service, profile, conversation_id, content, media_path, media_type, reply_to_platform_id, queued_at, status, metadata)
406
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?)`,
407
+ [
408
+ id,
409
+ message.service,
410
+ message.profile,
411
+ message.conversationId,
412
+ message.content ?? null,
413
+ message.mediaPath ?? null,
414
+ message.mediaType ?? null,
415
+ message.replyToPlatformId ?? null,
416
+ queuedAt,
417
+ message.metadata ? JSON.stringify(message.metadata) : null,
418
+ ]
419
+ );
420
+
421
+ return {
422
+ ...message,
423
+ id,
424
+ status: 'pending',
425
+ queuedAt,
426
+ };
427
+ }
428
+
429
+ /**
430
+ * Get pending outbox messages for processing
431
+ */
432
+ export function getPendingOutboxMessages(options?: {
433
+ service?: ServiceName;
434
+ profile?: string;
435
+ limit?: number;
436
+ }): OutboundMessage[] {
437
+ const db = getDatabase();
438
+ const conditions: string[] = ['status = ?'];
439
+ const params: (string | number)[] = ['pending'];
440
+
441
+ if (options?.service) {
442
+ conditions.push('service = ?');
443
+ params.push(options.service);
444
+ }
445
+ if (options?.profile) {
446
+ conditions.push('profile = ?');
447
+ params.push(options.profile);
448
+ }
449
+
450
+ const where = `WHERE ${conditions.join(' AND ')}`;
451
+ const limit = options?.limit ? `LIMIT ${options.limit}` : '';
452
+
453
+ const rows = db.query<OutboxRow, (string | number)[]>(
454
+ `SELECT * FROM outbox ${where} ORDER BY queued_at ASC ${limit}`
455
+ ).all(...params);
456
+
457
+ return rows.map(rowToOutboundMessage);
458
+ }
459
+
460
+ /**
461
+ * Get outbox messages with filters
462
+ */
463
+ export function getOutboxMessages(options: {
464
+ service?: ServiceName;
465
+ profile?: string;
466
+ status?: OutboxStatus;
467
+ limit?: number;
468
+ }): OutboundMessage[] {
469
+ const db = getDatabase();
470
+ const conditions: string[] = [];
471
+ const params: (string | number)[] = [];
472
+
473
+ if (options.service) {
474
+ conditions.push('service = ?');
475
+ params.push(options.service);
476
+ }
477
+ if (options.profile) {
478
+ conditions.push('profile = ?');
479
+ params.push(options.profile);
480
+ }
481
+ if (options.status) {
482
+ conditions.push('status = ?');
483
+ params.push(options.status);
484
+ }
485
+
486
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
487
+ const limit = options.limit ? `LIMIT ${options.limit}` : '';
488
+
489
+ const rows = db.query<OutboxRow, (string | number)[]>(
490
+ `SELECT * FROM outbox ${where} ORDER BY queued_at DESC ${limit}`
491
+ ).all(...params);
492
+
493
+ return rows.map(rowToOutboundMessage);
494
+ }
495
+
496
+ /**
497
+ * Get a single outbox message by ID (supports partial ID prefix matching)
498
+ */
499
+ export function getOutboxMessage(id: string): OutboundMessage | null {
500
+ const db = getDatabase();
501
+
502
+ // Full UUID is 36 characters, if shorter try prefix match
503
+ if (id.length < 36) {
504
+ const rows = db.query<OutboxRow, [string]>(
505
+ 'SELECT * FROM outbox WHERE id LIKE ? LIMIT 2'
506
+ ).all(`${id}%`);
507
+
508
+ // Only return if exactly one match (avoid ambiguity)
509
+ if (rows.length === 1) {
510
+ return rowToOutboundMessage(rows[0]);
511
+ }
512
+ return null;
513
+ }
514
+
515
+ const row = db.query<OutboxRow, [string]>(
516
+ 'SELECT * FROM outbox WHERE id = ?'
517
+ ).get(id);
518
+
519
+ return row ? rowToOutboundMessage(row) : null;
520
+ }
521
+
522
+ /**
523
+ * Update outbox message status (for processing)
524
+ */
525
+ export function updateOutboxStatus(
526
+ id: string,
527
+ status: OutboxStatus,
528
+ options?: { error?: string; platformId?: string }
529
+ ): boolean {
530
+ const db = getDatabase();
531
+ const updates: string[] = ['status = ?'];
532
+ const params: (string | number | null)[] = [status];
533
+
534
+ if (status === 'sent') {
535
+ updates.push('sent_at = ?');
536
+ params.push(Date.now());
537
+ }
538
+ if (options?.error !== undefined) {
539
+ updates.push('error = ?');
540
+ params.push(options.error);
541
+ }
542
+ if (options?.platformId !== undefined) {
543
+ updates.push('platform_id = ?');
544
+ params.push(options.platformId);
545
+ }
546
+
547
+ params.push(id);
548
+
549
+ const result = db.run(
550
+ `UPDATE outbox SET ${updates.join(', ')} WHERE id = ?`,
551
+ params
552
+ );
553
+ return result.changes > 0;
554
+ }
555
+
556
+ /**
557
+ * Delete old sent messages (retention cleanup)
558
+ */
559
+ export function cleanupOutbox(daysOld: number): number {
560
+ if (daysOld <= 0) return 0;
561
+ const db = getDatabase();
562
+ const cutoff = Date.now() - (daysOld * 24 * 60 * 60 * 1000);
563
+ const result = db.run(
564
+ 'DELETE FROM outbox WHERE status = ? AND sent_at < ?',
565
+ ['sent', cutoff]
566
+ );
567
+ return result.changes;
568
+ }
569
+
570
+ // ============ WHATSAPP AUTH STATE EXPORT/IMPORT ============
571
+
572
+ export interface WhatsAppAuthExport {
573
+ profile: string;
574
+ creds: string | null; // JSON string
575
+ keys: { type: string; keyId: string; data: string }[];
576
+ }
577
+
578
+ /**
579
+ * Export WhatsApp auth state for teleport
580
+ */
581
+ export async function exportWhatsAppAuthState(profile: string): Promise<WhatsAppAuthExport | null> {
582
+ const db = getDatabase();
583
+
584
+ // Get credentials
585
+ const credsRow = db.query<{ data: string }, [string]>(
586
+ 'SELECT data FROM whatsapp_auth_creds WHERE profile = ?'
587
+ ).get(profile);
588
+
589
+ // Get all keys
590
+ const keysRows = db.query<{ type: string; key_id: string; data: string }, [string]>(
591
+ 'SELECT type, key_id, data FROM whatsapp_auth_keys WHERE profile = ?'
592
+ ).all(profile);
593
+
594
+ if (!credsRow && keysRows.length === 0) {
595
+ return null;
596
+ }
597
+
598
+ return {
599
+ profile,
600
+ creds: credsRow?.data || null,
601
+ keys: keysRows.map(row => ({
602
+ type: row.type,
603
+ keyId: row.key_id,
604
+ data: row.data,
605
+ })),
606
+ };
607
+ }
608
+
609
+ /**
610
+ * Import WhatsApp auth state from teleport
611
+ */
612
+ export async function importWhatsAppAuthState(authExport: WhatsAppAuthExport): Promise<void> {
613
+ const db = getDatabase();
614
+ const { profile, creds, keys } = authExport;
615
+
616
+ // Clear existing auth state for this profile
617
+ db.run('DELETE FROM whatsapp_auth_creds WHERE profile = ?', [profile]);
618
+ db.run('DELETE FROM whatsapp_auth_keys WHERE profile = ?', [profile]);
619
+
620
+ // Import credentials
621
+ if (creds) {
622
+ db.run(
623
+ 'INSERT INTO whatsapp_auth_creds (profile, data, updated_at) VALUES (?, ?, ?)',
624
+ [profile, creds, Date.now()]
625
+ );
626
+ }
627
+
628
+ // Import keys
629
+ for (const key of keys) {
630
+ db.run(
631
+ 'INSERT INTO whatsapp_auth_keys (profile, type, key_id, data) VALUES (?, ?, ?, ?)',
632
+ [profile, key.type, key.keyId, key.data]
633
+ );
634
+ }
635
+ }
636
+
637
+ export { DATABASE_FILE };