cdp-edge 2.5.3 → 2.5.5

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.
@@ -63,6 +63,9 @@ import {
63
63
  processWhatsAppWebhook,
64
64
  verifyHmac,
65
65
  } from './modules/dispatch/whatsapp';
66
+ import {
67
+ pushLeadToCrm,
68
+ } from './modules/dispatch/crm';
66
69
 
67
70
  // ── ML — LTV + A/B Testing ────────────────────────────────────────────────────
68
71
  import {
@@ -239,6 +242,9 @@ export default {
239
242
  WA_NOTIFY_NUMBER: env.WA_NOTIFY_NUMBER ? 'set' : 'not set (optional - only for auto-reply)',
240
243
  TIKTOK_ACCESS_TOKEN: env.TIKTOK_ACCESS_TOKEN ? 'set' : 'not set (optional)',
241
244
  CALLMEBOT_PHONE: env.CALLMEBOT_PHONE ? 'set' : 'not set (optional)',
245
+ EVO_CRM_BASE_URL: env.EVO_CRM_BASE_URL ? 'set' : 'not set (optional - EVO CRM)',
246
+ EVO_CRM_CLIENT_ID: env.EVO_CRM_CLIENT_ID ? 'set' : 'not set (optional - EVO CRM)',
247
+ EVO_CRM_INBOX_ID: env.EVO_CRM_INBOX_ID ? 'set' : 'not set (optional - EVO CRM)',
242
248
  };
243
249
 
244
250
  const hasMissing =
@@ -371,9 +377,12 @@ export default {
371
377
  'utmSource','utmMedium','utmCampaign','utmContent','utmTerm',
372
378
  'fbclid','ttclid','gclid','transactionId','productName','currency'];
373
379
 
374
- const { eventName, behavioral_data, ...payload } = body as { eventName?: string; behavioral_data?: BehavioralData; [key: string]: any };
380
+ const { eventName: _bodyEventName, behavioral_data, ...payload } = body as { eventName?: string; behavioral_data?: BehavioralData; [key: string]: any };
375
381
  const trackPayload: TrackPayload = payload;
376
382
 
383
+ // Aceita eventName (camelCase) ou event_name (snake_case — formato cdpTrack.js SDK)
384
+ const eventName = _bodyEventName || (payload.event_name as string | undefined);
385
+
377
386
  // ── Extrair click_ids e utms de sub-objetos (formato cdpTrack.js) ────────
378
387
  // cdpTrack.js envia fbp/fbc/fbclid dentro de click_ids{} e UTMs dentro de utms{}
379
388
  // O Worker trabalha com campos no nível raiz — esta extração resolve o mismatch.
@@ -383,8 +392,11 @@ export default {
383
392
  if (!trackPayload.fbc && c.fbc) trackPayload.fbc = c.fbc;
384
393
  if (!trackPayload.fbclid && c.fbclid) trackPayload.fbclid = c.fbclid;
385
394
  if (!trackPayload.gclid && c.gclid) trackPayload.gclid = c.gclid;
395
+ if (!trackPayload.wbraid && c.wbraid) trackPayload.wbraid = c.wbraid;
396
+ if (!trackPayload.gbraid && c.gbraid) trackPayload.gbraid = c.gbraid;
386
397
  if (!trackPayload.ttclid && c.ttclid) trackPayload.ttclid = c.ttclid;
387
- if (!trackPayload.ttp && c.ttp) trackPayload.ttp = c.ttp; // TikTok Pixel cookie
398
+ if (!trackPayload.ttp && c.ttp) trackPayload.ttp = c.ttp;
399
+ if (!trackPayload.msclkid && c.msclkid) trackPayload.msclkid = c.msclkid;
388
400
  }
389
401
  if (payload.utms && typeof payload.utms === 'object') {
390
402
  const u = payload.utms as Record<string, string>;
@@ -395,6 +407,12 @@ export default {
395
407
  if (!trackPayload.utmTerm && u.utm_term) trackPayload.utmTerm = u.utm_term;
396
408
  }
397
409
 
410
+ // ── Normalizar campos snake_case → camelCase (formato cdpTrack.js SDK) ──
411
+ if (!trackPayload.userId && payload.user_id) trackPayload.userId = payload.user_id;
412
+ if (!trackPayload.eventId && payload.event_id) trackPayload.eventId = payload.event_id;
413
+ if (!trackPayload.pageUrl && payload.page_url) trackPayload.pageUrl = payload.page_url;
414
+ if (!trackPayload.sessionId && payload.session_id) trackPayload.sessionId = payload.session_id;
415
+
398
416
  // ── Validação de eventName ────────────────────────────────────────
399
417
  if (!eventName) {
400
418
  return new Response(JSON.stringify({ error: 'eventName é obrigatório' }), { status: 400, headers });
@@ -750,6 +768,21 @@ export default {
750
768
  ctx.waitUntil(resolveDeviceGraph(env.DB, payload.userId, payload.email, payload.phone));
751
769
  }
752
770
 
771
+ // Deduplicação server-side — INSERT OR IGNORE retorna changes=0 para duplicatas
772
+ if (env.DB && payload.eventId) {
773
+ try {
774
+ const dedup = await env.DB.prepare(
775
+ `INSERT OR IGNORE INTO events (event_id, event_name, user_id, created_at)
776
+ VALUES (?, ?, ?, datetime('now'))`
777
+ ).bind(payload.eventId, eventName, payload.userId || null).run();
778
+ if (dedup.meta.changes === 0) {
779
+ return new Response(JSON.stringify({ ok: true, skipped: 'duplicate' }), { status: 200, headers });
780
+ }
781
+ } catch {
782
+ // Tabela ausente ou erro de DB — não bloqueia o pipeline
783
+ }
784
+ }
785
+
753
786
  // R2 Audit Log — background
754
787
  ctx.waitUntil(writeAuditLog(env, eventName, payload, geoData));
755
788
 
@@ -766,6 +799,39 @@ export default {
766
799
  : []),
767
800
  ]);
768
801
 
802
+ // ── EVO CRM — cria contato + conversa + nota interna ─────────────────
803
+ // Silencioso se EVO_CRM_BASE_URL / EVO_CRM_CLIENT_ID não estiverem configurados.
804
+ const CRM_EVENTS = ['Lead', 'Contact', 'CompleteRegistration'];
805
+ if (CRM_EVENTS.includes(eventName) && trackPayload.phone) {
806
+ ctx.waitUntil(
807
+ pushLeadToCrm(env, {
808
+ phone: trackPayload.phone,
809
+ name: [trackPayload.firstName, trackPayload.lastName].filter(Boolean).join(' ') || null,
810
+ email: trackPayload.email || null,
811
+ fbclid: trackPayload.fbclid || null,
812
+ fbc: trackPayload.fbc || null,
813
+ fbp: trackPayload.fbp || null,
814
+ utmSource: trackPayload.utmSource || null,
815
+ utmMedium: trackPayload.utmMedium || null,
816
+ utmCampaign: trackPayload.utmCampaign || null,
817
+ utmContent: trackPayload.utmContent || null,
818
+ utmTerm: trackPayload.utmTerm || null,
819
+ pageUrl: trackPayload.pageUrl || null,
820
+ formName: trackPayload.contentName || trackPayload.productName || eventName,
821
+ eventName,
822
+ intentScore: typeof trackPayload.intent_score === 'number'
823
+ ? trackPayload.intent_score
824
+ : typeof trackPayload.intentScoreNum === 'number'
825
+ ? trackPayload.intentScoreNum
826
+ : null,
827
+ ltvClass: trackPayload.ltvClass || null,
828
+ funnelStage: trackPayload.funnel_stage || trackPayload.funnelDepth || null,
829
+ value: trackPayload.value ?? null,
830
+ currency: trackPayload.currency || null,
831
+ })
832
+ );
833
+ }
834
+
769
835
  // Automação de mensagens
770
836
  const AUTOMATION_EVENTS = ['Lead', 'Purchase', 'InitiateCheckout'];
771
837
  if (AUTOMATION_EVENTS.includes(eventName) && env.DB) {
@@ -115,6 +115,7 @@ export async function upsertProfile(env: Env, eventName: string, payload: TrackP
115
115
  const {
116
116
  userId, email, phone,
117
117
  fbp, fbc, ttp, gclid, ttclid, gaClientId,
118
+ wbraid, gbraid, msclkid,
118
119
  city, state, country,
119
120
  engagementScore, userScore,
120
121
  } = payload;
@@ -131,8 +132,9 @@ export async function upsertProfile(env: Env, eventName: string, payload: TrackP
131
132
  await env.DB.prepare(`
132
133
  INSERT INTO user_profiles
133
134
  (user_id, email, phone, fbp, fbc, ttp, gclid, ttclid, ga_client_id,
135
+ wbraid, gbraid, msclkid,
134
136
  city, state, country, score, cohort_label, created_at, updated_at)
135
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,datetime('now'),datetime('now'))
137
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,datetime('now'),datetime('now'))
136
138
  ON CONFLICT(user_id) DO UPDATE SET
137
139
  email = COALESCE(excluded.email, user_profiles.email),
138
140
  phone = COALESCE(excluded.phone, user_profiles.phone),
@@ -142,6 +144,9 @@ export async function upsertProfile(env: Env, eventName: string, payload: TrackP
142
144
  gclid = COALESCE(excluded.gclid, user_profiles.gclid),
143
145
  ttclid = COALESCE(excluded.ttclid, user_profiles.ttclid),
144
146
  ga_client_id = COALESCE(excluded.ga_client_id, user_profiles.ga_client_id),
147
+ wbraid = COALESCE(excluded.wbraid, user_profiles.wbraid),
148
+ gbraid = COALESCE(excluded.gbraid, user_profiles.gbraid),
149
+ msclkid = COALESCE(excluded.msclkid, user_profiles.msclkid),
145
150
  city = COALESCE(excluded.city, user_profiles.city),
146
151
  state = COALESCE(excluded.state, user_profiles.state),
147
152
  country = COALESCE(excluded.country, user_profiles.country),
@@ -158,6 +163,9 @@ export async function upsertProfile(env: Env, eventName: string, payload: TrackP
158
163
  gclid || null,
159
164
  ttclid || null,
160
165
  gaClientId || null,
166
+ wbraid || null,
167
+ gbraid || null,
168
+ msclkid || null,
161
169
  city || null,
162
170
  state || null,
163
171
  (country || (request as any).cf?.country || null),
@@ -0,0 +1,382 @@
1
+ /**
2
+ * CDP Edge — EVO CRM Premium Integration
3
+ * Fluxo: Meta CAPI → Worker → Contato + Conversa + Nota interna no EVO CRM
4
+ *
5
+ * Secrets necessários (wrangler secret put):
6
+ * EVO_CRM_BASE_URL → URL base do EVO CRM (ex: https://api-evocrm.suaempresa.com)
7
+ * EVO_CRM_CLIENT_ID → OAuth client_id (Doorkeeper app no CRM)
8
+ * EVO_CRM_CLIENT_SECRET → OAuth client_secret
9
+ * EVO_CRM_INBOX_ID → ID do inbox onde as conversas serão criadas
10
+ *
11
+ * Secrets opcionais (defaults BR):
12
+ * EVO_CRM_DEFAULT_COUNTRY → Dial code para números locais sem +cc (default "55")
13
+ * EVO_CRM_LOCALE → "pt-BR" | "en-US" | "es-ES" (default "pt-BR")
14
+ *
15
+ * Uso (qualquer origem de lead):
16
+ * await pushLeadToCrm(env, {
17
+ * phone: '5511999990000',
18
+ * name: 'João Silva',
19
+ * email: 'joao@exemplo.com',
20
+ * utmSource: 'facebook',
21
+ * utmCampaign: 'campanha-x',
22
+ * fbclid: 'AbC123...',
23
+ * intentScore: 85,
24
+ * ltvClass: 'high',
25
+ * pageUrl: 'https://exemplo.com/lp',
26
+ * });
27
+ */
28
+
29
+ import { Env } from '../../types.js';
30
+
31
+ // ── Tipos públicos ────────────────────────────────────────────────────────────
32
+
33
+ export interface CrmLeadData {
34
+ // Identificação
35
+ phone: string;
36
+ name?: string | null;
37
+ email?: string | null;
38
+
39
+ // Meta CAPI
40
+ fbclid?: string | null;
41
+ fbc?: string | null;
42
+ fbp?: string | null;
43
+ ctwaClid?: string | null;
44
+ adId?: string | null;
45
+ messageBody?: string | null;
46
+ headline?: string | null;
47
+
48
+ // UTMs
49
+ utmSource?: string | null;
50
+ utmMedium?: string | null;
51
+ utmCampaign?: string | null;
52
+ utmContent?: string | null;
53
+ utmTerm?: string | null;
54
+
55
+ // Contexto
56
+ eventName?: string | null;
57
+ pageUrl?: string | null;
58
+ formName?: string | null;
59
+
60
+ // Scoring (Quantum Tracking)
61
+ intentScore?: number | null;
62
+ ltvClass?: string | null;
63
+ funnelStage?: string | null;
64
+ botScore?: number | null;
65
+
66
+ // Monetário
67
+ value?: number | null;
68
+ currency?: string | null;
69
+
70
+ // Atributos livres adicionais
71
+ attributes?: Record<string, string>;
72
+ }
73
+
74
+ /** @deprecated Use CrmLeadData + pushLeadToCrm */
75
+ export interface CTWALeadData {
76
+ phone: string;
77
+ name?: string | null;
78
+ messageBody?: string | null;
79
+ ctwaClid?: string | null;
80
+ adId?: string | null;
81
+ sourceUrl?: string | null;
82
+ headline?: string | null;
83
+ wamid?: string | null;
84
+ }
85
+
86
+ /** @deprecated Use CrmLeadData + pushLeadToCrm */
87
+ export interface FormLeadData {
88
+ phone: string;
89
+ name?: string | null;
90
+ email?: string | null;
91
+ formName?: string | null;
92
+ utmSource?: string | null;
93
+ utmCampaign?: string | null;
94
+ pageUrl?: string | null;
95
+ }
96
+
97
+ // ── Helpers internos ──────────────────────────────────────────────────────────
98
+
99
+ function isCrmConfigured(env: Env): boolean {
100
+ return !!(env.EVO_CRM_BASE_URL && env.EVO_CRM_CLIENT_ID && env.EVO_CRM_CLIENT_SECRET);
101
+ }
102
+
103
+ function normalizePhone(phone: string, defaultCountryCode = '55'): string {
104
+ const trimmed = phone.trim();
105
+ const cc = (defaultCountryCode || '55').replace(/\D/g, '') || '55';
106
+
107
+ // Already in E.164 (starts with +): só limpa e re-prefixa
108
+ if (trimmed.startsWith('+')) return '+' + trimmed.replace(/\D/g, '');
109
+
110
+ const digits = trimmed.replace(/\D/g, '');
111
+
112
+ // Comportamento BR canônico (preservado): 55 + 10 ou 11 dígitos = válido
113
+ if (cc === '55' && digits.startsWith('55') && (digits.length === 12 || digits.length === 13)) {
114
+ return `+${digits}`;
115
+ }
116
+ if (cc === '55' && (digits.length === 10 || digits.length === 11)) {
117
+ return `+55${digits}`;
118
+ }
119
+
120
+ // Genérico: já tem o country code
121
+ if (digits.startsWith(cc) && digits.length > cc.length + 6) return `+${digits}`;
122
+
123
+ // Genérico: número local → prepend country code
124
+ if (digits.length >= 7 && digits.length <= 11) return `+${cc}${digits}`;
125
+
126
+ // Fallback: assume que já tem algum cc
127
+ return `+${digits}`;
128
+ }
129
+
130
+ // ── Localização da nota interna ───────────────────────────────────────────────
131
+
132
+ type Locale = 'pt-BR' | 'en-US' | 'es-ES';
133
+
134
+ const NOTE_LABELS: Record<Locale, Record<string, string>> = {
135
+ 'pt-BR': {
136
+ title: '📊 *Novo Lead*',
137
+ name: 'Nome',
138
+ source: 'Origem',
139
+ campaign: 'Campanha',
140
+ form: 'Formulário',
141
+ ad: 'Anúncio',
142
+ message: 'Mensagem',
143
+ score: 'Score',
144
+ ltv: 'LTV',
145
+ },
146
+ 'en-US': {
147
+ title: '📊 *New Lead*',
148
+ name: 'Name',
149
+ source: 'Source',
150
+ campaign: 'Campaign',
151
+ form: 'Form',
152
+ ad: 'Ad',
153
+ message: 'Message',
154
+ score: 'Score',
155
+ ltv: 'LTV',
156
+ },
157
+ 'es-ES': {
158
+ title: '📊 *Nuevo Lead*',
159
+ name: 'Nombre',
160
+ source: 'Origen',
161
+ campaign: 'Campaña',
162
+ form: 'Formulario',
163
+ ad: 'Anuncio',
164
+ message: 'Mensaje',
165
+ score: 'Score',
166
+ ltv: 'LTV',
167
+ },
168
+ };
169
+
170
+ function resolveLocale(input?: string | null): Locale {
171
+ if (input && input in NOTE_LABELS) return input as Locale;
172
+ return 'pt-BR';
173
+ }
174
+
175
+ async function getAccessToken(env: Env): Promise<string | null> {
176
+ try {
177
+ const res = await fetch(`${env.EVO_CRM_BASE_URL}/oauth/token`, {
178
+ method: 'POST',
179
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
180
+ body: new URLSearchParams({
181
+ grant_type: 'client_credentials',
182
+ client_id: env.EVO_CRM_CLIENT_ID!,
183
+ client_secret: env.EVO_CRM_CLIENT_SECRET!,
184
+ }),
185
+ });
186
+ if (!res.ok) return null;
187
+ const data: any = await res.json();
188
+ return data.access_token ?? null;
189
+ } catch {
190
+ return null;
191
+ }
192
+ }
193
+
194
+ /** Nota interna para o agente: apenas o essencial para atender o lead. */
195
+ function buildAgentNote(data: CrmLeadData, locale: Locale = 'pt-BR'): string {
196
+ const L = NOTE_LABELS[locale];
197
+ const lines: string[] = [L.title];
198
+
199
+ const name = data.name || data.phone;
200
+ lines.push(`• ${L.name}: ${name}`);
201
+
202
+ const source = [data.utmSource, data.utmMedium].filter(Boolean).join(' / ');
203
+ if (source) lines.push(`• ${L.source}: ${source}`);
204
+
205
+ if (data.utmCampaign) lines.push(`• ${L.campaign}: ${data.utmCampaign}`);
206
+ if (data.formName && data.formName !== data.eventName) lines.push(`• ${L.form}: ${data.formName}`);
207
+ if (data.headline) lines.push(`• ${L.ad}: ${data.headline}`);
208
+ if (data.messageBody) lines.push(`• ${L.message}: "${data.messageBody}"`);
209
+
210
+ if (data.intentScore != null) {
211
+ const score = data.intentScore > 1 ? data.intentScore : Math.round(data.intentScore * 100);
212
+ const stage = data.funnelStage ? ` · ${data.funnelStage}` : '';
213
+ lines.push(`• ${L.score}: ${score}${stage}`);
214
+ }
215
+ if (data.ltvClass) lines.push(`• ${L.ltv}: ${data.ltvClass}`);
216
+
217
+ return lines.join('\n');
218
+ }
219
+
220
+ /** Monta additional_attributes para o contato no CRM. */
221
+ function buildContactAttributes(data: CrmLeadData): Record<string, string> {
222
+ const attrs: Record<string, string> = {};
223
+
224
+ if (data.utmSource) attrs['utm_source'] = data.utmSource;
225
+ if (data.utmMedium) attrs['utm_medium'] = data.utmMedium;
226
+ if (data.utmCampaign) attrs['utm_campaign'] = data.utmCampaign;
227
+ if (data.utmContent) attrs['utm_content'] = data.utmContent;
228
+ if (data.utmTerm) attrs['utm_term'] = data.utmTerm;
229
+ if (data.pageUrl) attrs['pagina'] = data.pageUrl;
230
+ if (data.formName) attrs['formulario'] = data.formName;
231
+ if (data.fbclid) attrs['fbclid'] = data.fbclid;
232
+ if (data.fbc) attrs['fbc'] = data.fbc;
233
+ if (data.fbp) attrs['fbp'] = data.fbp;
234
+ if (data.ctwaClid) attrs['ctwa_clid'] = data.ctwaClid;
235
+ if (data.adId) attrs['ad_id'] = data.adId;
236
+ if (data.messageBody) attrs['mensagem'] = data.messageBody;
237
+ if (data.headline) attrs['anuncio'] = data.headline;
238
+ if (data.ltvClass) attrs['ltv_class'] = data.ltvClass;
239
+ if (data.funnelStage) attrs['funil'] = data.funnelStage;
240
+ if (data.intentScore != null) attrs['intencao'] = String(data.intentScore);
241
+ if (data.eventName) attrs['evento'] = data.eventName;
242
+
243
+ // Atributos livres
244
+ if (data.attributes) Object.assign(attrs, data.attributes);
245
+
246
+ return attrs;
247
+ }
248
+
249
+ // ── API principal (recomendada) ───────────────────────────────────────────────
250
+
251
+ /**
252
+ * Cria contato + conversa aberta + nota interna no EVO CRM.
253
+ * Silencioso se os secrets não estiverem configurados.
254
+ * Retorna o ID do contato criado ou null em caso de erro/duplicata.
255
+ */
256
+ export async function pushLeadToCrm(env: Env, data: CrmLeadData): Promise<string | null> {
257
+ if (!isCrmConfigured(env)) return null;
258
+
259
+ const token = await getAccessToken(env);
260
+ if (!token) {
261
+ console.warn('[EVO CRM] Failed to get access token');
262
+ return null;
263
+ }
264
+
265
+ const locale = resolveLocale(env.EVO_CRM_LOCALE);
266
+ const defaultCountry = env.EVO_CRM_DEFAULT_COUNTRY || '55';
267
+
268
+ const attrs = buildContactAttributes(data);
269
+ const contactPayload: Record<string, any> = {
270
+ name: data.name || data.phone,
271
+ phone_number: normalizePhone(data.phone, defaultCountry),
272
+ };
273
+ if (data.email) contactPayload.email = data.email;
274
+ if (Object.keys(attrs).length) contactPayload.additional_attributes = attrs;
275
+
276
+ // 1. Criar contato
277
+ let contactId: string | null = null;
278
+ try {
279
+ const res = await fetch(`${env.EVO_CRM_BASE_URL}/api/v1/contacts`, {
280
+ method: 'POST',
281
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
282
+ body: JSON.stringify(contactPayload),
283
+ });
284
+ if (!res.ok) {
285
+ const body = await res.text();
286
+ console.warn('[EVO CRM] createContact non-ok:', res.status, body.slice(0, 200));
287
+ // 422 = contato já existe — tentar extrair o ID do payload de erro
288
+ if (res.status === 422) {
289
+ try {
290
+ const errJson: any = JSON.parse(body);
291
+ const rawId = errJson?.id ?? errJson?.data?.id ?? errJson?.contact?.id ?? errJson?.data?.contact?.id;
292
+ if (rawId != null) contactId = String(rawId);
293
+ } catch { /* body não é JSON */ }
294
+ }
295
+ if (!contactId) return null;
296
+ } else {
297
+ const result: any = await res.json();
298
+ // EVO CRM pode retornar { id } direto ou { data: { id } } ou { data: { contact: { id } } }
299
+ const rawId = result?.id ?? result?.data?.id ?? result?.data?.contact?.id;
300
+ contactId = rawId != null ? String(rawId) : null;
301
+ }
302
+ } catch (err: any) {
303
+ console.warn('[EVO CRM] createContact failed:', err?.message || String(err));
304
+ return null;
305
+ }
306
+
307
+ if (!contactId || !env.EVO_CRM_INBOX_ID) return contactId;
308
+
309
+ // 2. Criar conversa
310
+ let conversationId: string | null = null;
311
+ try {
312
+ const res = await fetch(`${env.EVO_CRM_BASE_URL}/api/v1/conversations`, {
313
+ method: 'POST',
314
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
315
+ body: JSON.stringify({
316
+ inbox_id: env.EVO_CRM_INBOX_ID,
317
+ contact_id: contactId,
318
+ additional_attributes: {},
319
+ }),
320
+ });
321
+ if (!res.ok) {
322
+ const err = await res.text();
323
+ console.warn('[EVO CRM] createConversation non-ok:', res.status, err.slice(0, 200));
324
+ } else {
325
+ const result: any = await res.json();
326
+ const rawConvId = result?.id ?? result?.data?.id;
327
+ conversationId = rawConvId != null ? String(rawConvId) : null;
328
+ }
329
+ } catch (err: any) {
330
+ console.warn('[EVO CRM] createConversation failed:', err?.message || String(err));
331
+ }
332
+
333
+ // 3. Criar nota interna com contexto do lead
334
+ if (conversationId) {
335
+ try {
336
+ await fetch(`${env.EVO_CRM_BASE_URL}/api/v1/conversations/${conversationId}/messages`, {
337
+ method: 'POST',
338
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
339
+ body: JSON.stringify({
340
+ content: buildAgentNote(data, locale),
341
+ message_type: 'activity',
342
+ private: true,
343
+ }),
344
+ });
345
+ } catch {
346
+ // nota interna é best-effort
347
+ }
348
+ }
349
+
350
+ return contactId;
351
+ }
352
+
353
+ // ── Compat: CTWA (WhatsApp click-to-chat) ────────────────────────────────────
354
+
355
+ export async function notifyEvolutionCTWA(env: Env, data: CTWALeadData): Promise<void> {
356
+ await pushLeadToCrm(env, {
357
+ phone: data.phone,
358
+ name: data.name,
359
+ messageBody: data.messageBody,
360
+ ctwaClid: data.ctwaClid,
361
+ adId: data.adId,
362
+ pageUrl: data.sourceUrl,
363
+ headline: data.headline,
364
+ utmSource: 'whatsapp',
365
+ eventName: 'CTWA',
366
+ });
367
+ }
368
+
369
+ // ── Compat: Formulário ────────────────────────────────────────────────────────
370
+
371
+ export async function notifyEvolutionForm(env: Env, data: FormLeadData): Promise<void> {
372
+ await pushLeadToCrm(env, {
373
+ phone: data.phone,
374
+ name: data.name,
375
+ email: data.email,
376
+ formName: data.formName,
377
+ utmSource: data.utmSource,
378
+ utmCampaign: data.utmCampaign,
379
+ pageUrl: data.pageUrl,
380
+ eventName: 'Lead',
381
+ });
382
+ }
@@ -7,6 +7,7 @@ import { sha256, normalizePhone } from '../utils.js';
7
7
  import { saveLead, logApiFailure } from '../db.js';
8
8
  import { Env, TrackPayload } from '../../types.js';
9
9
  import { ExecutionContext } from '@cloudflare/workers-types';
10
+ import { notifyEvolutionCTWA } from './crm.js';
10
11
 
11
12
  // ── Tipos ───────────────────────────────────────────────────────────────────────
12
13
  interface WhatsAppOptions {
@@ -248,6 +249,22 @@ export async function processWhatsAppWebhook(env: Env, body: any, request: Reque
248
249
  }, request, 'whatsapp')
249
250
  );
250
251
 
252
+ // ── Notifica o Evolution CRM sobre o novo lead CTWA ──────────────────────
253
+ // Cria/atualiza o contato no Evolution e abre a conversa para o vendedor.
254
+ // Silencioso se EVOLUTION_BASE_URL / EVOLUTION_INSTANCE / EVOLUTION_API_KEY
255
+ // não estiverem configurados.
256
+ ctx.waitUntil(
257
+ notifyEvolutionCTWA(env, {
258
+ phone: phoneNorm,
259
+ messageBody: messageBody || undefined,
260
+ ctwaClid,
261
+ adId,
262
+ sourceUrl,
263
+ headline,
264
+ wamid,
265
+ })
266
+ );
267
+
251
268
  results.push({ ok: true, phone: phoneNorm.slice(0, 4) + '****', ctwa_clid: ctwaClid ? 'present' : 'absent', event_id: eventId });
252
269
  }
253
270
 
@@ -79,6 +79,22 @@ CREATE INDEX IF NOT EXISTS idx_profiles_fbp ON user_profiles(fbp);
79
79
  CREATE INDEX IF NOT EXISTS idx_profiles_msclkid ON user_profiles(msclkid);
80
80
  CREATE INDEX IF NOT EXISTS idx_profiles_li_fat ON user_profiles(li_fat_id);
81
81
 
82
+ -- ── Tabela de Eventos (Deduplicação + Label ML) ──────────────────────────────
83
+ -- Registra cada evento processado com sucesso.
84
+ -- Duplo uso: (1) deduplicação server-side via event_id UNIQUE;
85
+ -- (2) label de LTV para treinamento ML (lead comprou depois?)
86
+ CREATE TABLE IF NOT EXISTS events (
87
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
88
+ event_id TEXT UNIQUE, -- deduplicação: INSERT OR IGNORE descarta duplicatas
89
+ event_name TEXT NOT NULL,
90
+ user_id TEXT,
91
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
92
+ );
93
+
94
+ CREATE INDEX IF NOT EXISTS idx_events_user_id ON events(user_id);
95
+ CREATE INDEX IF NOT EXISTS idx_events_event_name ON events(event_name);
96
+ CREATE INDEX IF NOT EXISTS idx_events_created ON events(created_at);
97
+
82
98
  -- ── Tabela de LOG de Webhooks Offline ─────────────────────────────────────────
83
99
  CREATE TABLE IF NOT EXISTS webhook_events (
84
100
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -64,6 +64,14 @@ export interface Env {
64
64
  RESEND_API_KEY?: string;
65
65
  RESEND_FROM_EMAIL?: string;
66
66
  CALLMEBOT_APIKEY?: string;
67
+
68
+ // EVO CRM — OAuth2 client_credentials
69
+ EVO_CRM_BASE_URL?: string; // URL base do EVO CRM (ex: https://api-evocrm.suaempresa.com)
70
+ EVO_CRM_CLIENT_ID?: string; // OAuth client_id (Doorkeeper app)
71
+ EVO_CRM_CLIENT_SECRET?: string; // OAuth client_secret
72
+ EVO_CRM_INBOX_ID?: string; // ID do inbox onde as conversas serão criadas
73
+ EVO_CRM_DEFAULT_COUNTRY?: string; // Country dial code para phones locais (default "55" = Brasil)
74
+ EVO_CRM_LOCALE?: string; // Locale da nota interna: "pt-BR" | "en-US" | "es-ES" (default "pt-BR")
67
75
  }
68
76
 
69
77
  // ── Event Payload Types ───────────────────────────────────────────────────────
@@ -85,8 +93,17 @@ export interface TrackPayload {
85
93
  // Identifiers
86
94
  fbp?: string | null;
87
95
  fbc?: string | null;
96
+ fbclid?: string | null;
88
97
  ttp?: string | null;
98
+ gclid?: string | null;
99
+ wbraid?: string | null;
100
+ gbraid?: string | null;
101
+ ttclid?: string | null;
102
+ rclid?: string | null;
103
+ msclkid?: string | null;
104
+ li_fat_id?: string | null;
89
105
  gaClientId?: string | null;
106
+ sessionId?: string | null;
90
107
 
91
108
  // Parameters
92
109
  value?: number | null;
@@ -16,13 +16,13 @@ workers_dev = true
16
16
  # pattern = "*.SEU_DOMINIO/track*"
17
17
  # zone_name = "SEU_DOMINIO"
18
18
 
19
- [[routes]]
20
- pattern = "SEU_DOMINIO/track*"
21
- zone_name = "SEU_DOMINIO"
22
-
23
- [[routes]]
24
- pattern = "*.SEU_DOMINIO/track*"
25
- zone_name = "SEU_DOMINIO"
19
+ # [[routes]]
20
+ # pattern = "SEU_DOMINIO/track*"
21
+ # zone_name = "SEU_DOMINIO"
22
+ #
23
+ # [[routes]]
24
+ # pattern = "*.SEU_DOMINIO/track*"
25
+ # zone_name = "SEU_DOMINIO"
26
26
 
27
27
  # ── Variáveis públicas (não são segredos) ─────────────────────────────────────
28
28
  [vars]
@@ -134,3 +134,7 @@ head_sampling_rate = 1
134
134
  # wrangler secret put LINKEDIN_AD_ACCOUNT_ID ← ID da conta de anúncios LinkedIn
135
135
  # wrangler secret put SPOTIFY_ACCESS_TOKEN ← Bearer token Spotify Advertising API
136
136
  # wrangler secret put SPOTIFY_AD_ACCOUNT_ID ← ID da conta de anúncios Spotify Ads
137
+ # wrangler secret put EVO_CRM_BASE_URL ← URL base do EVO CRM (ex: https://api-evocrm.suaempresa.com)
138
+ # wrangler secret put EVO_CRM_CLIENT_ID ← OAuth client_id (Doorkeeper app no CRM)
139
+ # wrangler secret put EVO_CRM_CLIENT_SECRET ← OAuth client_secret
140
+ # wrangler secret put EVO_CRM_INBOX_ID ← ID do inbox onde as conversas serão criadas