@conversionpros/aiva 1.0.1 → 2.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 (149) hide show
  1. package/bin/aiva.js +26 -14
  2. package/lib/bluebubbles.js +145 -0
  3. package/lib/config-gen.js +253 -0
  4. package/lib/constants.js +72 -0
  5. package/lib/launch-agent.js +112 -0
  6. package/lib/prerequisites.js +236 -0
  7. package/lib/process.js +59 -145
  8. package/lib/setup.js +224 -194
  9. package/lib/validate.js +194 -0
  10. package/package.json +7 -32
  11. package/auto-deploy.js +0 -190
  12. package/cli-sync.js +0 -126
  13. package/d2a-prompt-template.txt +0 -106
  14. package/diagnostics-api.js +0 -304
  15. package/docs/ara-dedup-fix-scope.md +0 -112
  16. package/docs/ara-fix-round2-scope.md +0 -61
  17. package/docs/ara-greeting-fix-scope.md +0 -70
  18. package/docs/calendar-date-fix-scope.md +0 -28
  19. package/docs/getting-started.md +0 -115
  20. package/docs/network-architecture-rollout-scope.md +0 -43
  21. package/docs/scope-google-oauth-integration.md +0 -351
  22. package/docs/settings-page-scope.md +0 -50
  23. package/docs/xai-imagine-scope.md +0 -116
  24. package/docs/xai-voice-integration-scope.md +0 -115
  25. package/docs/xai-voice-tools-scope.md +0 -165
  26. package/email-router.js +0 -512
  27. package/follow-up-handler.js +0 -606
  28. package/gateway-monitor.js +0 -158
  29. package/google-email.js +0 -379
  30. package/google-oauth.js +0 -310
  31. package/grok-imagine.js +0 -97
  32. package/health-reporter.js +0 -287
  33. package/invisible-prefix-base.txt +0 -206
  34. package/invisible-prefix-owner.txt +0 -26
  35. package/invisible-prefix-slim.txt +0 -10
  36. package/invisible-prefix.txt +0 -43
  37. package/knowledge-base.js +0 -472
  38. package/lib/cli.js +0 -19
  39. package/lib/server.js +0 -42
  40. package/meta-capi.js +0 -206
  41. package/meta-leads.js +0 -411
  42. package/notion-oauth.js +0 -323
  43. package/public/agent-config.html +0 -241
  44. package/public/aiva-avatar-anime.png +0 -0
  45. package/public/css/docs.css.bak +0 -688
  46. package/public/css/onboarding.css +0 -543
  47. package/public/diagrams/claude-subscription-pool.html +0 -329
  48. package/public/diagrams/claude-subscription-pool.png +0 -0
  49. package/public/docs-icon.png +0 -0
  50. package/public/escalation.html +0 -237
  51. package/public/group-config.html +0 -300
  52. package/public/icon-192.png +0 -0
  53. package/public/icon-512.png +0 -0
  54. package/public/icons/agents.svg +0 -1
  55. package/public/icons/attach.svg +0 -1
  56. package/public/icons/characters.svg +0 -1
  57. package/public/icons/chat.svg +0 -1
  58. package/public/icons/docs.svg +0 -1
  59. package/public/icons/heartbeat.svg +0 -1
  60. package/public/icons/messages.svg +0 -1
  61. package/public/icons/mic.svg +0 -1
  62. package/public/icons/notes.svg +0 -1
  63. package/public/icons/settings.svg +0 -1
  64. package/public/icons/tasks.svg +0 -1
  65. package/public/images/onboarding/p0-communication-layer.png +0 -0
  66. package/public/images/onboarding/p0-infinite-surface.png +0 -0
  67. package/public/images/onboarding/p0-learning-model.png +0 -0
  68. package/public/images/onboarding/p0-meet-aiva.png +0 -0
  69. package/public/images/onboarding/p4-contact-intelligence.png +0 -0
  70. package/public/images/onboarding/p4-context-compounds.png +0 -0
  71. package/public/images/onboarding/p4-message-router.png +0 -0
  72. package/public/images/onboarding/p4-per-contact-rules.png +0 -0
  73. package/public/images/onboarding/p4-send-messages.png +0 -0
  74. package/public/images/onboarding/p6-be-precise.png +0 -0
  75. package/public/images/onboarding/p6-review-escalations.png +0 -0
  76. package/public/images/onboarding/p6-voice-input.png +0 -0
  77. package/public/images/onboarding/p7-completion.png +0 -0
  78. package/public/index.html +0 -11594
  79. package/public/js/onboarding.js +0 -699
  80. package/public/manifest.json +0 -24
  81. package/public/messages-v2.html +0 -2824
  82. package/public/permission-approve.html.bak +0 -107
  83. package/public/permissions.html +0 -150
  84. package/public/styles/design-system.css +0 -68
  85. package/router-db.js +0 -604
  86. package/router-utils.js +0 -28
  87. package/router-v2/adapters/imessage.js +0 -191
  88. package/router-v2/adapters/quo.js +0 -82
  89. package/router-v2/adapters/whatsapp.js +0 -192
  90. package/router-v2/contact-manager.js +0 -234
  91. package/router-v2/conversation-engine.js +0 -498
  92. package/router-v2/data/knowledge-base.json +0 -176
  93. package/router-v2/data/router-v2.db +0 -0
  94. package/router-v2/data/router-v2.db-shm +0 -0
  95. package/router-v2/data/router-v2.db-wal +0 -0
  96. package/router-v2/data/router.db +0 -0
  97. package/router-v2/db.js +0 -457
  98. package/router-v2/escalation-bridge.js +0 -540
  99. package/router-v2/follow-up-engine.js +0 -347
  100. package/router-v2/index.js +0 -441
  101. package/router-v2/ingestion.js +0 -213
  102. package/router-v2/knowledge-base.js +0 -231
  103. package/router-v2/lead-qualifier.js +0 -152
  104. package/router-v2/learning-loop.js +0 -202
  105. package/router-v2/outbound-sender.js +0 -160
  106. package/router-v2/package.json +0 -13
  107. package/router-v2/permission-gate.js +0 -86
  108. package/router-v2/playbook.js +0 -177
  109. package/router-v2/prompts/base.js +0 -52
  110. package/router-v2/prompts/first-contact.js +0 -38
  111. package/router-v2/prompts/lead-qualification.js +0 -37
  112. package/router-v2/prompts/scheduling.js +0 -72
  113. package/router-v2/prompts/style-overrides.js +0 -22
  114. package/router-v2/scheduler.js +0 -301
  115. package/router-v2/scripts/migrate-v1-to-v2.js +0 -215
  116. package/router-v2/scripts/seed-faq.js +0 -67
  117. package/router-v2/seed-knowledge-base.js +0 -39
  118. package/router-v2/utils/ai.js +0 -129
  119. package/router-v2/utils/phone.js +0 -52
  120. package/router-v2/utils/response-validator.js +0 -98
  121. package/router-v2/utils/sanitize.js +0 -222
  122. package/router.js +0 -5005
  123. package/routes/google-calendar.js +0 -186
  124. package/scripts/deploy.sh +0 -62
  125. package/scripts/macos-calendar.sh +0 -232
  126. package/scripts/onboard-device.sh +0 -466
  127. package/server.js +0 -5131
  128. package/start.sh +0 -24
  129. package/templates/AGENTS.md +0 -548
  130. package/templates/IDENTITY.md +0 -15
  131. package/templates/docs-agents.html +0 -132
  132. package/templates/docs-app.html +0 -130
  133. package/templates/docs-home.html +0 -83
  134. package/templates/docs-imessage.html +0 -121
  135. package/templates/docs-tasks.html +0 -123
  136. package/templates/docs-tips.html +0 -175
  137. package/templates/getting-started.html +0 -809
  138. package/templates/invisible-prefix-base.txt +0 -171
  139. package/templates/invisible-prefix-owner.txt +0 -282
  140. package/templates/invisible-prefix.txt +0 -338
  141. package/templates/manifest.json +0 -61
  142. package/templates/memory-org/clients.md +0 -7
  143. package/templates/memory-org/credentials.md +0 -9
  144. package/templates/memory-org/devices.md +0 -7
  145. package/templates/updates.html +0 -464
  146. package/tts-proxy.js +0 -96
  147. package/voice-call-local.js +0 -731
  148. package/voice-call.js +0 -732
  149. package/wa-listener.js +0 -354
@@ -1,441 +0,0 @@
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 };
@@ -1,213 +0,0 @@
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
- };