cdp-edge 2.5.4 → 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.
@@ -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
 
@@ -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 ───────────────────────────────────────────────────────
@@ -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