cdp-edge 2.5.4 → 2.5.6
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 +7 -2
- package/contracts/agent-versions.json +12 -9
- package/extracted-skill/tracking-events-generator/agents/evo-crm-agent.md +253 -0
- package/extracted-skill/tracking-events-generator/agents/ltv-predictor-agent.md +19 -0
- package/extracted-skill/tracking-events-generator/agents/master-orchestrator.md +308 -11
- package/extracted-skill/tracking-events-generator/agents/ml-clustering-agent.md +20 -0
- package/package.json +1 -1
- package/server-edge-tracker/INSTALAR.md +2 -2
- package/server-edge-tracker/index.ts +39 -0
- package/server-edge-tracker/modules/dispatch/crm.ts +382 -0
- package/server-edge-tracker/modules/dispatch/whatsapp.ts +17 -0
- package/server-edge-tracker/modules/ml/fraud.ts +15 -3
- package/server-edge-tracker/types.ts +14 -0
- package/server-edge-tracker/wrangler.toml +11 -7
- package/extracted-skill/tracking-events-generator/agents/crm-integration-agent.md +0 -1459
|
@@ -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
|
|
|
@@ -55,8 +55,11 @@ export async function checkFraudGate(env: Env, request: Request, payload: TrackP
|
|
|
55
55
|
result.score += 40; result.reasons.push('suspicious_user_agent');
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
// 4. Datacenter IP
|
|
58
|
+
// 4. Datacenter IP — kill-switch opcional (FRAUD_BLOCK_DATACENTERS=1 dropa direto)
|
|
59
59
|
if (ip && DATACENTER_PATTERNS.test(asn)) {
|
|
60
|
+
if (env.FRAUD_BLOCK_DATACENTERS === '1') {
|
|
61
|
+
return { allowed: false, score: 100, reasons: ['datacenter_ip_killswitch'], action: 'dropped' };
|
|
62
|
+
}
|
|
60
63
|
result.score += 35; result.reasons.push('datacenter_ip');
|
|
61
64
|
}
|
|
62
65
|
|
|
@@ -65,6 +68,14 @@ export async function checkFraudGate(env: Env, request: Request, payload: TrackP
|
|
|
65
68
|
result.score += 20; result.reasons.push('no_accept_language');
|
|
66
69
|
}
|
|
67
70
|
|
|
71
|
+
// 5b. Geo-fence — country fora de ALLOWED_COUNTRIES (vazio = sem geo-fence)
|
|
72
|
+
const allowed = (env.ALLOWED_COUNTRIES || '').toUpperCase().split(',').map(s => s.trim()).filter(Boolean);
|
|
73
|
+
if (allowed.length > 0 && country && !allowed.includes(country)) {
|
|
74
|
+
const penalty = parseInt(env.FRAUD_GEO_PENALTY || '50');
|
|
75
|
+
result.score += penalty;
|
|
76
|
+
result.reasons.push(`country_blocked:${country}`);
|
|
77
|
+
}
|
|
78
|
+
|
|
68
79
|
// 6. Velocity check via KV
|
|
69
80
|
if (env.GEO_CACHE && ip) {
|
|
70
81
|
const velKey1h = `fraud_velocity:${ip}:h`;
|
|
@@ -79,8 +90,9 @@ export async function checkFraudGate(env: Env, request: Request, payload: TrackP
|
|
|
79
90
|
|
|
80
91
|
result.score = Math.min(100, result.score);
|
|
81
92
|
|
|
82
|
-
// 8. Decisão final
|
|
83
|
-
|
|
93
|
+
// 8. Decisão final — threshold configurável via FRAUD_DROP_THRESHOLD (default 50)
|
|
94
|
+
const dropThreshold = parseInt(env.FRAUD_DROP_THRESHOLD || '50');
|
|
95
|
+
if (result.score >= dropThreshold) {
|
|
84
96
|
result.allowed = false;
|
|
85
97
|
result.action = 'dropped';
|
|
86
98
|
} else if (result.score >= 40) {
|
|
@@ -64,6 +64,20 @@ 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")
|
|
75
|
+
|
|
76
|
+
// Fraud Gate — defaults parametrizáveis por projeto (vazio = comportamento legado)
|
|
77
|
+
ALLOWED_COUNTRIES?: string; // CSV ISO-2, ex: "BR" ou "BR,PT,AO". Vazio = sem geo-fence
|
|
78
|
+
FRAUD_DROP_THRESHOLD?: string; // Score mínimo pra drop. Default "50" (antes era 80)
|
|
79
|
+
FRAUD_BLOCK_DATACENTERS?: string; // "1" = drop direto se ASN é datacenter (kill-switch)
|
|
80
|
+
FRAUD_GEO_PENALTY?: string; // Pontos somados quando country não está em ALLOWED_COUNTRIES. Default "50"
|
|
67
81
|
}
|
|
68
82
|
|
|
69
83
|
// ── 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
|