@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.
- package/README.md +148 -0
- package/auto-deploy.js +190 -0
- package/bin/aiva.js +81 -0
- package/cli-sync.js +126 -0
- package/d2a-prompt-template.txt +106 -0
- package/diagnostics-api.js +304 -0
- package/docs/ara-dedup-fix-scope.md +112 -0
- package/docs/ara-fix-round2-scope.md +61 -0
- package/docs/ara-greeting-fix-scope.md +70 -0
- package/docs/calendar-date-fix-scope.md +28 -0
- package/docs/getting-started.md +115 -0
- package/docs/network-architecture-rollout-scope.md +43 -0
- package/docs/scope-google-oauth-integration.md +351 -0
- package/docs/settings-page-scope.md +50 -0
- package/docs/xai-imagine-scope.md +116 -0
- package/docs/xai-voice-integration-scope.md +115 -0
- package/docs/xai-voice-tools-scope.md +165 -0
- package/email-router.js +512 -0
- package/follow-up-handler.js +606 -0
- package/gateway-monitor.js +158 -0
- package/google-email.js +379 -0
- package/google-oauth.js +310 -0
- package/grok-imagine.js +97 -0
- package/health-reporter.js +287 -0
- package/invisible-prefix-base.txt +206 -0
- package/invisible-prefix-owner.txt +26 -0
- package/invisible-prefix-slim.txt +10 -0
- package/invisible-prefix.txt +43 -0
- package/knowledge-base.js +472 -0
- package/lib/cli.js +19 -0
- package/lib/config.js +124 -0
- package/lib/health.js +57 -0
- package/lib/process.js +207 -0
- package/lib/server.js +42 -0
- package/lib/setup.js +472 -0
- package/meta-capi.js +206 -0
- package/meta-leads.js +411 -0
- package/notion-oauth.js +323 -0
- package/package.json +61 -0
- package/public/agent-config.html +241 -0
- package/public/aiva-avatar-anime.png +0 -0
- package/public/css/docs.css.bak +688 -0
- package/public/css/onboarding.css +543 -0
- package/public/diagrams/claude-subscription-pool.html +329 -0
- package/public/diagrams/claude-subscription-pool.png +0 -0
- package/public/docs-icon.png +0 -0
- package/public/escalation.html +237 -0
- package/public/group-config.html +300 -0
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/icons/agents.svg +1 -0
- package/public/icons/attach.svg +1 -0
- package/public/icons/characters.svg +1 -0
- package/public/icons/chat.svg +1 -0
- package/public/icons/docs.svg +1 -0
- package/public/icons/heartbeat.svg +1 -0
- package/public/icons/messages.svg +1 -0
- package/public/icons/mic.svg +1 -0
- package/public/icons/notes.svg +1 -0
- package/public/icons/settings.svg +1 -0
- package/public/icons/tasks.svg +1 -0
- package/public/images/onboarding/p0-communication-layer.png +0 -0
- package/public/images/onboarding/p0-infinite-surface.png +0 -0
- package/public/images/onboarding/p0-learning-model.png +0 -0
- package/public/images/onboarding/p0-meet-aiva.png +0 -0
- package/public/images/onboarding/p4-contact-intelligence.png +0 -0
- package/public/images/onboarding/p4-context-compounds.png +0 -0
- package/public/images/onboarding/p4-message-router.png +0 -0
- package/public/images/onboarding/p4-per-contact-rules.png +0 -0
- package/public/images/onboarding/p4-send-messages.png +0 -0
- package/public/images/onboarding/p6-be-precise.png +0 -0
- package/public/images/onboarding/p6-review-escalations.png +0 -0
- package/public/images/onboarding/p6-voice-input.png +0 -0
- package/public/images/onboarding/p7-completion.png +0 -0
- package/public/index.html +11594 -0
- package/public/js/onboarding.js +699 -0
- package/public/manifest.json +24 -0
- package/public/messages-v2.html +2824 -0
- package/public/permission-approve.html.bak +107 -0
- package/public/permissions.html +150 -0
- package/public/styles/design-system.css +68 -0
- package/router-db.js +604 -0
- package/router-utils.js +28 -0
- package/router-v2/adapters/imessage.js +191 -0
- package/router-v2/adapters/quo.js +82 -0
- package/router-v2/adapters/whatsapp.js +192 -0
- package/router-v2/contact-manager.js +234 -0
- package/router-v2/conversation-engine.js +498 -0
- package/router-v2/data/knowledge-base.json +176 -0
- package/router-v2/data/router-v2.db +0 -0
- package/router-v2/data/router-v2.db-shm +0 -0
- package/router-v2/data/router-v2.db-wal +0 -0
- package/router-v2/data/router.db +0 -0
- package/router-v2/db.js +457 -0
- package/router-v2/escalation-bridge.js +540 -0
- package/router-v2/follow-up-engine.js +347 -0
- package/router-v2/index.js +441 -0
- package/router-v2/ingestion.js +213 -0
- package/router-v2/knowledge-base.js +231 -0
- package/router-v2/lead-qualifier.js +152 -0
- package/router-v2/learning-loop.js +202 -0
- package/router-v2/outbound-sender.js +160 -0
- package/router-v2/package.json +13 -0
- package/router-v2/permission-gate.js +86 -0
- package/router-v2/playbook.js +177 -0
- package/router-v2/prompts/base.js +52 -0
- package/router-v2/prompts/first-contact.js +38 -0
- package/router-v2/prompts/lead-qualification.js +37 -0
- package/router-v2/prompts/scheduling.js +72 -0
- package/router-v2/prompts/style-overrides.js +22 -0
- package/router-v2/scheduler.js +301 -0
- package/router-v2/scripts/migrate-v1-to-v2.js +215 -0
- package/router-v2/scripts/seed-faq.js +67 -0
- package/router-v2/seed-knowledge-base.js +39 -0
- package/router-v2/utils/ai.js +129 -0
- package/router-v2/utils/phone.js +52 -0
- package/router-v2/utils/response-validator.js +98 -0
- package/router-v2/utils/sanitize.js +222 -0
- package/router.js +5005 -0
- package/routes/google-calendar.js +186 -0
- package/scripts/deploy.sh +62 -0
- package/scripts/macos-calendar.sh +232 -0
- package/scripts/onboard-device.sh +466 -0
- package/server.js +5131 -0
- package/start.sh +24 -0
- package/templates/AGENTS.md +548 -0
- package/templates/IDENTITY.md +15 -0
- package/templates/docs-agents.html +132 -0
- package/templates/docs-app.html +130 -0
- package/templates/docs-home.html +83 -0
- package/templates/docs-imessage.html +121 -0
- package/templates/docs-tasks.html +123 -0
- package/templates/docs-tips.html +175 -0
- package/templates/getting-started.html +809 -0
- package/templates/invisible-prefix-base.txt +171 -0
- package/templates/invisible-prefix-owner.txt +282 -0
- package/templates/invisible-prefix.txt +338 -0
- package/templates/manifest.json +61 -0
- package/templates/memory-org/clients.md +7 -0
- package/templates/memory-org/credentials.md +9 -0
- package/templates/memory-org/devices.md +7 -0
- package/templates/updates.html +464 -0
- package/templates/workspace/AGENTS.md.tmpl +161 -0
- package/templates/workspace/HEARTBEAT.md.tmpl +17 -0
- package/templates/workspace/IDENTITY.md.tmpl +15 -0
- package/templates/workspace/MEMORY.md.tmpl +16 -0
- package/templates/workspace/SOUL.md.tmpl +51 -0
- package/templates/workspace/USER.md.tmpl +25 -0
- package/tts-proxy.js +96 -0
- package/voice-call-local.js +731 -0
- package/voice-call.js +732 -0
- package/wa-listener.js +354 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// ── iMessage Adapter (BlueBubbles REST API) ──────────────
|
|
2
|
+
// No CLI dependency - uses BlueBubbles REST API directly.
|
|
3
|
+
'use strict';
|
|
4
|
+
|
|
5
|
+
const { getSetting } = require('../db');
|
|
6
|
+
|
|
7
|
+
// BlueBubbles config - read from settings or env
|
|
8
|
+
function getBBConfig() {
|
|
9
|
+
return {
|
|
10
|
+
url: process.env.BLUEBUBBLES_URL || getSetting('blueBubblesUrl') || 'http://localhost:1234',
|
|
11
|
+
password: process.env.BLUEBUBBLES_PASSWORD || getSetting('blueBubblesPassword') || '',
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function log(msg, data) {
|
|
16
|
+
const ts = new Date().toISOString();
|
|
17
|
+
if (data) console.log(`[${ts}] [IMESSAGE] ${msg}`, JSON.stringify(data));
|
|
18
|
+
else console.log(`[${ts}] [IMESSAGE] ${msg}`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Send an iMessage via BlueBubbles REST API.
|
|
23
|
+
* @param {string} phone - E.164 phone number
|
|
24
|
+
* @param {string} text - Message text
|
|
25
|
+
* @returns {Promise<{success: boolean, error?: string}>}
|
|
26
|
+
*/
|
|
27
|
+
async function send(phone, text) {
|
|
28
|
+
const config = getBBConfig();
|
|
29
|
+
if (!config.password) {
|
|
30
|
+
log('No BlueBubbles password configured');
|
|
31
|
+
return { success: false, error: 'no_bb_password' };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const resp = await fetch(`${config.url}/api/v1/message/text?password=${encodeURIComponent(config.password)}`, {
|
|
36
|
+
method: 'POST',
|
|
37
|
+
headers: { 'Content-Type': 'application/json' },
|
|
38
|
+
body: JSON.stringify({
|
|
39
|
+
chatGuid: `iMessage;-;${phone}`,
|
|
40
|
+
tempGuid: `temp-${Date.now()}`,
|
|
41
|
+
message: text,
|
|
42
|
+
method: 'private-api',
|
|
43
|
+
}),
|
|
44
|
+
signal: AbortSignal.timeout(30000),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
if (!resp.ok) {
|
|
48
|
+
const errText = await resp.text().catch(() => 'unknown');
|
|
49
|
+
log('BB API error', { status: resp.status, body: errText.substring(0, 200) });
|
|
50
|
+
return { success: false, error: `bb_http_${resp.status}` };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const data = await resp.json();
|
|
54
|
+
if (data.status === 200 || data.status === 201) {
|
|
55
|
+
return { success: true };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
log('BB API unexpected status', { data });
|
|
59
|
+
return { success: false, error: `bb_status_${data.status}` };
|
|
60
|
+
} catch (err) {
|
|
61
|
+
log('BB API request failed', { error: err.message });
|
|
62
|
+
return { success: false, error: err.message };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Fetch recent messages from BlueBubbles for a specific chat.
|
|
68
|
+
* Used for manual message polling (tracking Brandon's direct texts).
|
|
69
|
+
* @param {string} phone - E.164 phone number
|
|
70
|
+
* @param {number} [limit=20]
|
|
71
|
+
* @param {number} [afterMs] - Only messages after this timestamp (epoch ms)
|
|
72
|
+
* @returns {Promise<Array>}
|
|
73
|
+
*/
|
|
74
|
+
async function fetchRecentMessages(phone, limit = 20, afterMs = null) {
|
|
75
|
+
const config = getBBConfig();
|
|
76
|
+
if (!config.password) return [];
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
const chatGuid = `iMessage;-;${phone}`;
|
|
80
|
+
let url = `${config.url}/api/v1/chat/${encodeURIComponent(chatGuid)}/message?password=${encodeURIComponent(config.password)}&limit=${limit}&sort=desc`;
|
|
81
|
+
if (afterMs) url += `&after=${afterMs}`;
|
|
82
|
+
|
|
83
|
+
const resp = await fetch(url, {
|
|
84
|
+
signal: AbortSignal.timeout(15000),
|
|
85
|
+
});
|
|
86
|
+
if (!resp.ok) return [];
|
|
87
|
+
|
|
88
|
+
const data = await resp.json();
|
|
89
|
+
return (data.data || []).map(msg => ({
|
|
90
|
+
id: msg.guid,
|
|
91
|
+
text: msg.text || '',
|
|
92
|
+
isFromMe: msg.isFromMe,
|
|
93
|
+
dateCreated: msg.dateCreated,
|
|
94
|
+
attachments: (msg.attachments || []).map(a => ({
|
|
95
|
+
type: a.mimeType,
|
|
96
|
+
filename: a.transferName,
|
|
97
|
+
url: `${config.url}/api/v1/attachment/${a.guid}/download`,
|
|
98
|
+
password: config.password,
|
|
99
|
+
})),
|
|
100
|
+
}));
|
|
101
|
+
} catch (err) {
|
|
102
|
+
log('Fetch messages failed', { phone, error: err.message });
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Poll BlueBubbles for messages Brandon sent directly (manual messages).
|
|
109
|
+
* Returns messages sent by Brandon (isFromMe=true) since the given timestamp.
|
|
110
|
+
* @param {number} sinceMs - Epoch milliseconds
|
|
111
|
+
* @returns {Promise<Array<{phone: string, text: string, timestamp: number}>>}
|
|
112
|
+
*/
|
|
113
|
+
async function pollBrandonMessages(sinceMs) {
|
|
114
|
+
const config = getBBConfig();
|
|
115
|
+
if (!config.password) return [];
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
const url = `${config.url}/api/v1/message?password=${encodeURIComponent(config.password)}&after=${sinceMs}&sort=desc&limit=50`;
|
|
119
|
+
const resp = await fetch(url, {
|
|
120
|
+
signal: AbortSignal.timeout(15000),
|
|
121
|
+
});
|
|
122
|
+
if (!resp.ok) return [];
|
|
123
|
+
|
|
124
|
+
const data = await resp.json();
|
|
125
|
+
const messages = (data.data || [])
|
|
126
|
+
.filter(msg => msg.isFromMe && !msg.isArchived && msg.text)
|
|
127
|
+
.map(msg => {
|
|
128
|
+
// Extract phone from chat participants
|
|
129
|
+
const chat = msg.chats?.[0];
|
|
130
|
+
const chatGuid = chat?.guid || '';
|
|
131
|
+
const phoneMatch = chatGuid.match(/;([+-]?\d+)$/);
|
|
132
|
+
const phone = phoneMatch ? (phoneMatch[1].startsWith('+') ? phoneMatch[1] : `+${phoneMatch[1]}`) : '';
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
phone,
|
|
136
|
+
text: msg.text,
|
|
137
|
+
timestamp: msg.dateCreated,
|
|
138
|
+
guid: msg.guid,
|
|
139
|
+
};
|
|
140
|
+
})
|
|
141
|
+
.filter(msg => msg.phone);
|
|
142
|
+
|
|
143
|
+
return messages;
|
|
144
|
+
} catch (err) {
|
|
145
|
+
log('Poll Brandon messages failed', { error: err.message });
|
|
146
|
+
return [];
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Parse an incoming BlueBubbles webhook payload into normalized format.
|
|
152
|
+
* @param {Object} payload - Raw webhook payload
|
|
153
|
+
* @returns {Object|null} Normalized message or null if not a relevant message
|
|
154
|
+
*/
|
|
155
|
+
function parseWebhook(payload) {
|
|
156
|
+
const msg = payload?.data || payload;
|
|
157
|
+
if (!msg) return null;
|
|
158
|
+
|
|
159
|
+
// Skip non-text messages, reactions, and group chats
|
|
160
|
+
if (msg.associatedMessageType || msg.isArchived) return null;
|
|
161
|
+
if (msg.isFromMe) return null; // We track Brandon's messages via polling
|
|
162
|
+
|
|
163
|
+
const chatGuid = msg.chats?.[0]?.guid || '';
|
|
164
|
+
// Skip group chats (v2.1)
|
|
165
|
+
if (chatGuid.includes(';+;')) return null;
|
|
166
|
+
|
|
167
|
+
const phoneMatch = chatGuid.match(/;([+-]?\d+)$/);
|
|
168
|
+
if (!phoneMatch) return null;
|
|
169
|
+
|
|
170
|
+
let phone = phoneMatch[1];
|
|
171
|
+
if (!phone.startsWith('+')) phone = `+${phone}`;
|
|
172
|
+
|
|
173
|
+
// Check for tapback/reaction
|
|
174
|
+
const tapbackPattern = /^(Loved|Liked|Disliked|Laughed at|Emphasized|Questioned) ".+"/;
|
|
175
|
+
if (tapbackPattern.test(msg.text?.trim())) return null;
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
id: msg.guid,
|
|
179
|
+
phone,
|
|
180
|
+
channel: 'imessage',
|
|
181
|
+
text: msg.text || '',
|
|
182
|
+
attachments: (msg.attachments || []).map(a => ({
|
|
183
|
+
type: a.mimeType,
|
|
184
|
+
filename: a.transferName,
|
|
185
|
+
})),
|
|
186
|
+
timestamp: new Date(msg.dateCreated),
|
|
187
|
+
raw: payload,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
module.exports = { send, fetchRecentMessages, pollBrandonMessages, parseWebhook };
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// ── Quo / OpenPhone Adapter ──────────────────────────────
|
|
2
|
+
// Handles OpenPhone webhook ingestion and outbound via API.
|
|
3
|
+
'use strict';
|
|
4
|
+
|
|
5
|
+
const { getSetting } = require('../db');
|
|
6
|
+
|
|
7
|
+
function log(msg, data) {
|
|
8
|
+
const ts = new Date().toISOString();
|
|
9
|
+
if (data) console.log(`[${ts}] [QUO] ${msg}`, JSON.stringify(data));
|
|
10
|
+
else console.log(`[${ts}] [QUO] ${msg}`);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Send a message via OpenPhone/Quo API.
|
|
15
|
+
* @param {string} phone - E.164 phone number
|
|
16
|
+
* @param {string} text - Message text
|
|
17
|
+
* @returns {Promise<{success: boolean, error?: string}>}
|
|
18
|
+
*/
|
|
19
|
+
async function send(phone, text) {
|
|
20
|
+
const apiKey = process.env.OPENPHONE_API_KEY || getSetting('openphoneApiKey');
|
|
21
|
+
const fromNumber = process.env.OPENPHONE_NUMBER || getSetting('openphoneNumber');
|
|
22
|
+
|
|
23
|
+
if (!apiKey) {
|
|
24
|
+
log('No OpenPhone API key configured');
|
|
25
|
+
return { success: false, error: 'no_api_key' };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const resp = await fetch('https://api.openphone.com/v1/messages', {
|
|
30
|
+
method: 'POST',
|
|
31
|
+
headers: {
|
|
32
|
+
'Content-Type': 'application/json',
|
|
33
|
+
'Authorization': apiKey,
|
|
34
|
+
},
|
|
35
|
+
body: JSON.stringify({
|
|
36
|
+
from: fromNumber,
|
|
37
|
+
to: [phone],
|
|
38
|
+
content: text,
|
|
39
|
+
}),
|
|
40
|
+
signal: AbortSignal.timeout(30000),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
if (!resp.ok) {
|
|
44
|
+
const errText = await resp.text().catch(() => 'unknown');
|
|
45
|
+
log('API error', { status: resp.status, body: errText.substring(0, 200) });
|
|
46
|
+
return { success: false, error: `openphone_http_${resp.status}` };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return { success: true };
|
|
50
|
+
} catch (err) {
|
|
51
|
+
log('API request failed', { error: err.message });
|
|
52
|
+
return { success: false, error: err.message };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Parse an OpenPhone/Quo webhook payload into normalized format.
|
|
58
|
+
* @param {Object} payload - Raw webhook payload
|
|
59
|
+
* @returns {Object|null}
|
|
60
|
+
*/
|
|
61
|
+
function parseWebhook(payload) {
|
|
62
|
+
if (!payload) return null;
|
|
63
|
+
|
|
64
|
+
const data = payload.data || payload;
|
|
65
|
+
const phone = data.from || '';
|
|
66
|
+
if (!phone) return null;
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
id: data.id || `quo-${Date.now()}`,
|
|
70
|
+
phone: phone.startsWith('+') ? phone : `+${phone}`,
|
|
71
|
+
channel: 'quo',
|
|
72
|
+
text: data.body || data.content || data.text || '',
|
|
73
|
+
attachments: (data.media || []).map(m => ({
|
|
74
|
+
type: m.type || 'unknown',
|
|
75
|
+
url: m.url,
|
|
76
|
+
})),
|
|
77
|
+
timestamp: data.createdAt ? new Date(data.createdAt) : new Date(),
|
|
78
|
+
raw: payload,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = { send, parseWebhook };
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// ── WhatsApp Adapter (wacli CLI) ─────────────────────────
|
|
2
|
+
// Improved send queue - no longer stops the world.
|
|
3
|
+
// Uses a serialized queue that briefly pauses sync for sends.
|
|
4
|
+
'use strict';
|
|
5
|
+
|
|
6
|
+
const { execSync, spawn, exec } = require('child_process');
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
|
|
9
|
+
const WACLI_BIN = '/opt/homebrew/bin/wacli';
|
|
10
|
+
let WACLI_AVAILABLE = false;
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
fs.accessSync(WACLI_BIN, fs.constants.X_OK);
|
|
14
|
+
WACLI_AVAILABLE = true;
|
|
15
|
+
} catch {
|
|
16
|
+
try {
|
|
17
|
+
execSync('which wacli', { timeout: 3000, encoding: 'utf-8' });
|
|
18
|
+
WACLI_AVAILABLE = true;
|
|
19
|
+
} catch {
|
|
20
|
+
WACLI_AVAILABLE = false;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let syncProcess = null;
|
|
25
|
+
let syncPid = null;
|
|
26
|
+
let sendQueue = [];
|
|
27
|
+
let sendProcessing = false;
|
|
28
|
+
|
|
29
|
+
function log(msg, data) {
|
|
30
|
+
const ts = new Date().toISOString();
|
|
31
|
+
if (data) console.log(`[${ts}] [WHATSAPP] ${msg}`, JSON.stringify(data));
|
|
32
|
+
else console.log(`[${ts}] [WHATSAPP] ${msg}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function killAllSync() {
|
|
36
|
+
try { execSync('pkill -f "wacli (sync|auth)"', { timeout: 5000 }); } catch { /* none running */ }
|
|
37
|
+
syncProcess = null;
|
|
38
|
+
syncPid = null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Start wacli sync process if not already running.
|
|
43
|
+
* @returns {boolean}
|
|
44
|
+
*/
|
|
45
|
+
function startSync() {
|
|
46
|
+
if (!WACLI_AVAILABLE) return false;
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const ps = execSync('pgrep -f "wacli (sync|auth)"', { timeout: 5000, encoding: 'utf-8' }).trim();
|
|
50
|
+
if (ps) {
|
|
51
|
+
syncPid = parseInt(ps.split('\n')[0]);
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
} catch { /* no process */ }
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
syncProcess = spawn(WACLI_BIN, ['sync', '--follow', '--refresh-contacts'], {
|
|
58
|
+
detached: true,
|
|
59
|
+
stdio: 'ignore',
|
|
60
|
+
});
|
|
61
|
+
syncProcess.unref();
|
|
62
|
+
syncPid = syncProcess.pid;
|
|
63
|
+
log('Started wacli sync', { pid: syncPid });
|
|
64
|
+
return true;
|
|
65
|
+
} catch (err) {
|
|
66
|
+
log('Failed to start sync', { error: err.message });
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function stopSync() {
|
|
72
|
+
if (syncPid) {
|
|
73
|
+
try { process.kill(syncPid, 'SIGTERM'); } catch { /* dead */ }
|
|
74
|
+
syncProcess = null;
|
|
75
|
+
syncPid = null;
|
|
76
|
+
}
|
|
77
|
+
killAllSync();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Send a WhatsApp message. Queued to avoid lock contention.
|
|
82
|
+
* @param {string} phone - E.164 phone number
|
|
83
|
+
* @param {string} text - Message text
|
|
84
|
+
* @returns {Promise<{success: boolean, error?: string}>}
|
|
85
|
+
*/
|
|
86
|
+
async function send(phone, text) {
|
|
87
|
+
if (!WACLI_AVAILABLE) {
|
|
88
|
+
return { success: false, error: 'wacli not installed' };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return new Promise((resolve) => {
|
|
92
|
+
sendQueue.push({ phone, text, resolve });
|
|
93
|
+
processSendQueue();
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function processSendQueue() {
|
|
98
|
+
if (sendProcessing || sendQueue.length === 0) return;
|
|
99
|
+
sendProcessing = true;
|
|
100
|
+
|
|
101
|
+
// Briefly stop sync to release lock
|
|
102
|
+
stopSync();
|
|
103
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
104
|
+
|
|
105
|
+
while (sendQueue.length > 0) {
|
|
106
|
+
const { phone, text, resolve } = sendQueue.shift();
|
|
107
|
+
log('Sending', { phone, textLen: text.length });
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
const result = await new Promise((res, rej) => {
|
|
111
|
+
exec(
|
|
112
|
+
`${WACLI_BIN} send text --to "${phone.replace(/^\+/, '')}" --message ${JSON.stringify(text)} --timeout 60s`,
|
|
113
|
+
{ timeout: 90000, encoding: 'utf-8' },
|
|
114
|
+
(err, stdout) => {
|
|
115
|
+
if (err) rej(err);
|
|
116
|
+
else res(stdout);
|
|
117
|
+
}
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
log('Send success', { phone });
|
|
121
|
+
resolve({ success: true });
|
|
122
|
+
} catch (err) {
|
|
123
|
+
log('Send FAILED', { phone, error: err.message });
|
|
124
|
+
resolve({ success: false, error: err.message });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Small pause between sends
|
|
128
|
+
if (sendQueue.length > 0) await new Promise(r => setTimeout(r, 500));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
sendProcessing = false;
|
|
132
|
+
// Restart sync
|
|
133
|
+
await new Promise(r => setTimeout(r, 300));
|
|
134
|
+
startSync();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Fetch recent messages from wacli.
|
|
139
|
+
* @param {string} [afterTimestamp] - ISO timestamp
|
|
140
|
+
* @param {number} [limit=20]
|
|
141
|
+
* @returns {Array}
|
|
142
|
+
*/
|
|
143
|
+
function fetchMessages(afterTimestamp = null, limit = 20) {
|
|
144
|
+
if (!WACLI_AVAILABLE) return [];
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
let cmd = `${WACLI_BIN} messages list --json --limit ${limit}`;
|
|
148
|
+
if (afterTimestamp) cmd += ` --after "${afterTimestamp}"`;
|
|
149
|
+
|
|
150
|
+
const output = execSync(cmd, { timeout: 15000, encoding: 'utf-8' });
|
|
151
|
+
const messages = JSON.parse(output);
|
|
152
|
+
return Array.isArray(messages) ? messages : [];
|
|
153
|
+
} catch {
|
|
154
|
+
return [];
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Parse a WhatsApp webhook payload into normalized format.
|
|
160
|
+
* @param {Object} payload - Raw webhook payload
|
|
161
|
+
* @returns {Object|null}
|
|
162
|
+
*/
|
|
163
|
+
function parseWebhook(payload) {
|
|
164
|
+
if (!payload) return null;
|
|
165
|
+
|
|
166
|
+
// Handle wacli webhook format
|
|
167
|
+
const msg = payload.message || payload;
|
|
168
|
+
const phone = msg.from || msg.phone || '';
|
|
169
|
+
if (!phone) return null;
|
|
170
|
+
|
|
171
|
+
const normalized = phone.startsWith('+') ? phone : `+${phone.replace(/^\\+/, '')}`;
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
id: msg.id || `wa-${Date.now()}`,
|
|
175
|
+
phone: normalized,
|
|
176
|
+
channel: 'whatsapp',
|
|
177
|
+
text: msg.text || msg.body || '',
|
|
178
|
+
attachments: [],
|
|
179
|
+
timestamp: msg.timestamp ? new Date(msg.timestamp) : new Date(),
|
|
180
|
+
raw: payload,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Check if wacli is available and authenticated.
|
|
186
|
+
* @returns {boolean}
|
|
187
|
+
*/
|
|
188
|
+
function isAvailable() {
|
|
189
|
+
return WACLI_AVAILABLE;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
module.exports = { send, fetchMessages, parseWebhook, startSync, stopSync, isAvailable };
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// ── Contact Manager - CRUD + Context + Qualification ─────
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const { getStmts, getSetting } = require('./db');
|
|
5
|
+
const { normalizePhone } = require('./utils/phone');
|
|
6
|
+
|
|
7
|
+
// Default scopes per category (from design doc Section 7.3)
|
|
8
|
+
const DEFAULT_SCOPES = {
|
|
9
|
+
family: ['calendar.view', 'calendar.book', 'calendar.modify', 'messages.send', 'messages.auto-reply', 'info.business', 'info.personal', 'reminders.create'],
|
|
10
|
+
friend: ['calendar.view', 'calendar.book', 'messages.auto-reply', 'info.business'],
|
|
11
|
+
client: ['calendar.view', 'calendar.book', 'messages.auto-reply', 'info.business', 'support.technical'],
|
|
12
|
+
'qualified-lead': ['calendar.view', 'calendar.book', 'messages.auto-reply', 'info.business'],
|
|
13
|
+
lead: ['messages.auto-reply', 'info.business'],
|
|
14
|
+
team: ['calendar.view', 'calendar.book', 'calendar.modify', 'messages.send', 'messages.auto-reply', 'tasks.create', 'info.business', 'reminders.create'],
|
|
15
|
+
unknown: ['messages.auto-reply'],
|
|
16
|
+
blocked: [],
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function log(msg, data) {
|
|
20
|
+
const ts = new Date().toISOString();
|
|
21
|
+
if (data) console.log(`[${ts}] [CONTACT-MGR] ${msg}`, JSON.stringify(data));
|
|
22
|
+
else console.log(`[${ts}] [CONTACT-MGR] ${msg}`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Get a contact by phone number. Returns null if not found.
|
|
27
|
+
* @param {string} phone - E.164 phone
|
|
28
|
+
* @returns {Object|null}
|
|
29
|
+
*/
|
|
30
|
+
function getContact(phone) {
|
|
31
|
+
const stmts = getStmts();
|
|
32
|
+
const contact = stmts.getContact.get(normalizePhone(phone));
|
|
33
|
+
if (!contact) return null;
|
|
34
|
+
const context = stmts.getContext.get(contact.phone) || {};
|
|
35
|
+
const scopes = stmts.getAllScopes.all(contact.phone).filter(s => s.granted).map(s => s.scope);
|
|
36
|
+
return { ...contact, context, scopes };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Get or create a contact. If the contact doesn't exist, creates with defaults.
|
|
41
|
+
* @param {string} phone - E.164 phone
|
|
42
|
+
* @param {Object} [defaults] - Default values for new contacts
|
|
43
|
+
* @returns {Object}
|
|
44
|
+
*/
|
|
45
|
+
function getOrCreateContact(phone, defaults = {}) {
|
|
46
|
+
const normalized = normalizePhone(phone);
|
|
47
|
+
let contact = getContact(normalized);
|
|
48
|
+
if (contact) return contact;
|
|
49
|
+
|
|
50
|
+
const stmts = getStmts();
|
|
51
|
+
const name = defaults.name || 'Unknown';
|
|
52
|
+
const category = defaults.category || 'unknown';
|
|
53
|
+
const source = defaults.source || 'unknown';
|
|
54
|
+
|
|
55
|
+
stmts.upsertContact.run(normalized, name, category, 'auto', 'casual', '', source, 0, 0, 'none');
|
|
56
|
+
stmts.upsertContext.run(normalized, '', '', '[]', '', '{}');
|
|
57
|
+
|
|
58
|
+
// Apply default scopes for category
|
|
59
|
+
const categoryScopes = DEFAULT_SCOPES[category] || DEFAULT_SCOPES.unknown;
|
|
60
|
+
for (const scope of categoryScopes) {
|
|
61
|
+
stmts.upsertScope.run(normalized, scope, 1, 'auto');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
log('Created new contact', { phone: normalized, name, category, source });
|
|
65
|
+
return getContact(normalized);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Update a contact's fields.
|
|
70
|
+
* @param {string} phone - E.164 phone
|
|
71
|
+
* @param {Object} updates - Fields to update
|
|
72
|
+
* @returns {Object|null} Updated contact
|
|
73
|
+
*/
|
|
74
|
+
function updateContact(phone, updates) {
|
|
75
|
+
const normalized = normalizePhone(phone);
|
|
76
|
+
const existing = getContact(normalized);
|
|
77
|
+
if (!existing) return null;
|
|
78
|
+
|
|
79
|
+
const stmts = getStmts();
|
|
80
|
+
stmts.upsertContact.run(
|
|
81
|
+
normalized,
|
|
82
|
+
updates.name ?? existing.name,
|
|
83
|
+
updates.category ?? existing.category,
|
|
84
|
+
updates.response_mode ?? existing.response_mode,
|
|
85
|
+
updates.style ?? existing.style,
|
|
86
|
+
updates.instructions ?? existing.instructions,
|
|
87
|
+
updates.source ?? existing.source,
|
|
88
|
+
updates.introduced ?? existing.introduced,
|
|
89
|
+
updates.qualification_score ?? existing.qualification_score,
|
|
90
|
+
updates.pipeline_stage ?? existing.pipeline_stage,
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
// If category changed, apply new default scopes
|
|
94
|
+
if (updates.category && updates.category !== existing.category) {
|
|
95
|
+
applyDefaultScopes(normalized, updates.category);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
log('Updated contact', { phone: normalized, updates: Object.keys(updates) });
|
|
99
|
+
return getContact(normalized);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Update contact context.
|
|
104
|
+
* @param {string} phone - E.164 phone
|
|
105
|
+
* @param {Object} contextUpdates
|
|
106
|
+
*/
|
|
107
|
+
function updateContext(phone, contextUpdates) {
|
|
108
|
+
const normalized = normalizePhone(phone);
|
|
109
|
+
const stmts = getStmts();
|
|
110
|
+
const existing = stmts.getContext.get(normalized) || {};
|
|
111
|
+
|
|
112
|
+
stmts.upsertContext.run(
|
|
113
|
+
normalized,
|
|
114
|
+
contextUpdates.relationship ?? existing.relationship ?? '',
|
|
115
|
+
contextUpdates.last_topic ?? existing.last_topic ?? '',
|
|
116
|
+
contextUpdates.pending_items ?? existing.pending_items ?? '[]',
|
|
117
|
+
contextUpdates.conversation_summary ?? existing.conversation_summary ?? '',
|
|
118
|
+
contextUpdates.preferences ?? existing.preferences ?? '{}',
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Delete a contact and all associated data.
|
|
124
|
+
* @param {string} phone - E.164 phone
|
|
125
|
+
* @returns {boolean}
|
|
126
|
+
*/
|
|
127
|
+
function deleteContact(phone) {
|
|
128
|
+
const normalized = normalizePhone(phone);
|
|
129
|
+
const stmts = getStmts();
|
|
130
|
+
stmts.deleteContext.run(normalized);
|
|
131
|
+
stmts.deleteScopesByPhone.run(normalized);
|
|
132
|
+
stmts.deleteContact.run(normalized);
|
|
133
|
+
log('Deleted contact', { phone: normalized });
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Get all contacts, optionally filtered.
|
|
139
|
+
* @param {Object} [filter] - { category, search }
|
|
140
|
+
* @returns {Array}
|
|
141
|
+
*/
|
|
142
|
+
function listContacts(filter = {}) {
|
|
143
|
+
const stmts = getStmts();
|
|
144
|
+
if (filter.category) {
|
|
145
|
+
return stmts.filterByCategory.all(filter.category);
|
|
146
|
+
}
|
|
147
|
+
if (filter.search) {
|
|
148
|
+
const q = `%${filter.search}%`;
|
|
149
|
+
return stmts.searchContacts.all(q, q);
|
|
150
|
+
}
|
|
151
|
+
return stmts.getAllContacts.all();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Apply default scopes for a category. Does NOT revoke existing scopes.
|
|
156
|
+
* @param {string} phone
|
|
157
|
+
* @param {string} category
|
|
158
|
+
*/
|
|
159
|
+
function applyDefaultScopes(phone, category) {
|
|
160
|
+
const stmts = getStmts();
|
|
161
|
+
const scopes = DEFAULT_SCOPES[category] || DEFAULT_SCOPES.unknown;
|
|
162
|
+
for (const scope of scopes) {
|
|
163
|
+
stmts.upsertScope.run(phone, scope, 1, 'auto');
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Set specific scopes for a contact.
|
|
169
|
+
* @param {string} phone
|
|
170
|
+
* @param {Object} scopeMap - { 'calendar.book': true, 'info.personal': false }
|
|
171
|
+
*/
|
|
172
|
+
function setScopes(phone, scopeMap) {
|
|
173
|
+
const normalized = normalizePhone(phone);
|
|
174
|
+
const stmts = getStmts();
|
|
175
|
+
for (const [scope, granted] of Object.entries(scopeMap)) {
|
|
176
|
+
stmts.upsertScope.run(normalized, scope, granted ? 1 : 0, 'manual');
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Check if a contact has a specific scope.
|
|
182
|
+
* @param {string} phone
|
|
183
|
+
* @param {string} scope
|
|
184
|
+
* @returns {boolean}
|
|
185
|
+
*/
|
|
186
|
+
function hasScope(phone, scope) {
|
|
187
|
+
const stmts = getStmts();
|
|
188
|
+
const row = stmts.getScope.get(normalizePhone(phone), scope);
|
|
189
|
+
return row?.granted === 1;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Mark a contact as introduced by AIVA.
|
|
194
|
+
* @param {string} phone
|
|
195
|
+
*/
|
|
196
|
+
function markIntroduced(phone) {
|
|
197
|
+
getStmts().markIntroduced.run(normalizePhone(phone));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Update qualification score and pipeline stage.
|
|
202
|
+
* @param {string} phone
|
|
203
|
+
* @param {number} score - 0-100
|
|
204
|
+
* @param {string} stage - none, cold, warm, hot, qualified, client
|
|
205
|
+
*/
|
|
206
|
+
function updateQualification(phone, score, stage) {
|
|
207
|
+
getStmts().updateQualification.run(score, stage, normalizePhone(phone));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Check if the last outbound message was sent by Brandon (for reintro logic).
|
|
212
|
+
* @param {string} phone
|
|
213
|
+
* @returns {boolean}
|
|
214
|
+
*/
|
|
215
|
+
function needsReintro(phone) {
|
|
216
|
+
const row = getStmts().getLastSentBy.get(normalizePhone(phone));
|
|
217
|
+
return row && row.sent_by === 'brandon';
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
module.exports = {
|
|
221
|
+
getContact,
|
|
222
|
+
getOrCreateContact,
|
|
223
|
+
updateContact,
|
|
224
|
+
updateContext,
|
|
225
|
+
deleteContact,
|
|
226
|
+
listContacts,
|
|
227
|
+
applyDefaultScopes,
|
|
228
|
+
setScopes,
|
|
229
|
+
hasScope,
|
|
230
|
+
markIntroduced,
|
|
231
|
+
updateQualification,
|
|
232
|
+
needsReintro,
|
|
233
|
+
DEFAULT_SCOPES,
|
|
234
|
+
};
|