@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,606 @@
|
|
|
1
|
+
// ── Follow-Up Handler ────────────────────────────────────
|
|
2
|
+
// Scans for dead conversations and uses AI to decide follow-ups
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const { execSync } = require('child_process');
|
|
6
|
+
const { db, stmts } = require('./router-db');
|
|
7
|
+
|
|
8
|
+
const CONFIG_PATH = path.join(process.env.HOME, '.openclaw', 'openclaw.json');
|
|
9
|
+
|
|
10
|
+
// ── Sent-By Reintroduction Logic (shared) ────────────────
|
|
11
|
+
const { addReintroIfNeeded } = require('./router-utils');
|
|
12
|
+
|
|
13
|
+
function getOpenClawPassword() {
|
|
14
|
+
try {
|
|
15
|
+
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
|
|
16
|
+
return config?.gateway?.auth?.password || '';
|
|
17
|
+
} catch { return ''; }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function getSetting(key) {
|
|
21
|
+
const row = stmts.getSetting.get(key);
|
|
22
|
+
return row ? row.value : '';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function log(msg, data) {
|
|
26
|
+
const ts = new Date().toISOString();
|
|
27
|
+
if (data) console.log(`[${ts}] [FOLLOW-UP] ${msg}`, JSON.stringify(data));
|
|
28
|
+
else console.log(`[${ts}] [FOLLOW-UP] ${msg}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isBusinessHours() {
|
|
32
|
+
// Robust PST/PDT calculation using Intl.DateTimeFormat for reliable timezone parts
|
|
33
|
+
const now = new Date();
|
|
34
|
+
const parts = new Intl.DateTimeFormat('en-US', {
|
|
35
|
+
timeZone: 'America/Los_Angeles',
|
|
36
|
+
hour: 'numeric', hour12: false,
|
|
37
|
+
weekday: 'short',
|
|
38
|
+
year: 'numeric', month: 'numeric', day: 'numeric'
|
|
39
|
+
}).formatToParts(now);
|
|
40
|
+
|
|
41
|
+
const get = (type) => parts.find(p => p.type === type)?.value;
|
|
42
|
+
const hour = parseInt(get('hour'), 10); // 0-23 in America/Los_Angeles
|
|
43
|
+
const weekday = get('weekday'); // "Sun", "Mon", etc.
|
|
44
|
+
|
|
45
|
+
if (weekday === 'Sun') return false; // No Sundays
|
|
46
|
+
|
|
47
|
+
const startHour = parseInt(getSetting('followUpStartHour')) || 8;
|
|
48
|
+
const endHour = parseInt(getSetting('followUpEndHour')) || 18;
|
|
49
|
+
|
|
50
|
+
// Hard floor/ceiling: never outside 8-18 regardless of settings
|
|
51
|
+
const effectiveStart = Math.max(startHour, 8);
|
|
52
|
+
const effectiveEnd = Math.min(endHour, 18);
|
|
53
|
+
|
|
54
|
+
return hour >= effectiveStart && hour < effectiveEnd;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isErrorMessage(text) {
|
|
58
|
+
if (!text) return false;
|
|
59
|
+
const t = text.toLowerCase();
|
|
60
|
+
return /no response from openclaw|openclaw.*error|internal server error|ECONNREFUSED|agent.*timeout|session.*failed|tool.*error/i.test(t);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Sanitize outbound messages — strip any mention of internal systems
|
|
64
|
+
function sanitizeOutbound(text) {
|
|
65
|
+
if (!text) return text;
|
|
66
|
+
// If the message mentions OpenClaw at all, it's contaminated — reject it
|
|
67
|
+
if (/openclaw/i.test(text)) {
|
|
68
|
+
log('BLOCKED outbound message containing "OpenClaw"', { snippet: text.substring(0, 100) });
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
return text;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function sendIMessage(phone, text) {
|
|
75
|
+
if (isErrorMessage(text)) {
|
|
76
|
+
log('BLOCKED error message from reaching contact', { phone, text: text.substring(0, 80) });
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
const tmpFile = `/tmp/imsg-fu-${Date.now()}.txt`;
|
|
81
|
+
fs.writeFileSync(tmpFile, text);
|
|
82
|
+
execSync(`imsg send --to "${phone}" --text "$(cat ${tmpFile})"`, { timeout: 15000, encoding: 'utf-8', shell: '/bin/bash' });
|
|
83
|
+
try { fs.unlinkSync(tmpFile); } catch(e) {}
|
|
84
|
+
log('iMessage sent', { phone, len: text.length });
|
|
85
|
+
return true;
|
|
86
|
+
} catch (err) {
|
|
87
|
+
log('iMessage send failed', { phone, error: err.message });
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function sendWhatsApp(phone, text) {
|
|
93
|
+
if (isErrorMessage(text)) {
|
|
94
|
+
log('BLOCKED error message from reaching contact (WA)', { phone, text: text.substring(0, 80) });
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
const waListener = require('./wa-listener');
|
|
99
|
+
const result = await waListener.sendMessage(phone, text);
|
|
100
|
+
if (result.success) {
|
|
101
|
+
log('WhatsApp sent', { phone, len: text.length });
|
|
102
|
+
return true;
|
|
103
|
+
} else {
|
|
104
|
+
log('WhatsApp send failed', { phone, error: result.error });
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
} catch (err) {
|
|
108
|
+
log('WhatsApp send error', { phone, error: err.message });
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function parseTimeDelay(delayStr) {
|
|
114
|
+
// Returns a datetime string for next_follow_up_at
|
|
115
|
+
const now = new Date();
|
|
116
|
+
|
|
117
|
+
if (!delayStr || delayStr === 'never') return null;
|
|
118
|
+
|
|
119
|
+
const hourMatch = delayStr.match(/(\d+)\s*hour/i);
|
|
120
|
+
if (hourMatch) {
|
|
121
|
+
const ms = parseInt(hourMatch[1]) * 3600000;
|
|
122
|
+
return new Date(now.getTime() + ms).toISOString().replace('T', ' ').replace('Z', '').split('.')[0];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const dayMatch = delayStr.match(/(\d+)\s*day/i);
|
|
126
|
+
if (dayMatch) {
|
|
127
|
+
const ms = parseInt(dayMatch[1]) * 86400000;
|
|
128
|
+
return new Date(now.getTime() + ms).toISOString().replace('T', ' ').replace('Z', '').split('.')[0];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (/tomorrow\s*morning/i.test(delayStr)) {
|
|
132
|
+
// Calculate tomorrow 9 AM in America/Los_Angeles properly
|
|
133
|
+
// Get current LA date parts
|
|
134
|
+
const laParts = new Intl.DateTimeFormat('en-US', {
|
|
135
|
+
timeZone: 'America/Los_Angeles',
|
|
136
|
+
year: 'numeric', month: '2-digit', day: '2-digit'
|
|
137
|
+
}).formatToParts(now);
|
|
138
|
+
const laGet = (t) => laParts.find(p => p.type === t)?.value;
|
|
139
|
+
const laYear = parseInt(laGet('year'));
|
|
140
|
+
const laMonth = parseInt(laGet('month')) - 1;
|
|
141
|
+
const laDay = parseInt(laGet('day'));
|
|
142
|
+
// Create tomorrow 9 AM in LA by brute-force: scan UTC hours to find when LA shows 9 AM tomorrow
|
|
143
|
+
const tomorrowDay = laDay + 1;
|
|
144
|
+
// Start from a rough UTC guess (tomorrow 17:00 UTC ≈ 9 AM PST) and adjust
|
|
145
|
+
let target = new Date(Date.UTC(laYear, laMonth, tomorrowDay, 17, 0, 0));
|
|
146
|
+
// Verify and adjust: get the LA hour for this UTC time
|
|
147
|
+
const checkHour = (d) => parseInt(new Intl.DateTimeFormat('en-US', { timeZone: 'America/Los_Angeles', hour: 'numeric', hour12: false }).format(d));
|
|
148
|
+
const checkDay = (d) => parseInt(new Intl.DateTimeFormat('en-US', { timeZone: 'America/Los_Angeles', day: 'numeric' }).format(d));
|
|
149
|
+
// Adjust until we hit 9 AM on the right day
|
|
150
|
+
for (let adj = -3; adj <= 3; adj++) {
|
|
151
|
+
const candidate = new Date(target.getTime() + adj * 3600000);
|
|
152
|
+
if (checkHour(candidate) === 9 && checkDay(candidate) === tomorrowDay) {
|
|
153
|
+
target = candidate;
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return target.toISOString().replace('T', ' ').replace('Z', '').split('.')[0];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Default: 4 hours
|
|
161
|
+
return new Date(now.getTime() + 4 * 3600000).toISOString().replace('T', ' ').replace('Z', '').split('.')[0];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function getAnthropicApiKey() {
|
|
165
|
+
if (process.env.ANTHROPIC_API_KEY) return process.env.ANTHROPIC_API_KEY;
|
|
166
|
+
// Read from openclaw's auth config
|
|
167
|
+
try {
|
|
168
|
+
const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
|
|
169
|
+
// Try to extract from the auth token (OpenClaw uses this for its proxy)
|
|
170
|
+
const authToken = cfg?.auth?.apiKeys?.[0] || cfg?.gateway?.auth?.apiKeys?.[0];
|
|
171
|
+
if (authToken) return authToken;
|
|
172
|
+
} catch(e) {}
|
|
173
|
+
// Fallback: the known key for this installation
|
|
174
|
+
return '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';
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function callXAIDirect(systemPrompt, userMessage) {
|
|
178
|
+
const apiKey = 'xai-Gn37fuJg5ty4gvWFG2rbth34AxNORUKH8r4vTXQDtjwMGUqKZ7nYy8u2YStosGUCVBEg7VMHSqQZcKS4';
|
|
179
|
+
try {
|
|
180
|
+
const resp = await fetch('https://api.x.ai/v1/chat/completions', {
|
|
181
|
+
method: 'POST',
|
|
182
|
+
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` },
|
|
183
|
+
body: JSON.stringify({ model: 'grok-4-1-fast-non-reasoning', max_tokens: 500, messages: [{ role: 'system', content: systemPrompt }, { role: 'user', content: userMessage }] }),
|
|
184
|
+
signal: AbortSignal.timeout(30000)
|
|
185
|
+
});
|
|
186
|
+
if (!resp.ok) { log('xAI API failed', { status: resp.status }); return null; }
|
|
187
|
+
const data = await resp.json();
|
|
188
|
+
return data.choices?.[0]?.message?.content?.trim() || null;
|
|
189
|
+
} catch(e) { log('xAI API error', { error: e.message }); return null; }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function callOpenClawProxy(systemPrompt, userMessage) {
|
|
193
|
+
const password = getOpenClawPassword();
|
|
194
|
+
if (!password) return null;
|
|
195
|
+
try {
|
|
196
|
+
const resp = await fetch('http://127.0.0.1:18789/v1/chat/completions', {
|
|
197
|
+
method: 'POST',
|
|
198
|
+
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${password}` },
|
|
199
|
+
body: JSON.stringify({ model: 'claude-sonnet-4-5', max_tokens: 500, messages: [{ role: 'system', content: systemPrompt }, { role: 'user', content: userMessage }] }),
|
|
200
|
+
signal: AbortSignal.timeout(60000)
|
|
201
|
+
});
|
|
202
|
+
if (!resp.ok) { const errText = await resp.text(); log('Proxy HTTP error', { status: resp.status, body: errText.substring(0, 200) }); return null; }
|
|
203
|
+
const data = await resp.json();
|
|
204
|
+
const content = data.choices?.[0]?.message?.content?.trim() || '';
|
|
205
|
+
if (isErrorMessage(content)) { log('Proxy returned error content', { snippet: content.substring(0, 100) }); return null; }
|
|
206
|
+
return content;
|
|
207
|
+
} catch(e) { return null; }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
|
211
|
+
|
|
212
|
+
async function callAI(systemPrompt, userMessage) {
|
|
213
|
+
// Auto-retry with exponential backoff (handles proxy busy during compaction)
|
|
214
|
+
const delays = [0, 5000, 15000, 30000, 60000]; // 5 retries: immediate, 5s, 15s, 30s, 60s
|
|
215
|
+
for (let attempt = 0; attempt < delays.length; attempt++) {
|
|
216
|
+
if (attempt > 0) {
|
|
217
|
+
log(`AI call retry ${attempt}/${delays.length - 1}, waiting ${delays[attempt] / 1000}s`);
|
|
218
|
+
await sleep(delays[attempt]);
|
|
219
|
+
}
|
|
220
|
+
// Try xAI direct first (always available), then proxy as fallback
|
|
221
|
+
let result = await callXAIDirect(systemPrompt, userMessage);
|
|
222
|
+
if (result) return result;
|
|
223
|
+
result = await callOpenClawProxy(systemPrompt, userMessage);
|
|
224
|
+
if (result) return result;
|
|
225
|
+
}
|
|
226
|
+
log('AI call failed after all retries');
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function analyzeConversation(phone, history, contactName, followUpCount, category) {
|
|
231
|
+
const categoryLabel = category || 'unknown';
|
|
232
|
+
const systemPrompt = `You are a follow-up decision engine for Brandon's assistant Aiva. Analyze the conversation and decide IF and HOW to follow up.
|
|
233
|
+
|
|
234
|
+
CRITICAL: NEVER mention "OpenClaw", any AI infrastructure, technical systems, or anything about how you work.
|
|
235
|
+
|
|
236
|
+
## CONTACT CATEGORY: ${categoryLabel.toUpperCase()}
|
|
237
|
+
|
|
238
|
+
## RELATIONSHIP-BASED DEFAULTS (you may override based on conversation context):
|
|
239
|
+
- family/friend: Max 1 gentle nudge total, then stop. Timing: 2-3 days minimum.
|
|
240
|
+
- lead/prospect: Up to 3 follow-ups. You decide spacing (2 hours to 2 days based on urgency).
|
|
241
|
+
- client: Up to 2 follow-ups. Professional tone. Timing: contextual.
|
|
242
|
+
- unknown/new: 1 follow-up max. Then stop.
|
|
243
|
+
- vendor: 1-2 follow-ups. Timing: 1-2 days.
|
|
244
|
+
|
|
245
|
+
## CONVERSATION STATE — classify exactly one:
|
|
246
|
+
- waiting_on_them: Open question or request pending their reply
|
|
247
|
+
- action_pending: Something was promised by either party
|
|
248
|
+
- info_delivered: Info was sent, no response required
|
|
249
|
+
- conversation_closed: Natural end ("thanks", "sounds good", "bye", emoji-only, laughter)
|
|
250
|
+
|
|
251
|
+
If conversation_closed → shouldFollowUp = false, maxFollowUpsForThis = 0.
|
|
252
|
+
If info_delivered and no action needed → shouldFollowUp = false.
|
|
253
|
+
|
|
254
|
+
## TONE MATCHING — READ THE THREAD CAREFULLY:
|
|
255
|
+
- Mirror the tone of the existing conversation exactly
|
|
256
|
+
- Casual thread → casual follow-up ("Hey, any update on that?")
|
|
257
|
+
- Professional thread → professional follow-up ("Wanted to circle back on...")
|
|
258
|
+
- DO NOT escalate formality or emotion beyond what's in the thread
|
|
259
|
+
- DO NOT manufacture empathy ("you deserve better", "that's not fair to you")
|
|
260
|
+
- DO NOT apologize for following up ("sorry to bother", "I know you're busy")
|
|
261
|
+
- DO NOT use emotional manipulation or over-promising
|
|
262
|
+
- Each follow-up should be SHORTER than the last, not longer
|
|
263
|
+
- Casual: 1-2 sentences max. Business: 2-3 sentences max.
|
|
264
|
+
|
|
265
|
+
## HARD RULES:
|
|
266
|
+
- This is follow-up attempt #${followUpCount + 1}
|
|
267
|
+
- If attempt >= maxFollowUpsForThis you set, shouldFollowUp = false
|
|
268
|
+
- If the last 2+ outbound messages are about the same topic and the contact hasn't responded with new information, return shouldFollowUp: false
|
|
269
|
+
- Business hours only (8 AM - 6 PM PST), never on Sundays
|
|
270
|
+
- Current time: ${new Date().toISOString()}
|
|
271
|
+
|
|
272
|
+
## TONE RULES FOR FOLLOW-UPS:
|
|
273
|
+
- Each follow-up should be SHORTER and MORE CASUAL than the last, not longer or more emotional
|
|
274
|
+
- NEVER apologize for following up ("sorry to bother", "I feel bad", "I'm embarrassed")
|
|
275
|
+
- NEVER escalate emotional language or make dramatic promises
|
|
276
|
+
- If you've already followed up once, the next one should be ultra-brief (e.g., "Hey, just circling back on this")
|
|
277
|
+
- If you've followed up twice with no reply, STOP (shouldFollowUp = false)
|
|
278
|
+
- A human would send ONE brief nudge and then wait. Be that human.
|
|
279
|
+
|
|
280
|
+
Respond in JSON ONLY:
|
|
281
|
+
{
|
|
282
|
+
"shouldFollowUp": true/false,
|
|
283
|
+
"maxFollowUpsForThis": 0-3,
|
|
284
|
+
"nextFollowUpIn": "4 hours" | "tomorrow morning" | "2 days" | "never",
|
|
285
|
+
"suggestedMessage": "Short, natural follow-up text",
|
|
286
|
+
"reasoning": "Brief explanation",
|
|
287
|
+
"conversationState": "waiting_on_them" | "conversation_closed" | "info_delivered" | "action_pending",
|
|
288
|
+
"topic": "brief topic label (e.g., 'plane tickets', 'meeting schedule')"
|
|
289
|
+
}`;
|
|
290
|
+
const userMsg = `Contact: ${contactName} (${phone})\nContact category: ${categoryLabel}\nFollow-up attempt: #${followUpCount + 1}\n\nConversation:\n${history}\n\nShould we follow up?`;
|
|
291
|
+
|
|
292
|
+
const content = await callAI(systemPrompt, userMsg);
|
|
293
|
+
if (!content) return null;
|
|
294
|
+
try {
|
|
295
|
+
const jsonMatch = content.match(/\{[\s\S]*\}/);
|
|
296
|
+
if (!jsonMatch) return null;
|
|
297
|
+
return JSON.parse(jsonMatch[0]);
|
|
298
|
+
} catch(e) { return null; }
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function fetchConversationHistory(phone, limit = 25) {
|
|
302
|
+
try {
|
|
303
|
+
const output = execSync(`imsg chats 2>/dev/null | grep "${phone}"`, { timeout: 5000, encoding: 'utf-8' }).trim();
|
|
304
|
+
const chatIdMatch = output.match(/^\[(\d+)\]/);
|
|
305
|
+
if (!chatIdMatch) return null;
|
|
306
|
+
|
|
307
|
+
const chatId = chatIdMatch[1];
|
|
308
|
+
const historyRaw = execSync(`imsg history --chat-id ${chatId} --limit ${limit}`, { timeout: 10000, encoding: 'utf-8' }).trim();
|
|
309
|
+
if (!historyRaw) return null;
|
|
310
|
+
|
|
311
|
+
const lines = historyRaw.split('\n').filter(l => l.trim());
|
|
312
|
+
const messages = lines.map(line => {
|
|
313
|
+
const match = line.match(/^(\S+)\s+\[(sent|recv)\]\s+\S+:\s+(.*)$/);
|
|
314
|
+
if (!match) return null;
|
|
315
|
+
return { ts: match[1], direction: match[2], text: match[3] };
|
|
316
|
+
}).filter(Boolean);
|
|
317
|
+
|
|
318
|
+
return messages.reverse().map(m => {
|
|
319
|
+
const sender = m.direction === 'sent' ? 'Brandon/Aiva' : 'Them';
|
|
320
|
+
return `[${new Date(m.ts).toLocaleString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true })}] ${sender}: ${m.text}`;
|
|
321
|
+
}).join('\n');
|
|
322
|
+
} catch {
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async function checkForDeadConversations() {
|
|
328
|
+
log('Scanning for dead conversations...');
|
|
329
|
+
|
|
330
|
+
// Get conversations where our last message was outbound and at least 1 hour ago
|
|
331
|
+
const deadConvos = db.prepare(`
|
|
332
|
+
SELECT m.phone, m.message_preview, m.timestamp,
|
|
333
|
+
COALESCE(NULLIF(cr.name, ''), NULLIF(cr.name, 'Unknown'), m.phone) as contact_name,
|
|
334
|
+
COALESCE(cr.source, 'imessage') as channel
|
|
335
|
+
FROM message_log m
|
|
336
|
+
INNER JOIN (
|
|
337
|
+
SELECT phone, MAX(id) as max_id FROM message_log
|
|
338
|
+
WHERE forwarded_to != 'group' AND forwarded_to NOT LIKE '%group%'
|
|
339
|
+
GROUP BY phone
|
|
340
|
+
) latest ON m.phone = latest.phone AND m.id = latest.max_id
|
|
341
|
+
LEFT JOIN contact_rules cr ON cr.phone = m.phone
|
|
342
|
+
LEFT JOIN follow_up_tracker ft ON ft.phone = m.phone
|
|
343
|
+
WHERE m.direction = 'outbound'
|
|
344
|
+
AND m.timestamp <= datetime('now', '-1 hour')
|
|
345
|
+
AND (ft.phone IS NULL OR ft.status NOT IN ('cold', 'completed', 'paused'))
|
|
346
|
+
AND m.phone != ?
|
|
347
|
+
`).all(getSetting('masterPhone') || '+15099794110');
|
|
348
|
+
|
|
349
|
+
log('Dead conversations found', { count: deadConvos.length });
|
|
350
|
+
|
|
351
|
+
for (const conv of deadConvos) {
|
|
352
|
+
const existing = stmts.getFollowUpByPhone.get(conv.phone);
|
|
353
|
+
if (existing) continue; // Already tracked
|
|
354
|
+
|
|
355
|
+
// Create a new tracker entry
|
|
356
|
+
const now = new Date().toISOString().replace('T', ' ').replace('Z', '').split('.')[0];
|
|
357
|
+
stmts.upsertFollowUp.run(
|
|
358
|
+
conv.phone,
|
|
359
|
+
conv.channel === 'whatsapp' ? 'whatsapp' : 'imessage',
|
|
360
|
+
conv.contact_name,
|
|
361
|
+
conv.message_preview || '',
|
|
362
|
+
conv.timestamp || now,
|
|
363
|
+
'active',
|
|
364
|
+
conv.timestamp || now
|
|
365
|
+
);
|
|
366
|
+
log('New follow-up tracker created', { phone: conv.phone, name: conv.contact_name });
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async function processFollowUps() {
|
|
371
|
+
if (getSetting('followUpEnabled') !== 'true') {
|
|
372
|
+
log('Follow-ups disabled, skipping');
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (!isBusinessHours()) {
|
|
377
|
+
log('Outside business hours, skipping');
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// First scan for new dead conversations
|
|
382
|
+
await checkForDeadConversations();
|
|
383
|
+
|
|
384
|
+
// Then process active follow-ups that are due
|
|
385
|
+
const dueFollowUps = stmts.getActiveFollowUps.all();
|
|
386
|
+
log('Due follow-ups', { count: dueFollowUps.length });
|
|
387
|
+
|
|
388
|
+
const maxDefault = parseInt(getSetting('followUpMaxDefault')) || 3;
|
|
389
|
+
|
|
390
|
+
for (const fu of dueFollowUps) {
|
|
391
|
+
if (fu.opted_out) {
|
|
392
|
+
log('Skipping opted-out contact', { phone: fu.phone });
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Safety ceiling — hard max before AI even runs (AI will typically cap lower)
|
|
397
|
+
const maxFollowUps = fu.max_follow_ups || maxDefault;
|
|
398
|
+
if (fu.follow_up_count >= Math.max(maxFollowUps, 3)) {
|
|
399
|
+
stmts.markFollowUpCold.run(fu.phone);
|
|
400
|
+
// Notify Brandon
|
|
401
|
+
const masterPhone = getSetting('masterPhone') || '+15099794110';
|
|
402
|
+
sendIMessage(masterPhone, `Follow-up limit reached for ${fu.contact_name} (${fu.phone}). Marked as cold after ${fu.follow_up_count} attempts. Last message: "${(fu.last_our_message || '').substring(0, 100)}"`);
|
|
403
|
+
log('Marked cold — max follow-ups reached', { phone: fu.phone, count: fu.follow_up_count });
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Get conversation history
|
|
408
|
+
const history = fetchConversationHistory(fu.phone);
|
|
409
|
+
if (!history) {
|
|
410
|
+
log('No history found, skipping', { phone: fu.phone });
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// ── Daily follow-up cap (max 1 per contact per day) ──
|
|
415
|
+
const maxDailyFollowUps = parseInt(getSetting('maxDailyFollowUps')) || 1;
|
|
416
|
+
const recentFollowUps = stmts.countRecentFollowUps.get(fu.phone);
|
|
417
|
+
const followUpCountToday = recentFollowUps?.count || 0;
|
|
418
|
+
if (followUpCountToday >= maxDailyFollowUps) {
|
|
419
|
+
log('RATE LIMIT: daily follow-up cap reached, skipping', { phone: fu.phone, followUpCountToday, maxDailyFollowUps });
|
|
420
|
+
// Push next check to tomorrow
|
|
421
|
+
const tomorrow = new Date(Date.now() + 24 * 3600000).toISOString().replace('T', ' ').replace('Z', '').split('.')[0];
|
|
422
|
+
stmts.incrementFollowUp.run(tomorrow, fu.phone);
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Hard silence check: if contact hasn't replied to our last 2+ messages, mark cold immediately
|
|
427
|
+
const historyLines = history.split('\n').filter(l => l.trim());
|
|
428
|
+
let consecutiveOurs = 0;
|
|
429
|
+
for (let i = historyLines.length - 1; i >= 0; i--) {
|
|
430
|
+
if (/Brandon\/Aiva:/.test(historyLines[i])) consecutiveOurs++;
|
|
431
|
+
else break;
|
|
432
|
+
}
|
|
433
|
+
if (consecutiveOurs >= 2) {
|
|
434
|
+
stmts.markFollowUpCold.run(fu.phone);
|
|
435
|
+
const masterPhone = getSetting('masterPhone') || '+15099794110';
|
|
436
|
+
sendIMessage(masterPhone, `Silence detected for ${fu.contact_name} (${fu.phone}) — ${consecutiveOurs} unanswered messages. Marked cold.`);
|
|
437
|
+
log('Hard silence cap — contact not replying, marked cold', { phone: fu.phone, consecutiveOurs });
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Fetch contact category from contact_rules
|
|
442
|
+
const contactRule = db.prepare('SELECT category FROM contact_rules WHERE phone = ?').get(fu.phone);
|
|
443
|
+
const category = contactRule?.category || 'unknown';
|
|
444
|
+
|
|
445
|
+
// AI analysis
|
|
446
|
+
const analysis = await analyzeConversation(fu.phone, history, fu.contact_name, fu.follow_up_count, category);
|
|
447
|
+
if (!analysis) {
|
|
448
|
+
log('AI analysis failed, skipping', { phone: fu.phone });
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Use AI's max follow-ups instead of flat default
|
|
453
|
+
const aiMax = analysis.maxFollowUpsForThis ?? analysis.maxFollowUpsForThisConvo;
|
|
454
|
+
if (typeof aiMax === 'number' && aiMax >= 0 && fu.follow_up_count >= aiMax) {
|
|
455
|
+
stmts.markFollowUpCold.run(fu.phone);
|
|
456
|
+
log('AI max follow-ups reached', { phone: fu.phone, aiMax, count: fu.follow_up_count, relationship: analysis.relationshipType });
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
log('AI analysis result', { phone: fu.phone, shouldFollowUp: analysis.shouldFollowUp, state: analysis.conversationState, reasoning: analysis.reasoning, category, maxForThis: aiMax, topic: analysis.topic });
|
|
461
|
+
|
|
462
|
+
// ── Topic dedup: if AI suggests same topic as last follow-up, mark cold ──
|
|
463
|
+
if (analysis.shouldFollowUp && analysis.topic && fu.last_follow_up_topic) {
|
|
464
|
+
const newTopic = (analysis.topic || '').toLowerCase().trim();
|
|
465
|
+
const lastTopic = (fu.last_follow_up_topic || '').toLowerCase().trim();
|
|
466
|
+
if (newTopic && lastTopic && (newTopic === lastTopic || newTopic.includes(lastTopic) || lastTopic.includes(newTopic))) {
|
|
467
|
+
stmts.markFollowUpCold.run(fu.phone);
|
|
468
|
+
log('Topic dedup: same topic as last follow-up, marked cold', { phone: fu.phone, topic: newTopic, lastTopic });
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
if (!analysis.shouldFollowUp) {
|
|
474
|
+
// Mark as completed if conversation is closed
|
|
475
|
+
if (analysis.conversationState === 'conversation_closed') {
|
|
476
|
+
stmts.updateFollowUpStatus.run('completed', fu.phone);
|
|
477
|
+
log('Conversation closed, marking completed', { phone: fu.phone });
|
|
478
|
+
} else {
|
|
479
|
+
// Push next check further out
|
|
480
|
+
const nextTime = parseTimeDelay(analysis.nextFollowUpIn || '4 hours');
|
|
481
|
+
if (nextTime) {
|
|
482
|
+
stmts.incrementFollowUp.run(nextTime, fu.phone);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Send the follow-up (with safety retry)
|
|
489
|
+
let message = sanitizeOutbound(analysis.suggestedMessage);
|
|
490
|
+
if (!message) {
|
|
491
|
+
for (let retry = 1; retry <= 3; retry++) {
|
|
492
|
+
log(`Safety filter retry ${retry}/3 for ${fu.phone}`);
|
|
493
|
+
const cleanMsg = await callAI(
|
|
494
|
+
'You are Aiva, Brandon\'s friendly assistant. Write a brief, natural follow-up message. Just the message text. Do NOT mention any software, AI, technical systems, platforms, or tools.',
|
|
495
|
+
`Contact: ${fu.contact_name || fu.phone}\nWrite a clean follow-up message:`
|
|
496
|
+
);
|
|
497
|
+
if (cleanMsg) { message = sanitizeOutbound(cleanMsg); if (message) break; }
|
|
498
|
+
}
|
|
499
|
+
if (!message) continue;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
message = addReintroIfNeeded(fu.phone, message);
|
|
503
|
+
const sent = fu.channel === 'whatsapp'
|
|
504
|
+
? await sendWhatsApp(fu.phone, message)
|
|
505
|
+
: sendIMessage(fu.phone, message);
|
|
506
|
+
|
|
507
|
+
if (sent) {
|
|
508
|
+
// Log it
|
|
509
|
+
stmts.insertLog.run(fu.phone, 'outbound', `[FOLLOW-UP] ${message.substring(0, 80)}`, JSON.stringify({ followUp: true, attempt: fu.follow_up_count + 1 }), 'follow-up', fu.channel || 'imessage', 'aiva');
|
|
510
|
+
|
|
511
|
+
// Calculate next follow-up time
|
|
512
|
+
const nextTime = parseTimeDelay(analysis.nextFollowUpIn || '4 hours');
|
|
513
|
+
stmts.incrementFollowUp.run(nextTime || new Date(Date.now() + 4 * 3600000).toISOString().replace('T', ' ').replace('Z', '').split('.')[0], fu.phone);
|
|
514
|
+
|
|
515
|
+
// Save topic for dedup
|
|
516
|
+
if (analysis.topic) {
|
|
517
|
+
try { db.prepare("UPDATE follow_up_tracker SET last_follow_up_topic = ? WHERE phone = ?").run(analysis.topic, fu.phone); } catch(e) {}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
log('Follow-up sent', { phone: fu.phone, attempt: fu.follow_up_count + 1, nextIn: analysis.nextFollowUpIn, topic: analysis.topic });
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
log('Follow-up processing complete');
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
async function sendCustomFollowUp(phone, message) {
|
|
528
|
+
const tracker = stmts.getFollowUpByPhone.get(phone);
|
|
529
|
+
if (!tracker) return { error: 'No tracker found for this phone' };
|
|
530
|
+
|
|
531
|
+
const channel = tracker.channel || 'imessage';
|
|
532
|
+
message = addReintroIfNeeded(phone, message);
|
|
533
|
+
const sent = channel === 'whatsapp'
|
|
534
|
+
? await sendWhatsApp(phone, message)
|
|
535
|
+
: sendIMessage(phone, message);
|
|
536
|
+
|
|
537
|
+
if (sent) {
|
|
538
|
+
stmts.insertLog.run(phone, 'outbound', `[FOLLOW-UP] ${message.substring(0, 80)}`, JSON.stringify({ followUp: true, custom: true }), 'follow-up', channel, 'aiva');
|
|
539
|
+
const nextTime = new Date(Date.now() + 4 * 3600000).toISOString().replace('T', ' ').replace('Z', '').split('.')[0];
|
|
540
|
+
stmts.incrementFollowUp.run(nextTime, phone);
|
|
541
|
+
return { sent: true };
|
|
542
|
+
}
|
|
543
|
+
return { error: 'Send failed' };
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
async function sendFollowUpNow(phone, customMessage) {
|
|
547
|
+
const tracker = stmts.getFollowUpByPhone.get(phone);
|
|
548
|
+
if (!tracker) return { error: 'No tracker found for this phone' };
|
|
549
|
+
|
|
550
|
+
// If custom message, just send it
|
|
551
|
+
if (customMessage) return sendCustomFollowUp(phone, customMessage);
|
|
552
|
+
|
|
553
|
+
// Otherwise, AI-generate one (force mode since user clicked Send Now)
|
|
554
|
+
const history = fetchConversationHistory(phone, 25);
|
|
555
|
+
if (!history) return { error: 'Could not fetch conversation history' };
|
|
556
|
+
|
|
557
|
+
const contactRule = db.prepare('SELECT category FROM contact_rules WHERE phone = ?').get(phone);
|
|
558
|
+
const category = contactRule?.category || 'unknown';
|
|
559
|
+
let analysis = await analyzeConversation(phone, history, tracker.contact_name || phone, tracker.follow_up_count || 0, category);
|
|
560
|
+
log('Initial analysis result', { hasAnalysis: !!analysis, hasSuggested: !!analysis?.suggestedMessage, shouldFollowUp: analysis?.shouldFollowUp });
|
|
561
|
+
// If AI says don't follow up but user clicked Send Now, force a generic follow-up
|
|
562
|
+
if (!analysis || !analysis.suggestedMessage) {
|
|
563
|
+
log('Force-generating follow-up message (Send Now override)');
|
|
564
|
+
const forceMsg = await callAI(
|
|
565
|
+
'You are Aiva, Brandon\'s friendly assistant. Write a brief, natural follow-up message for this conversation. Just the message text, nothing else. CRITICAL: NEVER mention OpenClaw, AI systems, technical infrastructure, or anything about how you work internally.',
|
|
566
|
+
`Contact: ${tracker.contact_name || phone}\nConversation:\n${history}\n\nWrite a follow-up message:`
|
|
567
|
+
);
|
|
568
|
+
if (forceMsg && !isErrorMessage(forceMsg)) analysis = { suggestedMessage: forceMsg };
|
|
569
|
+
if (!analysis || !analysis.suggestedMessage) return { error: 'AI could not generate a follow-up message' };
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
let message = sanitizeOutbound(analysis.suggestedMessage);
|
|
573
|
+
|
|
574
|
+
// If blocked by safety filter, retry with explicit clean prompt (up to 3 times)
|
|
575
|
+
if (!message) {
|
|
576
|
+
for (let retry = 1; retry <= 3; retry++) {
|
|
577
|
+
log(`Safety filter retry ${retry}/3 — regenerating clean message`);
|
|
578
|
+
const cleanMsg = await callAI(
|
|
579
|
+
'You are Aiva, Brandon\'s friendly assistant. Your previous message was rejected because it referenced internal systems. Write a completely new, natural follow-up message for this conversation. Just the message text. Do NOT mention any software, AI, technical systems, platforms, or tools. Keep it human and conversational.',
|
|
580
|
+
`Contact: ${tracker.contact_name || phone}\nConversation:\n${history}\n\nWrite a clean, natural follow-up message:`
|
|
581
|
+
);
|
|
582
|
+
if (cleanMsg) {
|
|
583
|
+
message = sanitizeOutbound(cleanMsg);
|
|
584
|
+
if (message) break;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
if (!message) return { error: 'Could not generate a clean follow-up message after retries' };
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const channel = tracker.channel || 'imessage';
|
|
591
|
+
message = addReintroIfNeeded(phone, message);
|
|
592
|
+
const sent = channel === 'whatsapp'
|
|
593
|
+
? await sendWhatsApp(phone, message)
|
|
594
|
+
: sendIMessage(phone, message);
|
|
595
|
+
|
|
596
|
+
if (sent) {
|
|
597
|
+
stmts.insertLog.run(phone, 'outbound', `[FOLLOW-UP] ${message.substring(0, 80)}`, JSON.stringify({ followUp: true, sendNow: true, attempt: tracker.follow_up_count + 1 }), 'follow-up', channel, 'aiva');
|
|
598
|
+
const nextTime = new Date(Date.now() + 4 * 3600000).toISOString().replace('T', ' ').replace('Z', '').split('.')[0];
|
|
599
|
+
stmts.incrementFollowUp.run(nextTime, phone);
|
|
600
|
+
log('Send-now follow-up sent', { phone, attempt: tracker.follow_up_count + 1 });
|
|
601
|
+
return { sent: true, message };
|
|
602
|
+
}
|
|
603
|
+
return { error: 'Send failed' };
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
module.exports = { processFollowUps, checkForDeadConversations, analyzeConversation, sendCustomFollowUp, sendFollowUpNow };
|