@conversionpros/aiva 1.0.0

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 (152) hide show
  1. package/README.md +148 -0
  2. package/auto-deploy.js +190 -0
  3. package/bin/aiva.js +81 -0
  4. package/cli-sync.js +126 -0
  5. package/d2a-prompt-template.txt +106 -0
  6. package/diagnostics-api.js +304 -0
  7. package/docs/ara-dedup-fix-scope.md +112 -0
  8. package/docs/ara-fix-round2-scope.md +61 -0
  9. package/docs/ara-greeting-fix-scope.md +70 -0
  10. package/docs/calendar-date-fix-scope.md +28 -0
  11. package/docs/getting-started.md +115 -0
  12. package/docs/network-architecture-rollout-scope.md +43 -0
  13. package/docs/scope-google-oauth-integration.md +351 -0
  14. package/docs/settings-page-scope.md +50 -0
  15. package/docs/xai-imagine-scope.md +116 -0
  16. package/docs/xai-voice-integration-scope.md +115 -0
  17. package/docs/xai-voice-tools-scope.md +165 -0
  18. package/email-router.js +512 -0
  19. package/follow-up-handler.js +606 -0
  20. package/gateway-monitor.js +158 -0
  21. package/google-email.js +379 -0
  22. package/google-oauth.js +310 -0
  23. package/grok-imagine.js +97 -0
  24. package/health-reporter.js +287 -0
  25. package/invisible-prefix-base.txt +206 -0
  26. package/invisible-prefix-owner.txt +26 -0
  27. package/invisible-prefix-slim.txt +10 -0
  28. package/invisible-prefix.txt +43 -0
  29. package/knowledge-base.js +472 -0
  30. package/lib/cli.js +19 -0
  31. package/lib/config.js +124 -0
  32. package/lib/health.js +57 -0
  33. package/lib/process.js +207 -0
  34. package/lib/server.js +42 -0
  35. package/lib/setup.js +472 -0
  36. package/meta-capi.js +206 -0
  37. package/meta-leads.js +411 -0
  38. package/notion-oauth.js +323 -0
  39. package/package.json +61 -0
  40. package/public/agent-config.html +241 -0
  41. package/public/aiva-avatar-anime.png +0 -0
  42. package/public/css/docs.css.bak +688 -0
  43. package/public/css/onboarding.css +543 -0
  44. package/public/diagrams/claude-subscription-pool.html +329 -0
  45. package/public/diagrams/claude-subscription-pool.png +0 -0
  46. package/public/docs-icon.png +0 -0
  47. package/public/escalation.html +237 -0
  48. package/public/group-config.html +300 -0
  49. package/public/icon-192.png +0 -0
  50. package/public/icon-512.png +0 -0
  51. package/public/icons/agents.svg +1 -0
  52. package/public/icons/attach.svg +1 -0
  53. package/public/icons/characters.svg +1 -0
  54. package/public/icons/chat.svg +1 -0
  55. package/public/icons/docs.svg +1 -0
  56. package/public/icons/heartbeat.svg +1 -0
  57. package/public/icons/messages.svg +1 -0
  58. package/public/icons/mic.svg +1 -0
  59. package/public/icons/notes.svg +1 -0
  60. package/public/icons/settings.svg +1 -0
  61. package/public/icons/tasks.svg +1 -0
  62. package/public/images/onboarding/p0-communication-layer.png +0 -0
  63. package/public/images/onboarding/p0-infinite-surface.png +0 -0
  64. package/public/images/onboarding/p0-learning-model.png +0 -0
  65. package/public/images/onboarding/p0-meet-aiva.png +0 -0
  66. package/public/images/onboarding/p4-contact-intelligence.png +0 -0
  67. package/public/images/onboarding/p4-context-compounds.png +0 -0
  68. package/public/images/onboarding/p4-message-router.png +0 -0
  69. package/public/images/onboarding/p4-per-contact-rules.png +0 -0
  70. package/public/images/onboarding/p4-send-messages.png +0 -0
  71. package/public/images/onboarding/p6-be-precise.png +0 -0
  72. package/public/images/onboarding/p6-review-escalations.png +0 -0
  73. package/public/images/onboarding/p6-voice-input.png +0 -0
  74. package/public/images/onboarding/p7-completion.png +0 -0
  75. package/public/index.html +11594 -0
  76. package/public/js/onboarding.js +699 -0
  77. package/public/manifest.json +24 -0
  78. package/public/messages-v2.html +2824 -0
  79. package/public/permission-approve.html.bak +107 -0
  80. package/public/permissions.html +150 -0
  81. package/public/styles/design-system.css +68 -0
  82. package/router-db.js +604 -0
  83. package/router-utils.js +28 -0
  84. package/router-v2/adapters/imessage.js +191 -0
  85. package/router-v2/adapters/quo.js +82 -0
  86. package/router-v2/adapters/whatsapp.js +192 -0
  87. package/router-v2/contact-manager.js +234 -0
  88. package/router-v2/conversation-engine.js +498 -0
  89. package/router-v2/data/knowledge-base.json +176 -0
  90. package/router-v2/data/router-v2.db +0 -0
  91. package/router-v2/data/router-v2.db-shm +0 -0
  92. package/router-v2/data/router-v2.db-wal +0 -0
  93. package/router-v2/data/router.db +0 -0
  94. package/router-v2/db.js +457 -0
  95. package/router-v2/escalation-bridge.js +540 -0
  96. package/router-v2/follow-up-engine.js +347 -0
  97. package/router-v2/index.js +441 -0
  98. package/router-v2/ingestion.js +213 -0
  99. package/router-v2/knowledge-base.js +231 -0
  100. package/router-v2/lead-qualifier.js +152 -0
  101. package/router-v2/learning-loop.js +202 -0
  102. package/router-v2/outbound-sender.js +160 -0
  103. package/router-v2/package.json +13 -0
  104. package/router-v2/permission-gate.js +86 -0
  105. package/router-v2/playbook.js +177 -0
  106. package/router-v2/prompts/base.js +52 -0
  107. package/router-v2/prompts/first-contact.js +38 -0
  108. package/router-v2/prompts/lead-qualification.js +37 -0
  109. package/router-v2/prompts/scheduling.js +72 -0
  110. package/router-v2/prompts/style-overrides.js +22 -0
  111. package/router-v2/scheduler.js +301 -0
  112. package/router-v2/scripts/migrate-v1-to-v2.js +215 -0
  113. package/router-v2/scripts/seed-faq.js +67 -0
  114. package/router-v2/seed-knowledge-base.js +39 -0
  115. package/router-v2/utils/ai.js +129 -0
  116. package/router-v2/utils/phone.js +52 -0
  117. package/router-v2/utils/response-validator.js +98 -0
  118. package/router-v2/utils/sanitize.js +222 -0
  119. package/router.js +5005 -0
  120. package/routes/google-calendar.js +186 -0
  121. package/scripts/deploy.sh +62 -0
  122. package/scripts/macos-calendar.sh +232 -0
  123. package/scripts/onboard-device.sh +466 -0
  124. package/server.js +5131 -0
  125. package/start.sh +24 -0
  126. package/templates/AGENTS.md +548 -0
  127. package/templates/IDENTITY.md +15 -0
  128. package/templates/docs-agents.html +132 -0
  129. package/templates/docs-app.html +130 -0
  130. package/templates/docs-home.html +83 -0
  131. package/templates/docs-imessage.html +121 -0
  132. package/templates/docs-tasks.html +123 -0
  133. package/templates/docs-tips.html +175 -0
  134. package/templates/getting-started.html +809 -0
  135. package/templates/invisible-prefix-base.txt +171 -0
  136. package/templates/invisible-prefix-owner.txt +282 -0
  137. package/templates/invisible-prefix.txt +338 -0
  138. package/templates/manifest.json +61 -0
  139. package/templates/memory-org/clients.md +7 -0
  140. package/templates/memory-org/credentials.md +9 -0
  141. package/templates/memory-org/devices.md +7 -0
  142. package/templates/updates.html +464 -0
  143. package/templates/workspace/AGENTS.md.tmpl +161 -0
  144. package/templates/workspace/HEARTBEAT.md.tmpl +17 -0
  145. package/templates/workspace/IDENTITY.md.tmpl +15 -0
  146. package/templates/workspace/MEMORY.md.tmpl +16 -0
  147. package/templates/workspace/SOUL.md.tmpl +51 -0
  148. package/templates/workspace/USER.md.tmpl +25 -0
  149. package/tts-proxy.js +96 -0
  150. package/voice-call-local.js +731 -0
  151. package/voice-call.js +732 -0
  152. package/wa-listener.js +354 -0
@@ -0,0 +1,441 @@
1
+ // ── Router v2 Entry Point - Express Routes + Initialization ──
2
+ 'use strict';
3
+
4
+ const { initDatabase, getSetting, setSetting, getStmts } = require('./db');
5
+ const { ingestMessage, startBBPolling, stopBBPolling } = require('./ingestion');
6
+ const { processMessage } = require('./conversation-engine');
7
+ const { handleReply, processTimeouts } = require('./escalation-bridge');
8
+ const { processFollowUps } = require('./follow-up-engine');
9
+ const { listContacts, getContact, updateContact, deleteContact, setScopes, getOrCreateContact } = require('./contact-manager');
10
+ const { listFaq, createFaq, updateFaq, deleteFaq, search: searchKB } = require('./knowledge-base');
11
+ const { listSections, createSection, updateSection, deleteSection } = require('./playbook');
12
+ const { listPreferences, deletePreference, getMetrics, decayConfidence } = require('./learning-loop');
13
+ const { normalizePhone } = require('./utils/phone');
14
+
15
+ let initialized = false;
16
+ let timeoutTimer = null;
17
+ let followUpTimer = null;
18
+ let decayTimer = null;
19
+
20
+ function log(msg, data) {
21
+ const ts = new Date().toISOString();
22
+ if (data) console.log(`[${ts}] [ROUTER-V2] ${msg}`, JSON.stringify(data));
23
+ else console.log(`[${ts}] [ROUTER-V2] ${msg}`);
24
+ }
25
+
26
+ /**
27
+ * Initialize the router v2. Call once at startup.
28
+ * @returns {Object} { db, stmts }
29
+ */
30
+ function initialize() {
31
+ if (initialized) return;
32
+
33
+ const result = initDatabase();
34
+ log('Database initialized');
35
+
36
+ // Start BB polling for Brandon's manual messages
37
+ startBBPolling(30000);
38
+
39
+ // Start escalation timeout checker (every 60s)
40
+ timeoutTimer = setInterval(() => {
41
+ processTimeouts().catch(err => log('Timeout check error', { error: err.message }));
42
+ }, 60000);
43
+
44
+ // Start follow-up engine (every 30 min)
45
+ followUpTimer = setInterval(() => {
46
+ processFollowUps().catch(err => log('Follow-up error', { error: err.message }));
47
+ }, 30 * 60000);
48
+
49
+ // Confidence decay (daily)
50
+ decayTimer = setInterval(() => {
51
+ decayConfidence();
52
+ }, 24 * 3600000);
53
+
54
+ initialized = true;
55
+ log('Router v2 initialized');
56
+ return result;
57
+ }
58
+
59
+ /**
60
+ * Shutdown cleanup.
61
+ */
62
+ function shutdown() {
63
+ stopBBPolling();
64
+ if (timeoutTimer) clearInterval(timeoutTimer);
65
+ if (followUpTimer) clearInterval(followUpTimer);
66
+ if (decayTimer) clearInterval(decayTimer);
67
+ log('Router v2 shut down');
68
+ }
69
+
70
+ /**
71
+ * Register Express routes on an existing app.
72
+ * @param {import('express').Application} app - Express app
73
+ */
74
+ function registerRoutes(app) {
75
+ // ── Webhook endpoints (inbound messages) ──
76
+
77
+ // iMessage / BlueBubbles webhook
78
+ app.post('/webhook/imessage', (req, res) => {
79
+ try {
80
+ ingestMessage(req.body, 'imessage', (message) => {
81
+ processMessage(message).catch(err => log('Process error', { phone: message.phone, error: err.message }));
82
+ });
83
+ res.json({ ok: true });
84
+ } catch (err) {
85
+ log('iMessage webhook error', { error: err.message });
86
+ res.status(500).json({ error: err.message });
87
+ }
88
+ });
89
+
90
+ // WhatsApp webhook
91
+ app.post('/webhook/whatsapp', (req, res) => {
92
+ try {
93
+ ingestMessage(req.body, 'whatsapp', (message) => {
94
+ processMessage(message).catch(err => log('Process error', { phone: message.phone, error: err.message }));
95
+ });
96
+ res.json({ ok: true });
97
+ } catch (err) {
98
+ log('WhatsApp webhook error', { error: err.message });
99
+ res.status(500).json({ error: err.message });
100
+ }
101
+ });
102
+
103
+ // Quo / OpenPhone webhook
104
+ app.post('/webhook/quo', (req, res) => {
105
+ try {
106
+ ingestMessage(req.body, 'quo', (message) => {
107
+ processMessage(message).catch(err => log('Process error', { phone: message.phone, error: err.message }));
108
+ });
109
+ res.json({ ok: true });
110
+ } catch (err) {
111
+ log('Quo webhook error', { error: err.message });
112
+ res.status(500).json({ error: err.message });
113
+ }
114
+ });
115
+
116
+ // ── Escalation reply endpoint ──
117
+ app.post('/api/router/escalation-reply', async (req, res) => {
118
+ try {
119
+ const result = await handleReply(req.body);
120
+ res.json(result);
121
+ } catch (err) {
122
+ log('Escalation reply error', { error: err.message });
123
+ res.status(500).json({ error: err.message });
124
+ }
125
+ });
126
+
127
+ // ── Escalation action handler (from interactive UI) ──
128
+ app.post('/api/router/escalation-action', async (req, res) => {
129
+ try {
130
+ const { escalationId, actionIndex, actionType, scopes, phone } = req.body;
131
+ log('Escalation action received', { escalationId, actionType, scopes, phone });
132
+
133
+ // Grant scopes if action requires it
134
+ if (actionType === 'approve_and_grant' && scopes && scopes.length > 0 && phone) {
135
+ const scopeMap = {};
136
+ scopes.forEach(s => { scopeMap[s] = true; });
137
+ setScopes(normalizePhone(phone), scopeMap);
138
+ log('Scopes granted via escalation action', { phone, scopes });
139
+ }
140
+
141
+ // Handle the reply based on action type
142
+ if (actionType === 'approve_and_grant' || actionType === 'approve_one_time') {
143
+ // Reply affirmatively to the contact
144
+ const result = await handleReply({ escalationId, response: 'Yes, approve and confirm their request' });
145
+ res.json({ success: true, action: actionType, scopesGranted: scopes || [], reply: result });
146
+ } else if (actionType === 'decline') {
147
+ const result = await handleReply({ escalationId, response: 'Politely decline their request' });
148
+ res.json({ success: true, action: actionType, reply: result });
149
+ } else if (actionType === 'ignore' || actionType === 'handle_personally') {
150
+ // No reply to contact
151
+ res.json({ success: true, action: actionType, noReply: true });
152
+ } else {
153
+ res.json({ success: true, action: 'unknown', note: 'No automated action taken' });
154
+ }
155
+ } catch (err) {
156
+ log('Escalation action error', { error: err.message });
157
+ res.status(500).json({ error: err.message });
158
+ }
159
+ });
160
+
161
+ // ── Contact management API ──
162
+
163
+ app.get('/api/v2/contacts', (req, res) => {
164
+ try {
165
+ const filter = {};
166
+ if (req.query.category) filter.category = req.query.category;
167
+ if (req.query.search) filter.search = req.query.search;
168
+ res.json(listContacts(filter));
169
+ } catch (err) {
170
+ res.status(500).json({ error: err.message });
171
+ }
172
+ });
173
+
174
+ app.get('/api/v2/contacts/:phone', (req, res) => {
175
+ try {
176
+ const contact = getContact(normalizePhone(req.params.phone));
177
+ if (!contact) return res.status(404).json({ error: 'Contact not found' });
178
+ res.json(contact);
179
+ } catch (err) {
180
+ res.status(500).json({ error: err.message });
181
+ }
182
+ });
183
+
184
+ app.put('/api/v2/contacts/:phone', (req, res) => {
185
+ try {
186
+ const phone = normalizePhone(req.params.phone);
187
+ // Ensure contact exists
188
+ getOrCreateContact(phone, req.body);
189
+ const updated = updateContact(phone, req.body);
190
+ if (!updated) return res.status(404).json({ error: 'Contact not found' });
191
+ res.json(updated);
192
+ } catch (err) {
193
+ res.status(500).json({ error: err.message });
194
+ }
195
+ });
196
+
197
+ app.delete('/api/v2/contacts/:phone', (req, res) => {
198
+ try {
199
+ deleteContact(normalizePhone(req.params.phone));
200
+ res.json({ ok: true });
201
+ } catch (err) {
202
+ res.status(500).json({ error: err.message });
203
+ }
204
+ });
205
+
206
+ // Scopes management
207
+ app.put('/api/v2/contacts/:phone/scopes', (req, res) => {
208
+ try {
209
+ setScopes(normalizePhone(req.params.phone), req.body.scopes || {});
210
+ const contact = getContact(normalizePhone(req.params.phone));
211
+ res.json({ scopes: contact?.scopes || [] });
212
+ } catch (err) {
213
+ res.status(500).json({ error: err.message });
214
+ }
215
+ });
216
+
217
+ // ── FAQ management API ──
218
+
219
+ app.get('/api/v2/faq', (req, res) => {
220
+ try {
221
+ res.json(listFaq(req.query.deviceId || null));
222
+ } catch (err) {
223
+ res.status(500).json({ error: err.message });
224
+ }
225
+ });
226
+
227
+ app.post('/api/v2/faq', (req, res) => {
228
+ try {
229
+ const entry = createFaq(req.body);
230
+ res.json(entry);
231
+ } catch (err) {
232
+ res.status(500).json({ error: err.message });
233
+ }
234
+ });
235
+
236
+ app.put('/api/v2/faq/:id', (req, res) => {
237
+ try {
238
+ const entry = updateFaq(parseInt(req.params.id), req.body);
239
+ if (!entry) return res.status(404).json({ error: 'FAQ entry not found' });
240
+ res.json(entry);
241
+ } catch (err) {
242
+ res.status(500).json({ error: err.message });
243
+ }
244
+ });
245
+
246
+ app.delete('/api/v2/faq/:id', (req, res) => {
247
+ try {
248
+ deleteFaq(parseInt(req.params.id));
249
+ res.json({ ok: true });
250
+ } catch (err) {
251
+ res.status(500).json({ error: err.message });
252
+ }
253
+ });
254
+
255
+ app.get('/api/v2/faq/search', (req, res) => {
256
+ try {
257
+ const results = searchKB(req.query.q || '', req.query.deviceId || null);
258
+ res.json(results);
259
+ } catch (err) {
260
+ res.status(500).json({ error: err.message });
261
+ }
262
+ });
263
+
264
+ // ── Playbook management API ──
265
+
266
+ app.get('/api/v2/playbook', (req, res) => {
267
+ try {
268
+ res.json(listSections(req.query.deviceId || null));
269
+ } catch (err) {
270
+ res.status(500).json({ error: err.message });
271
+ }
272
+ });
273
+
274
+ app.post('/api/v2/playbook', (req, res) => {
275
+ try {
276
+ const section = createSection(req.body);
277
+ res.json(section);
278
+ } catch (err) {
279
+ res.status(500).json({ error: err.message });
280
+ }
281
+ });
282
+
283
+ app.put('/api/v2/playbook/:id', (req, res) => {
284
+ try {
285
+ const section = updateSection(parseInt(req.params.id), req.body);
286
+ if (!section) return res.status(404).json({ error: 'Section not found' });
287
+ res.json(section);
288
+ } catch (err) {
289
+ res.status(500).json({ error: err.message });
290
+ }
291
+ });
292
+
293
+ app.delete('/api/v2/playbook/:id', (req, res) => {
294
+ try {
295
+ deleteSection(parseInt(req.params.id));
296
+ res.json({ ok: true });
297
+ } catch (err) {
298
+ res.status(500).json({ error: err.message });
299
+ }
300
+ });
301
+
302
+ // ── Preferences / Learning API ──
303
+
304
+ app.get('/api/v2/preferences', (req, res) => {
305
+ try {
306
+ res.json(listPreferences());
307
+ } catch (err) {
308
+ res.status(500).json({ error: err.message });
309
+ }
310
+ });
311
+
312
+ app.delete('/api/v2/preferences/:id', (req, res) => {
313
+ try {
314
+ deletePreference(parseInt(req.params.id));
315
+ res.json({ ok: true });
316
+ } catch (err) {
317
+ res.status(500).json({ error: err.message });
318
+ }
319
+ });
320
+
321
+ app.get('/api/v2/learning/metrics', (req, res) => {
322
+ try {
323
+ res.json(getMetrics());
324
+ } catch (err) {
325
+ res.status(500).json({ error: err.message });
326
+ }
327
+ });
328
+
329
+ // ── Settings API ──
330
+
331
+ app.get('/api/v2/settings', (req, res) => {
332
+ try {
333
+ const stmts = getStmts();
334
+ const all = stmts.getAllSettings.all();
335
+ const settings = Object.fromEntries(all.map(s => [s.key, s.value]));
336
+ res.json(settings);
337
+ } catch (err) {
338
+ res.status(500).json({ error: err.message });
339
+ }
340
+ });
341
+
342
+ app.put('/api/v2/settings', (req, res) => {
343
+ try {
344
+ for (const [key, value] of Object.entries(req.body)) {
345
+ setSetting(key, value);
346
+ }
347
+ res.json({ ok: true });
348
+ } catch (err) {
349
+ res.status(500).json({ error: err.message });
350
+ }
351
+ });
352
+
353
+ // ── Follow-up management ──
354
+
355
+ app.get('/api/v2/follow-ups', (req, res) => {
356
+ try {
357
+ res.json(getStmts().getAllFollowUps.all());
358
+ } catch (err) {
359
+ res.status(500).json({ error: err.message });
360
+ }
361
+ });
362
+
363
+ app.post('/api/v2/follow-ups/:phone/send', async (req, res) => {
364
+ try {
365
+ const { sendFollowUpNow } = require('./follow-up-engine');
366
+ const result = await sendFollowUpNow(normalizePhone(req.params.phone), req.body.message);
367
+ res.json(result);
368
+ } catch (err) {
369
+ res.status(500).json({ error: err.message });
370
+ }
371
+ });
372
+
373
+ app.delete('/api/v2/follow-ups/:phone', (req, res) => {
374
+ try {
375
+ getStmts().deleteFollowUp.run(normalizePhone(req.params.phone));
376
+ res.json({ ok: true });
377
+ } catch (err) {
378
+ res.status(500).json({ error: err.message });
379
+ }
380
+ });
381
+
382
+ // ── Message log ──
383
+
384
+ app.get('/api/v2/messages', (req, res) => {
385
+ try {
386
+ const phone = req.query.phone ? normalizePhone(req.query.phone) : null;
387
+ const limit = parseInt(req.query.limit) || 50;
388
+ const stmts = getStmts();
389
+ const messages = phone
390
+ ? stmts.getRecentMessages.all(phone, limit)
391
+ : stmts.getRecentMessagesAll.all(limit);
392
+ res.json(messages);
393
+ } catch (err) {
394
+ res.status(500).json({ error: err.message });
395
+ }
396
+ });
397
+
398
+ // ── Send message (manual / API) ──
399
+
400
+ app.post('/api/v2/send', async (req, res) => {
401
+ try {
402
+ const { sendMessage } = require('./outbound-sender');
403
+ const result = await sendMessage({
404
+ phone: normalizePhone(req.body.phone),
405
+ text: req.body.message || req.body.text,
406
+ channel: req.body.channel || 'imessage',
407
+ sentBy: req.body.sentBy || 'aiva',
408
+ stateAtTime: req.body.stateAtTime || 'manual',
409
+ });
410
+ res.json(result);
411
+ } catch (err) {
412
+ res.status(500).json({ error: err.message });
413
+ }
414
+ });
415
+
416
+ // ── Escalation management ──
417
+
418
+ app.get('/api/v2/escalations', (req, res) => {
419
+ try {
420
+ const db = require('./db').getDb();
421
+ const escalations = db.prepare("SELECT * FROM escalations ORDER BY created_at DESC LIMIT ?").all(parseInt(req.query.limit) || 50);
422
+ res.json(escalations);
423
+ } catch (err) {
424
+ res.status(500).json({ error: err.message });
425
+ }
426
+ });
427
+
428
+ // ── Health check ──
429
+
430
+ app.get('/api/v2/health', (req, res) => {
431
+ res.json({
432
+ version: 'v2',
433
+ initialized,
434
+ timestamp: new Date().toISOString(),
435
+ });
436
+ });
437
+
438
+ log('Routes registered');
439
+ }
440
+
441
+ module.exports = { initialize, shutdown, registerRoutes };
@@ -0,0 +1,213 @@
1
+ // ── Message Ingestion - Normalize + Debounce + BB Polling ──
2
+ 'use strict';
3
+
4
+ const { getStmts, getSetting } = require('./db');
5
+ const { normalizePhone } = require('./utils/phone');
6
+ const imessageAdapter = require('./adapters/imessage');
7
+
8
+ // Debounce state: phone -> { timer, messages: [] }
9
+ const debounceMap = new Map();
10
+ // Per-phone processing lock to prevent concurrent AI calls
11
+ const processingLocks = new Set();
12
+ // Processed message IDs for dedup
13
+ const processedIds = new Set();
14
+ const MAX_PROCESSED = 5000;
15
+ // Last BB poll timestamp (epoch ms)
16
+ let lastBBPollMs = Date.now() - 60000;
17
+ let bbPollTimer = null;
18
+
19
+ function log(msg, data) {
20
+ const ts = new Date().toISOString();
21
+ if (data) console.log(`[${ts}] [INGESTION] ${msg}`, JSON.stringify(data));
22
+ else console.log(`[${ts}] [INGESTION] ${msg}`);
23
+ }
24
+
25
+ /**
26
+ * Normalize a raw webhook payload from any channel into the unified InboundMessage format.
27
+ * @param {Object} rawPayload - Raw webhook data
28
+ * @param {string} channel - 'imessage' | 'whatsapp' | 'quo'
29
+ * @returns {Object|null} Normalized message or null if irrelevant
30
+ */
31
+ function normalizeMessage(rawPayload, channel) {
32
+ let adapter;
33
+ switch (channel) {
34
+ case 'imessage':
35
+ adapter = require('./adapters/imessage');
36
+ break;
37
+ case 'whatsapp':
38
+ adapter = require('./adapters/whatsapp');
39
+ break;
40
+ case 'quo':
41
+ adapter = require('./adapters/quo');
42
+ break;
43
+ default:
44
+ log('Unknown channel', { channel });
45
+ return null;
46
+ }
47
+
48
+ const parsed = adapter.parseWebhook(rawPayload);
49
+ if (!parsed) return null;
50
+
51
+ // Normalize phone
52
+ parsed.phone = normalizePhone(parsed.phone);
53
+ if (!parsed.phone) return null;
54
+
55
+ return parsed;
56
+ }
57
+
58
+ /**
59
+ * Check if a message is a duplicate (already processed).
60
+ * @param {string} messageId
61
+ * @returns {boolean}
62
+ */
63
+ function isDuplicate(messageId) {
64
+ if (!messageId) return false;
65
+ if (processedIds.has(messageId)) return true;
66
+
67
+ processedIds.add(messageId);
68
+ // Prune old IDs
69
+ if (processedIds.size > MAX_PROCESSED) {
70
+ const idsArray = Array.from(processedIds);
71
+ for (let i = 0; i < idsArray.length - MAX_PROCESSED / 2; i++) {
72
+ processedIds.delete(idsArray[i]);
73
+ }
74
+ }
75
+
76
+ return false;
77
+ }
78
+
79
+ /**
80
+ * Debounce rapid-fire messages from the same contact.
81
+ * Calls the callback with all accumulated messages after the debounce window.
82
+ * @param {Object} message - Normalized inbound message
83
+ * @param {Function} onReady - Callback(messages[]) when debounce window closes
84
+ */
85
+ function debounceMessage(message, onReady) {
86
+ const debounceMs = parseInt(getSetting('debounceMs')) || 3000;
87
+ const phone = message.phone;
88
+
89
+ if (debounceMap.has(phone)) {
90
+ const entry = debounceMap.get(phone);
91
+ clearTimeout(entry.timer);
92
+ entry.messages.push(message);
93
+ entry.timer = setTimeout(() => {
94
+ debounceMap.delete(phone);
95
+ onReady(entry.messages);
96
+ }, debounceMs);
97
+ } else {
98
+ const entry = {
99
+ messages: [message],
100
+ timer: setTimeout(() => {
101
+ debounceMap.delete(phone);
102
+ onReady(entry.messages);
103
+ }, debounceMs),
104
+ };
105
+ debounceMap.set(phone, entry);
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Process a raw inbound webhook: normalize, dedup, debounce, then invoke callback.
111
+ * @param {Object} rawPayload - Raw webhook data
112
+ * @param {string} channel - Channel identifier
113
+ * @param {Function} onMessage - Callback(normalizedMessage) for processing
114
+ */
115
+ function ingestMessage(rawPayload, channel, onMessage) {
116
+ const message = normalizeMessage(rawPayload, channel);
117
+ if (!message) return;
118
+
119
+ // TEMPORARILY DISABLED for testing (2026-02-27)
120
+ // Skip master phone messages (Brandon's own messages come through polling)
121
+ // const masterPhone = getSetting('masterPhone');
122
+ // if (message.phone === masterPhone) return;
123
+
124
+ // Dedup
125
+ if (isDuplicate(message.id)) {
126
+ log('Duplicate skipped', { id: message.id, phone: message.phone });
127
+ return;
128
+ }
129
+
130
+ // Log inbound message
131
+ const stmts = getStmts();
132
+ stmts.insertMessage.run(
133
+ message.phone, message.channel, 'inbound', message.text,
134
+ JSON.stringify(message.attachments || []), 'contact', '',
135
+ );
136
+
137
+ // Debounce then process (with per-phone lock to prevent concurrent AI calls)
138
+ debounceMessage(message, async (messages) => {
139
+ const phone = messages[0].phone;
140
+ const msg = messages.length === 1
141
+ ? messages[0]
142
+ : { ...messages[messages.length - 1], text: messages.map(m => m.text).filter(Boolean).join('\n') };
143
+
144
+ // Wait for any in-flight processing for this phone to finish
145
+ while (processingLocks.has(phone)) {
146
+ await new Promise(r => setTimeout(r, 200));
147
+ }
148
+
149
+ processingLocks.add(phone);
150
+ try {
151
+ await onMessage(msg);
152
+ } catch (err) {
153
+ log('Processing error', { phone, error: err.message });
154
+ } finally {
155
+ processingLocks.delete(phone);
156
+ }
157
+ });
158
+ }
159
+
160
+ /**
161
+ * Start polling BlueBubbles for Brandon's manual messages.
162
+ * These are messages Brandon sends directly via iMessage (not through AIVA).
163
+ * @param {number} [intervalMs=30000] - Poll interval
164
+ */
165
+ function startBBPolling(intervalMs = 30000) {
166
+ if (bbPollTimer) return;
167
+
168
+ async function poll() {
169
+ try {
170
+ const messages = await imessageAdapter.pollBrandonMessages(lastBBPollMs);
171
+ if (messages.length > 0) {
172
+ const stmts = getStmts();
173
+ for (const msg of messages) {
174
+ if (!msg.phone || isDuplicate(msg.guid)) continue;
175
+
176
+ const phone = normalizePhone(msg.phone);
177
+ // Log as outbound with sent_by: 'brandon'
178
+ stmts.insertMessage.run(
179
+ phone, 'imessage', 'outbound', msg.text,
180
+ '[]', 'brandon', '',
181
+ );
182
+ log('Tracked Brandon message', { phone, len: msg.text.length });
183
+ }
184
+ lastBBPollMs = Math.max(...messages.map(m => m.timestamp), lastBBPollMs);
185
+ }
186
+ } catch (err) {
187
+ log('BB poll error', { error: err.message });
188
+ }
189
+ }
190
+
191
+ bbPollTimer = setInterval(poll, intervalMs);
192
+ poll(); // Initial poll
193
+ log('Started BB polling', { intervalMs });
194
+ }
195
+
196
+ /**
197
+ * Stop BB polling.
198
+ */
199
+ function stopBBPolling() {
200
+ if (bbPollTimer) {
201
+ clearInterval(bbPollTimer);
202
+ bbPollTimer = null;
203
+ }
204
+ }
205
+
206
+ module.exports = {
207
+ normalizeMessage,
208
+ isDuplicate,
209
+ debounceMessage,
210
+ ingestMessage,
211
+ startBBPolling,
212
+ stopBBPolling,
213
+ };