cdp-edge 1.2.0 → 1.3.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 +153 -306
- package/bin/cdp-edge.js +71 -61
- package/contracts/agent-versions.json +682 -0
- package/contracts/api-versions.json +372 -368
- package/contracts/types.ts +81 -0
- package/dist/commands/analyze.js +52 -52
- package/dist/commands/infra.js +54 -54
- package/dist/commands/install.js +26 -3
- package/dist/commands/server.js +174 -174
- package/dist/commands/setup.js +332 -100
- package/dist/commands/validate.js +248 -84
- package/dist/index.js +12 -12
- package/dist/sdk/cdpTrack.js +2095 -0
- package/dist/sdk/cdpTrack.min.js +64 -0
- package/dist/sdk/install-snippet.html +10 -0
- package/docs/whatsapp-ctwa.md +5 -4
- package/extracted-skill/tracking-events-generator/INTEGRACAO-COMPLETA.md +89 -0
- package/extracted-skill/tracking-events-generator/MELHORIAS-IMPLEMENTADAS.md +101 -0
- package/extracted-skill/tracking-events-generator/advanced-matching.js +364 -364
- package/extracted-skill/tracking-events-generator/agents/ab-ltv-agent.md +196 -0
- package/extracted-skill/tracking-events-generator/agents/ab-testing-agent.md +1 -1
- package/extracted-skill/tracking-events-generator/agents/attribution-agent.md +41 -41
- package/extracted-skill/tracking-events-generator/agents/bidding-agent.md +347 -0
- package/extracted-skill/tracking-events-generator/agents/bing-agent.md +40 -50
- package/extracted-skill/tracking-events-generator/agents/browser-tracking.md +174 -74
- package/extracted-skill/tracking-events-generator/agents/code-guardian-agent.md +1 -1
- package/extracted-skill/tracking-events-generator/agents/compliance-agent.md +25 -5
- package/extracted-skill/tracking-events-generator/agents/dashboard-agent.md +10 -10
- package/extracted-skill/tracking-events-generator/agents/database-agent.md +43 -42
- package/extracted-skill/tracking-events-generator/agents/debug-agent.md +22 -22
- package/extracted-skill/tracking-events-generator/agents/devops-agent.md +232 -0
- package/extracted-skill/tracking-events-generator/agents/domain-setup-agent.md +23 -9
- package/extracted-skill/tracking-events-generator/agents/email-agent.md +28 -1
- package/extracted-skill/tracking-events-generator/agents/evo-crm-agent.md +244 -0
- package/extracted-skill/tracking-events-generator/agents/fingerprint-agent.md +206 -1
- package/extracted-skill/tracking-events-generator/agents/fraud-detection-agent.md +143 -0
- package/extracted-skill/tracking-events-generator/agents/google-agent.md +128 -2
- package/extracted-skill/tracking-events-generator/agents/intelligence-agent.md +191 -31
- package/extracted-skill/tracking-events-generator/agents/lead-scoring-agent.md +282 -0
- package/extracted-skill/tracking-events-generator/agents/linkedin-agent.md +145 -34
- package/extracted-skill/tracking-events-generator/agents/localization-agent.md +1 -1
- package/extracted-skill/tracking-events-generator/agents/ltv-predictor-agent.md +5 -5
- package/extracted-skill/tracking-events-generator/agents/master-feedback-loop.md +81 -21
- package/extracted-skill/tracking-events-generator/agents/master-orchestrator.md +428 -190
- package/extracted-skill/tracking-events-generator/agents/match-quality-agent.md +304 -0
- package/extracted-skill/tracking-events-generator/agents/memory-agent.json +25 -109
- package/extracted-skill/tracking-events-generator/agents/memory-agent.md +190 -15
- package/extracted-skill/tracking-events-generator/agents/meta-agent.md +10 -2
- package/extracted-skill/tracking-events-generator/agents/ml-clustering-agent.md +749 -0
- package/extracted-skill/tracking-events-generator/agents/page-analyzer.md +21 -4
- package/extracted-skill/tracking-events-generator/agents/performance-agent.md +41 -31
- package/extracted-skill/tracking-events-generator/agents/performance-optimization-agent.md +18 -8
- package/extracted-skill/tracking-events-generator/agents/pinterest-agent.md +14 -6
- package/extracted-skill/tracking-events-generator/agents/premium-tracking-intelligence-agent.md +7 -7
- package/extracted-skill/tracking-events-generator/agents/r2-setup-agent.md +16 -8
- package/extracted-skill/tracking-events-generator/agents/reddit-agent.md +15 -7
- package/extracted-skill/tracking-events-generator/agents/security-enterprise-agent.md +157 -48
- package/extracted-skill/tracking-events-generator/agents/server-tracking.md +35 -35
- package/extracted-skill/tracking-events-generator/agents/spotify-agent.md +15 -7
- package/extracted-skill/tracking-events-generator/agents/tiktok-agent.md +73 -2
- package/extracted-skill/tracking-events-generator/agents/tracking-plan-agent.md +104 -9
- package/extracted-skill/tracking-events-generator/agents/utm-agent.md +322 -0
- package/extracted-skill/tracking-events-generator/agents/validator-agent.md +13 -9
- package/extracted-skill/tracking-events-generator/agents/webhook-agent.md +112 -4
- package/extracted-skill/tracking-events-generator/agents/whatsapp-agent.md +58 -5
- package/extracted-skill/tracking-events-generator/agents/whatsapp-ctwa-setup-agent.md +26 -18
- package/extracted-skill/tracking-events-generator/agents/youtube-agent.md +152 -37
- package/extracted-skill/tracking-events-generator/anti-blocking.js +285 -285
- package/extracted-skill/tracking-events-generator/cdpTrack.js +642 -641
- package/extracted-skill/tracking-events-generator/contracts/api-versions.json +14 -10
- package/extracted-skill/tracking-events-generator/engagement-scoring.js +226 -226
- package/extracted-skill/tracking-events-generator/evals/evals.json +235 -235
- package/extracted-skill/tracking-events-generator/integration-test.js +497 -497
- package/extracted-skill/tracking-events-generator/knowledge-base.md +172 -0
- package/extracted-skill/tracking-events-generator/micro-events.js +992 -992
- package/extracted-skill/tracking-events-generator/models/lancamento-imobiliario.md +344 -0
- package/extracted-skill/tracking-events-generator/models/pinterest/conversions-api-template.js +144 -144
- package/extracted-skill/tracking-events-generator/models/pinterest/event-mappings.json +48 -48
- package/extracted-skill/tracking-events-generator/models/pinterest/tag-template.js +28 -28
- package/extracted-skill/tracking-events-generator/models/quiz-funnel.md +83 -19
- package/extracted-skill/tracking-events-generator/models/reddit/conversions-api-template.js +205 -205
- package/extracted-skill/tracking-events-generator/models/reddit/event-mappings.json +56 -56
- package/extracted-skill/tracking-events-generator/models/reddit/pixel-template.js +19 -19
- package/extracted-skill/tracking-events-generator/models/scenarios/behavior-engine.js +425 -425
- package/extracted-skill/tracking-events-generator/route-intent-capture.js +222 -0
- package/extracted-skill/tracking-events-generator/tracking.config.js +3 -3
- package/package.json +89 -75
- package/scripts/build-sdk.js +106 -0
- package/server-edge-tracker/.client.env.example +14 -0
- package/server-edge-tracker/INSTALAR.md +222 -23
- package/server-edge-tracker/SEGMENTATION-DOCS.md +513 -0
- package/server-edge-tracker/config/utm-mapping.json +64 -0
- package/server-edge-tracker/deploy-client.cjs +76 -0
- package/server-edge-tracker/index.ts +1230 -0
- package/server-edge-tracker/migrate-v7.sql +64 -0
- package/server-edge-tracker/modules/db.ts +710 -0
- package/server-edge-tracker/modules/dispatch/crm.ts +382 -0
- package/server-edge-tracker/modules/dispatch/ga4.ts +72 -0
- package/server-edge-tracker/modules/dispatch/meta.ts +143 -0
- package/server-edge-tracker/modules/dispatch/platforms.ts +255 -0
- package/server-edge-tracker/modules/dispatch/tiktok.ts +107 -0
- package/server-edge-tracker/modules/dispatch/whatsapp.ts +296 -0
- package/server-edge-tracker/modules/intelligence.ts +589 -0
- package/server-edge-tracker/modules/ml/bidding.ts +247 -0
- package/server-edge-tracker/modules/ml/fraud.ts +302 -0
- package/server-edge-tracker/modules/ml/logistic.ts +226 -0
- package/server-edge-tracker/modules/ml/ltv.ts +531 -0
- package/server-edge-tracker/modules/ml/matchquality.ts +232 -0
- package/server-edge-tracker/modules/ml/quiz.ts +343 -0
- package/server-edge-tracker/modules/ml/roas.ts +255 -0
- package/server-edge-tracker/modules/ml/segmentation.ts +407 -0
- package/server-edge-tracker/modules/nurture.ts +257 -0
- package/server-edge-tracker/modules/utils.ts +311 -0
- package/server-edge-tracker/modules/utm/utm-enricher.ts +231 -0
- package/server-edge-tracker/schema-ab-ltv.sql +97 -0
- package/server-edge-tracker/schema-bidding.sql +86 -0
- package/server-edge-tracker/schema-fraud.sql +90 -0
- package/server-edge-tracker/schema-indexes.sql +67 -0
- package/server-edge-tracker/schema-ltv-feedback.sql +11 -0
- package/server-edge-tracker/schema-quiz.sql +52 -0
- package/server-edge-tracker/schema-sales-engine.sql +113 -0
- package/server-edge-tracker/schema-segmentation.sql +219 -0
- package/server-edge-tracker/schema-utm.sql +82 -0
- package/server-edge-tracker/schema.sql +281 -265
- package/server-edge-tracker/types.ts +275 -0
- package/server-edge-tracker/wrangler.toml +140 -85
- package/templates/lancamento-imobiliario.md +344 -0
- package/templates/multi-step-checkout.md +3 -4
- package/templates/pinterest/conversions-api-template.js +144 -144
- package/templates/pinterest/event-mappings.json +48 -48
- package/templates/pinterest/tag-template.js +28 -28
- package/templates/quiz-funnel.md +83 -19
- package/templates/reddit/conversions-api-template.js +205 -205
- package/templates/reddit/event-mappings.json +56 -56
- package/templates/reddit/pixel-template.js +12 -39
- package/templates/scenarios/behavior-engine.js +45 -22
- package/docs/PixelBuilder-Documentacao-Completa (2).docx +0 -0
- package/docs/installation.md +0 -155
- package/docs/quick-start.md +0 -185
- package/extracted-skill/tracking-events-generator/agents/crm-integration-agent.md +0 -1419
- package/extracted-skill/tracking-events-generator/agents/intelligence-scheduling.md +0 -643
- package/server-edge-tracker/worker.js +0 -2574
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CDP Edge — Plataformas Adicionais
|
|
3
|
+
* Pinterest CAPI v5, Reddit CAPI v2.0, LinkedIn CAPI 202401, Spotify CAPI v1
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { sha256, normalizePhone } from '../utils.js';
|
|
7
|
+
import { logApiFailure } from '../db.js';
|
|
8
|
+
import { Env, TrackPayload } from '../../types.js';
|
|
9
|
+
import { ExecutionContext } from '@cloudflare/workers-types';
|
|
10
|
+
|
|
11
|
+
// ── Pinterest Conversions API v5 ──────────────────────────────────────────────
|
|
12
|
+
export async function sendPinterestCapi(env: Env, eventName: string, payload: TrackPayload, request: Request | null, ctx: ExecutionContext | null): Promise<any> {
|
|
13
|
+
if (!env.PINTEREST_ACCESS_TOKEN || !env.PINTEREST_AD_ACCOUNT_ID) {
|
|
14
|
+
return { skipped: 'Pinterest credentials not set' };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const { email, phone, userId, eventId, pageUrl, value, currency, contentIds, contentName } = payload;
|
|
18
|
+
const phoneNorm = normalizePhone(phone);
|
|
19
|
+
|
|
20
|
+
const pinterestEventMap: Record<string, string> = {
|
|
21
|
+
PageView: 'pagevisit', ViewContent: 'pagevisit', Lead: 'lead', Purchase: 'checkout',
|
|
22
|
+
AddToCart: 'addtocart', InitiateCheckout: 'checkout', CompleteRegistration: 'signup',
|
|
23
|
+
Search: 'search', Contact: 'lead',
|
|
24
|
+
};
|
|
25
|
+
const pEvent = pinterestEventMap[eventName] || 'custom';
|
|
26
|
+
|
|
27
|
+
const userData: Record<string, string | string[]> = {
|
|
28
|
+
...(email && { em: [await sha256(email) || ''] }),
|
|
29
|
+
...(phoneNorm && { ph: [await sha256(phoneNorm) || ''] }),
|
|
30
|
+
...(userId && { external_id: [await sha256(String(userId)) || ''] }),
|
|
31
|
+
client_ip_address: request?.headers.get('CF-Connecting-IP') || '',
|
|
32
|
+
client_user_agent: request?.headers.get('User-Agent') || '',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const body = {
|
|
36
|
+
data: [{
|
|
37
|
+
event_name: pEvent,
|
|
38
|
+
action_source: 'web',
|
|
39
|
+
event_time: Math.floor(Date.now() / 1000),
|
|
40
|
+
event_id: eventId || `cdp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
|
41
|
+
event_source_url: pageUrl || '',
|
|
42
|
+
user_data: userData,
|
|
43
|
+
custom_data: {
|
|
44
|
+
currency: (currency || 'BRL').toUpperCase(),
|
|
45
|
+
value: value ? String(parseFloat(String(value))) : '0',
|
|
46
|
+
...(contentIds && contentIds.length > 0 && { content_ids: contentIds.map(String) }),
|
|
47
|
+
...(contentName && { content_name: contentName }),
|
|
48
|
+
content_type: 'product',
|
|
49
|
+
},
|
|
50
|
+
}],
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const res = await fetch(
|
|
55
|
+
`https://api.pinterest.com/v5/ad_accounts/${env.PINTEREST_AD_ACCOUNT_ID}/events`,
|
|
56
|
+
{ method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${env.PINTEREST_ACCESS_TOKEN}` }, body: JSON.stringify(body) }
|
|
57
|
+
);
|
|
58
|
+
const data = await res.json();
|
|
59
|
+
if (!res.ok) {
|
|
60
|
+
const msg = (data as any).message || (data as any).code || String(res.status);
|
|
61
|
+
console.error('Pinterest CAPI error:', res.status, msg);
|
|
62
|
+
if (env.DB && ctx) ctx.waitUntil(logApiFailure(env.DB, 'pinterest', eventName, String(res.status), msg, (body.data as any)[0].event_id, JSON.stringify(body)));
|
|
63
|
+
}
|
|
64
|
+
return data;
|
|
65
|
+
} catch (err: any) {
|
|
66
|
+
console.error('Pinterest CAPI fetch failed:', err?.message || String(err));
|
|
67
|
+
if (env.DB && ctx) ctx.waitUntil(logApiFailure(env.DB, 'pinterest', eventName, 'FETCH_ERROR', err?.message || String(err), '', JSON.stringify(body)));
|
|
68
|
+
if (env.RETRY_QUEUE) {
|
|
69
|
+
const send = env.RETRY_QUEUE.send({ eventType: eventName, payload, platform: 'pinterest' });
|
|
70
|
+
if (ctx) ctx.waitUntil(send); else send.catch(() => {});
|
|
71
|
+
}
|
|
72
|
+
return { error: err?.message || String(err) };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── Reddit Conversions API v2.0 ───────────────────────────────────────────────
|
|
77
|
+
export async function sendRedditCapi(env: Env, eventName: string, payload: TrackPayload, request: Request | null, ctx: ExecutionContext | null): Promise<any> {
|
|
78
|
+
if (!env.REDDIT_ACCESS_TOKEN || !env.REDDIT_AD_ACCOUNT_ID) {
|
|
79
|
+
return { skipped: 'Reddit credentials not set' };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const { email, phone, userId, eventId, pageUrl, value, currency } = payload;
|
|
83
|
+
const phoneNorm = normalizePhone(phone);
|
|
84
|
+
|
|
85
|
+
const redditEventMap: Record<string, string> = {
|
|
86
|
+
PageView: 'PageVisit', ViewContent: 'ViewContent', Lead: 'Lead', Purchase: 'Purchase',
|
|
87
|
+
AddToCart: 'AddToCart', InitiateCheckout: 'Purchase', CompleteRegistration: 'SignUp',
|
|
88
|
+
Search: 'Search', Contact: 'Lead',
|
|
89
|
+
};
|
|
90
|
+
const rEvent = redditEventMap[eventName] || 'Custom';
|
|
91
|
+
|
|
92
|
+
const user: Record<string, { value: string }> = {
|
|
93
|
+
...(email && { email: { value: await sha256(email) || '' } }),
|
|
94
|
+
...(phoneNorm && { phoneNumber: { value: await sha256(phoneNorm) || '' } }),
|
|
95
|
+
...(userId && { externalId: { value: await sha256(String(userId)) || '' } }),
|
|
96
|
+
ipAddress: { value: request?.headers.get('CF-Connecting-IP') || '' },
|
|
97
|
+
userAgent: { value: request?.headers.get('User-Agent') || '' },
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const event: Record<string, any> = {
|
|
101
|
+
event_at: new Date().toISOString(),
|
|
102
|
+
event_type: { tracking_type: rEvent, ...(eventName === 'InitiateCheckout' && { conversion_type: 'BEGIN_CHECKOUT' }) },
|
|
103
|
+
click_id: (payload as any).rdtClid || '',
|
|
104
|
+
event_metadata: {
|
|
105
|
+
currency: (currency || 'BRL').toUpperCase(),
|
|
106
|
+
value_decimal: String(value || 0),
|
|
107
|
+
item_count: '1',
|
|
108
|
+
conversion_id: eventId || `cdp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
|
109
|
+
},
|
|
110
|
+
user,
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const body = { events: [event] };
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
const res = await fetch(
|
|
117
|
+
`https://ads-api.reddit.com/api/v2.0/conversions/events/${env.REDDIT_AD_ACCOUNT_ID}`,
|
|
118
|
+
{ method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${env.REDDIT_ACCESS_TOKEN}` }, body: JSON.stringify(body) }
|
|
119
|
+
);
|
|
120
|
+
if (!res.ok) {
|
|
121
|
+
const txt = await res.text();
|
|
122
|
+
console.error('Reddit CAPI error:', txt);
|
|
123
|
+
if (env.DB && ctx) ctx.waitUntil(logApiFailure(env.DB, 'reddit', eventName, String(res.status), txt, event.event_metadata.conversion_id, JSON.stringify(body)));
|
|
124
|
+
return { error: `HTTP ${res.status}` };
|
|
125
|
+
}
|
|
126
|
+
return await res.json();
|
|
127
|
+
} catch (err: any) {
|
|
128
|
+
console.error('Reddit CAPI fetch failed:', err?.message || String(err));
|
|
129
|
+
if (env.DB && ctx) ctx.waitUntil(logApiFailure(env.DB, 'reddit', eventName, 'FETCH_ERROR', err?.message || String(err), '', JSON.stringify(body)));
|
|
130
|
+
if (env.RETRY_QUEUE) {
|
|
131
|
+
const send = env.RETRY_QUEUE.send({ eventType: eventName, payload, platform: 'reddit' });
|
|
132
|
+
if (ctx) ctx.waitUntil(send); else send.catch(() => {});
|
|
133
|
+
}
|
|
134
|
+
return { error: err?.message || String(err) };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ── LinkedIn Conversions API (LinkedIn-Version: 202401) ───────────────────────
|
|
139
|
+
export async function sendLinkedInCapi(env: Env, eventName: string, payload: TrackPayload, request: Request | null, ctx: ExecutionContext | null): Promise<any> {
|
|
140
|
+
if (!env.LINKEDIN_ACCESS_TOKEN || !env.LINKEDIN_CONVERSION_ID) {
|
|
141
|
+
return { skipped: 'LinkedIn credentials not set' };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const { email, phone, firstName, lastName, userId, eventId, pageUrl, value, currency } = payload;
|
|
145
|
+
const phoneNorm = normalizePhone(phone);
|
|
146
|
+
|
|
147
|
+
const linkedInEventMap: Record<string, string> = {
|
|
148
|
+
Lead: 'LEAD', Purchase: 'PURCHASE', CompleteRegistration: 'REGISTRATION',
|
|
149
|
+
AddToCart: 'ADD_TO_CART', InitiateCheckout: 'OTHER', ViewContent: 'OTHER',
|
|
150
|
+
PageView: 'OTHER', Contact: 'LEAD',
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const userInfo: Record<string, string> = {
|
|
154
|
+
...(email && { 'SHA256_EMAIL': await sha256(email) || '' }),
|
|
155
|
+
...(phoneNorm && { 'SHA256_PHONE': await sha256(phoneNorm) || '' }),
|
|
156
|
+
...(firstName && { 'SHA256_FIRST_NAME': await sha256(firstName.toLowerCase().trim()) || '' }),
|
|
157
|
+
...(lastName && { 'SHA256_LAST_NAME': await sha256(lastName.toLowerCase().trim()) || '' }),
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const body: Record<string, any> = {
|
|
161
|
+
conversion: `urn:li:conversion:${env.LINKEDIN_CONVERSION_ID}`,
|
|
162
|
+
conversionHappenedAt: Date.now(),
|
|
163
|
+
conversionValue: value ? { currencyCode: (currency || 'BRL').toUpperCase(), amount: String(parseFloat(String(value))) } : undefined,
|
|
164
|
+
eventId: eventId || `cdp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
|
165
|
+
...(Object.keys(userInfo).length > 0 && { user: { userIds: Object.entries(userInfo).map(([idType, idValue]) => ({ idType, idValue })) } }),
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
const res = await fetch('https://api.linkedin.com/rest/conversionEvents', {
|
|
170
|
+
method: 'POST',
|
|
171
|
+
headers: {
|
|
172
|
+
'Content-Type': 'application/json',
|
|
173
|
+
Authorization: `Bearer ${env.LINKEDIN_ACCESS_TOKEN}`,
|
|
174
|
+
'LinkedIn-Version': '202401',
|
|
175
|
+
'X-Restli-Protocol-Version': '2.0.0',
|
|
176
|
+
},
|
|
177
|
+
body: JSON.stringify(body),
|
|
178
|
+
});
|
|
179
|
+
if (!res.ok) {
|
|
180
|
+
const txt = await res.text();
|
|
181
|
+
console.error('LinkedIn CAPI error:', txt);
|
|
182
|
+
if (env.DB && ctx) ctx.waitUntil(logApiFailure(env.DB, 'linkedin', eventName, String(res.status), txt, body.eventId, JSON.stringify(body)));
|
|
183
|
+
return { error: `HTTP ${res.status}` };
|
|
184
|
+
}
|
|
185
|
+
return { ok: true };
|
|
186
|
+
} catch (err: any) {
|
|
187
|
+
console.error('LinkedIn CAPI fetch failed:', err?.message || String(err));
|
|
188
|
+
if (env.DB && ctx) ctx.waitUntil(logApiFailure(env.DB, 'linkedin', eventName, 'FETCH_ERROR', err?.message || String(err), '', JSON.stringify(body)));
|
|
189
|
+
if (env.RETRY_QUEUE) {
|
|
190
|
+
const send = env.RETRY_QUEUE.send({ eventType: eventName, payload, platform: 'linkedin' });
|
|
191
|
+
if (ctx) ctx.waitUntil(send); else send.catch(() => {});
|
|
192
|
+
}
|
|
193
|
+
return { error: err?.message || String(err) };
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ── Spotify Conversions API v1 ────────────────────────────────────────────────
|
|
198
|
+
export async function sendSpotifyCapi(env: Env, eventName: string, payload: TrackPayload, request: Request | null, ctx: ExecutionContext | null): Promise<any> {
|
|
199
|
+
if (!env.SPOTIFY_ACCESS_TOKEN || !env.SPOTIFY_AD_ACCOUNT_ID) {
|
|
200
|
+
return { skipped: 'Spotify credentials not set' };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const { email, phone, userId, eventId, pageUrl, value, currency } = payload;
|
|
204
|
+
const phoneNorm = normalizePhone(phone);
|
|
205
|
+
|
|
206
|
+
const spotifyEventMap: Record<string, string> = {
|
|
207
|
+
Purchase: 'PURCHASE', Lead: 'LEAD', CompleteRegistration: 'SIGN_UP',
|
|
208
|
+
AddToCart: 'ADD_TO_CART', InitiateCheckout: 'INITIATE_CHECKOUT',
|
|
209
|
+
ViewContent: 'VIEW_CONTENT', PageView: 'PAGE_VIEW', Contact: 'LEAD',
|
|
210
|
+
};
|
|
211
|
+
const spEvent = spotifyEventMap[eventName] || 'CUSTOM';
|
|
212
|
+
|
|
213
|
+
const user: Record<string, string> = {
|
|
214
|
+
...(email && { hashed_email: await sha256(email) || '' }),
|
|
215
|
+
...(phoneNorm && { hashed_phone: await sha256(phoneNorm) || '' }),
|
|
216
|
+
...(userId && { user_id: userId }),
|
|
217
|
+
ip_address: request?.headers.get('CF-Connecting-IP') || '',
|
|
218
|
+
user_agent: request?.headers.get('User-Agent') || '',
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const body = {
|
|
222
|
+
data: [{
|
|
223
|
+
event_id: eventId || `cdp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
|
224
|
+
event_type: spEvent,
|
|
225
|
+
event_time: Math.floor(Date.now() / 1000),
|
|
226
|
+
url: pageUrl || '',
|
|
227
|
+
user,
|
|
228
|
+
...(value !== undefined && {
|
|
229
|
+
value: { currency: (currency || 'BRL').toUpperCase(), amount: parseFloat(String(value)) },
|
|
230
|
+
}),
|
|
231
|
+
}],
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
try {
|
|
235
|
+
const res = await fetch(
|
|
236
|
+
`https://advertising-api.spotify.com/conversion/v1/accounts/${env.SPOTIFY_AD_ACCOUNT_ID}/events`,
|
|
237
|
+
{ method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${env.SPOTIFY_ACCESS_TOKEN}` }, body: JSON.stringify(body) }
|
|
238
|
+
);
|
|
239
|
+
if (!res.ok) {
|
|
240
|
+
const txt = await res.text();
|
|
241
|
+
console.error('Spotify CAPI error:', txt);
|
|
242
|
+
if (env.DB && ctx) ctx.waitUntil(logApiFailure(env.DB, 'spotify', eventName, String(res.status), txt, (body.data as any)[0].event_id, JSON.stringify(body)));
|
|
243
|
+
return { error: `HTTP ${res.status}` };
|
|
244
|
+
}
|
|
245
|
+
return await res.json();
|
|
246
|
+
} catch (err: any) {
|
|
247
|
+
console.error('Spotify CAPI fetch failed:', err?.message || String(err));
|
|
248
|
+
if (env.DB && ctx) ctx.waitUntil(logApiFailure(env.DB, 'spotify', eventName, 'FETCH_ERROR', err?.message || String(err), '', JSON.stringify(body)));
|
|
249
|
+
if (env.RETRY_QUEUE) {
|
|
250
|
+
const send = env.RETRY_QUEUE.send({ eventType: eventName, payload, platform: 'spotify' });
|
|
251
|
+
if (ctx) ctx.waitUntil(send); else send.catch(() => {});
|
|
252
|
+
}
|
|
253
|
+
return { error: err?.message || String(err) };
|
|
254
|
+
}
|
|
255
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CDP Edge — TikTok Events API v1.3
|
|
3
|
+
* Envia eventos server-side para a TikTok Events API.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { sha256, normalizePhone } from '../utils.js';
|
|
7
|
+
import { logApiFailure } from '../db.js';
|
|
8
|
+
import { Env, TrackPayload } from '../../types.js';
|
|
9
|
+
import { ExecutionContext } from '@cloudflare/workers-types';
|
|
10
|
+
|
|
11
|
+
export async function sendTikTokApi(env: Env, eventName: string, payload: TrackPayload, request: Request | null, ctx: ExecutionContext | null): Promise<any> {
|
|
12
|
+
if (!env.TIKTOK_ACCESS_TOKEN) return { skipped: 'TIKTOK_ACCESS_TOKEN not set' };
|
|
13
|
+
|
|
14
|
+
const pixelId = env.TIKTOK_PIXEL_ID;
|
|
15
|
+
if (!pixelId || pixelId === 'SEU_TIKTOK_PIXEL_ID') return { skipped: 'TIKTOK_PIXEL_ID not configured' };
|
|
16
|
+
|
|
17
|
+
const {
|
|
18
|
+
email, phone, firstName, lastName,
|
|
19
|
+
fbp, fbc, ttp, ttclid, userId,
|
|
20
|
+
eventId, pageUrl,
|
|
21
|
+
value, currency,
|
|
22
|
+
contentIds, contentName, contentType,
|
|
23
|
+
} = payload;
|
|
24
|
+
|
|
25
|
+
const phoneNorm = normalizePhone(phone);
|
|
26
|
+
|
|
27
|
+
const user: Record<string, string> = {
|
|
28
|
+
...(email && { email: await sha256(email) || '' }),
|
|
29
|
+
...(phoneNorm && { phone_number: await sha256(phoneNorm) || '' }),
|
|
30
|
+
...(userId && { external_id: await sha256(String(userId)) || '' }),
|
|
31
|
+
...(ttp && { ttp }),
|
|
32
|
+
...(ttclid && { ttclid }),
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const properties: Record<string, any> = {
|
|
36
|
+
...(value !== undefined && { value: parseFloat(String(value)) }),
|
|
37
|
+
...(currency && { currency: String(currency).toUpperCase() }),
|
|
38
|
+
...(contentIds && contentIds && contentIds.length > 0 && {
|
|
39
|
+
contents: contentIds.map(id => ({
|
|
40
|
+
content_id: String(id),
|
|
41
|
+
content_name: contentName || '',
|
|
42
|
+
content_type: contentType || 'product',
|
|
43
|
+
quantity: 1,
|
|
44
|
+
price: value ? parseFloat(String(value)) : 0,
|
|
45
|
+
})),
|
|
46
|
+
}),
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const event: Record<string, any> = {
|
|
50
|
+
event: eventName,
|
|
51
|
+
event_time: Math.floor(Date.now() / 1000),
|
|
52
|
+
event_id: eventId || `cdp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
|
53
|
+
user,
|
|
54
|
+
page: {
|
|
55
|
+
url: pageUrl || `https://${env.SITE_DOMAIN}`,
|
|
56
|
+
referrer: request?.headers.get('Referer') || '',
|
|
57
|
+
},
|
|
58
|
+
...(Object.keys(properties).length > 0 && { properties }),
|
|
59
|
+
context: {
|
|
60
|
+
ip: request?.headers.get('CF-Connecting-IP') || '',
|
|
61
|
+
user_agent: request?.headers.get('User-Agent') || '',
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const body = {
|
|
66
|
+
event_source: 'web',
|
|
67
|
+
event_source_id: pixelId,
|
|
68
|
+
data: [event],
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// Endpoint canônico: sempre /v1.3/event/track/
|
|
72
|
+
const endpoint = 'https://business-api.tiktok.com/open_api/v1.3/event/track/';
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const res = await fetch(endpoint, {
|
|
76
|
+
method: 'POST',
|
|
77
|
+
headers: {
|
|
78
|
+
'Content-Type': 'application/json',
|
|
79
|
+
'Access-Token': env.TIKTOK_ACCESS_TOKEN,
|
|
80
|
+
},
|
|
81
|
+
body: JSON.stringify(body),
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const data = await res.json();
|
|
85
|
+
if (!res.ok || (data as any).code !== 0) {
|
|
86
|
+
console.error('TikTok Events API error:', res.status, (data as any).message || (data as any).code || 'unknown');
|
|
87
|
+
|
|
88
|
+
if (env.DB && ctx) {
|
|
89
|
+
ctx.waitUntil(logApiFailure(env.DB, 'tiktok', eventName, String((data as any).code || res.status), (data as any).message || 'TikTok API error', event.event_id, JSON.stringify(body)));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return data;
|
|
93
|
+
} catch (err: any) {
|
|
94
|
+
console.error('TikTok Events API fetch failed:', err?.message || String(err));
|
|
95
|
+
|
|
96
|
+
if (env.DB && ctx) {
|
|
97
|
+
ctx.waitUntil(logApiFailure(env.DB, 'tiktok', eventName, 'FETCH_ERROR', err?.message || String(err), '', JSON.stringify(body)));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (env.RETRY_QUEUE) {
|
|
101
|
+
const send = env.RETRY_QUEUE.send({ eventType: eventName, payload, platform: 'tiktok' });
|
|
102
|
+
if (ctx) ctx.waitUntil(send); else send.catch(() => {});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return { error: err?.message || String(err) };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CDP Edge — WhatsApp Cloud API v22.0 + HMAC Verification
|
|
3
|
+
* sendWhatsApp, processWhatsAppWebhook, verifyHmac, sendCallMeBot
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { sha256, normalizePhone } from '../utils.js';
|
|
7
|
+
import { saveLead, logApiFailure } from '../db.js';
|
|
8
|
+
import { Env, TrackPayload } from '../../types.js';
|
|
9
|
+
import { ExecutionContext } from '@cloudflare/workers-types';
|
|
10
|
+
import { notifyEvolutionCTWA } from './crm.js';
|
|
11
|
+
|
|
12
|
+
// ── Tipos ───────────────────────────────────────────────────────────────────────
|
|
13
|
+
interface WhatsAppOptions {
|
|
14
|
+
to?: string;
|
|
15
|
+
template?: {
|
|
16
|
+
name: string;
|
|
17
|
+
language?: string;
|
|
18
|
+
components?: any[];
|
|
19
|
+
};
|
|
20
|
+
mediaType?: 'image' | 'document' | 'video' | 'audio';
|
|
21
|
+
mediaUrl?: string;
|
|
22
|
+
caption?: string;
|
|
23
|
+
filename?: string;
|
|
24
|
+
interactive?: 'buttons' | 'list';
|
|
25
|
+
bodyText?: string;
|
|
26
|
+
buttons?: Array<{ id: string; title: string }>;
|
|
27
|
+
listButton?: string;
|
|
28
|
+
rows?: Array<{ id: string; title: string; description?: string }>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface WhatsAppMessage {
|
|
32
|
+
from: string;
|
|
33
|
+
id: string;
|
|
34
|
+
type: string;
|
|
35
|
+
text?: { body: string };
|
|
36
|
+
referral?: {
|
|
37
|
+
ctwa_clid?: string;
|
|
38
|
+
source_id?: string;
|
|
39
|
+
source_url?: string;
|
|
40
|
+
headline?: string;
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ── Resolvedores de secrets (canônico + legado) ────────────────────────────────
|
|
45
|
+
// Meta Cloud API v22.0 usa PHONE_NUMBER_ID e ACCESS_TOKEN como termos oficiais.
|
|
46
|
+
// Suportamos ambos os nomes para compatibilidade com secrets já configurados.
|
|
47
|
+
function resolvePhoneNumberId(env: Env): string | undefined {
|
|
48
|
+
return env.WHATSAPP_PHONE_NUMBER_ID || env.WA_PHONE_ID;
|
|
49
|
+
}
|
|
50
|
+
function resolveAccessToken(env: Env): string | undefined {
|
|
51
|
+
return env.WHATSAPP_ACCESS_TOKEN || env.WA_ACCESS_TOKEN;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ── sendWhatsApp — envia mensagem via Meta Cloud API ──────────────────────────
|
|
55
|
+
export async function sendWhatsApp(env: Env, tipo: string, payload: TrackPayload, options: WhatsAppOptions = {}): Promise<any> {
|
|
56
|
+
if (!resolvePhoneNumberId(env) || !resolveAccessToken(env) || !env.WA_NOTIFY_NUMBER) {
|
|
57
|
+
return { skipped: 'WhatsApp não configurado' };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const to = options.to || env.WA_NOTIFY_NUMBER;
|
|
61
|
+
|
|
62
|
+
if (options.template) {
|
|
63
|
+
const { name, language = 'pt_BR', components = [] } = options.template;
|
|
64
|
+
const body = { messaging_product: 'whatsapp', to, type: 'template', template: { name, language: { code: language }, components } };
|
|
65
|
+
return _sendWARequest(env, body);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (options.mediaType && options.mediaUrl) {
|
|
69
|
+
const mediaPayload: Record<string, string> = { link: options.mediaUrl };
|
|
70
|
+
if (options.caption) mediaPayload.caption = options.caption;
|
|
71
|
+
if (options.filename) mediaPayload.filename = options.filename;
|
|
72
|
+
const body = { messaging_product: 'whatsapp', to, type: options.mediaType, [options.mediaType]: mediaPayload };
|
|
73
|
+
return _sendWARequest(env, body);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (options.interactive === 'buttons' && options.buttons && options.buttons.length > 0) {
|
|
77
|
+
const body = {
|
|
78
|
+
messaging_product: 'whatsapp', to, type: 'interactive',
|
|
79
|
+
interactive: {
|
|
80
|
+
type: 'button',
|
|
81
|
+
body: { text: options.bodyText || '' },
|
|
82
|
+
action: {
|
|
83
|
+
buttons: options.buttons.slice(0, 3).map(b => ({ type: 'reply', reply: { id: b.id, title: b.title } })),
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
return _sendWARequest(env, body);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (options.interactive === 'list' && options.rows && options.rows.length > 0) {
|
|
91
|
+
const body = {
|
|
92
|
+
messaging_product: 'whatsapp', to, type: 'interactive',
|
|
93
|
+
interactive: {
|
|
94
|
+
type: 'list',
|
|
95
|
+
body: { text: options.bodyText || '' },
|
|
96
|
+
action: { button: options.listButton || 'Ver opções', sections: [{ rows: options.rows.slice(0, 10) }] },
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
return _sendWARequest(env, body);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Text fallback (dentro da janela de 24h)
|
|
103
|
+
const nome = [payload.firstName, payload.lastName].filter(Boolean).join(' ') || 'sem nome';
|
|
104
|
+
const valor = payload.value ? `R$ ${parseFloat(String(payload.value)).toFixed(2)}` : '—';
|
|
105
|
+
const utm = payload.utmSource || 'direto';
|
|
106
|
+
const produto = payload.contentName || '';
|
|
107
|
+
|
|
108
|
+
let texto = '';
|
|
109
|
+
if (tipo === 'Purchase') {
|
|
110
|
+
texto =
|
|
111
|
+
`🛒 *Nova Venda!*\n\n` +
|
|
112
|
+
`👤 ${nome}\n📧 ${payload.email || '—'}\n📱 ${payload.phone || '—'}\n` +
|
|
113
|
+
`💰 ${valor}\n${produto ? `📦 ${produto}\n` : ''}` +
|
|
114
|
+
`🔗 UTM: ${utm}\n🕐 ${new Date().toLocaleString('pt-BR', { timeZone: 'America/Sao_Paulo' })}`;
|
|
115
|
+
} else if (tipo === 'Lead') {
|
|
116
|
+
texto =
|
|
117
|
+
`📋 *Novo Lead!*\n\n` +
|
|
118
|
+
`📧 ${payload.email || '—'}\n🔗 UTM: ${utm}\n` +
|
|
119
|
+
`🌐 ${payload.pageUrl || '—'}\n🕐 ${new Date().toLocaleString('pt-BR', { timeZone: 'America/Sao_Paulo' })}`;
|
|
120
|
+
} else {
|
|
121
|
+
return { skipped: `tipo ${tipo} não suportado sem template` };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return _sendWARequest(env, { messaging_product: 'whatsapp', to, type: 'text', text: { body: texto } });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ── _sendWARequest — executor interno ─────────────────────────────────────────
|
|
128
|
+
async function _sendWARequest(env: Env, body: Record<string, any>): Promise<any> {
|
|
129
|
+
try {
|
|
130
|
+
const phoneNumberId = resolvePhoneNumberId(env);
|
|
131
|
+
const accessToken = resolveAccessToken(env);
|
|
132
|
+
const res = await fetch(`https://graph.facebook.com/v22.0/${phoneNumberId}/messages`, {
|
|
133
|
+
method: 'POST',
|
|
134
|
+
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${accessToken}` },
|
|
135
|
+
body: JSON.stringify(body),
|
|
136
|
+
});
|
|
137
|
+
const data = await res.json();
|
|
138
|
+
if (!res.ok) console.error('WhatsApp Meta API error:', res.status, (data as any).error?.message || 'unknown');
|
|
139
|
+
return { ok: res.ok, status: res.status, data };
|
|
140
|
+
} catch (err: any) {
|
|
141
|
+
console.error('WhatsApp Meta API failed:', err?.message || String(err));
|
|
142
|
+
return { ok: false, error: err?.message || String(err) };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ── sendCallMeBot — alertas de sistema via WhatsApp ───────────────────────────
|
|
147
|
+
export async function sendCallMeBot(env: Env, mensagem: string): Promise<any> {
|
|
148
|
+
if (!env.CALLMEBOT_PHONE || !env.CALLMEBOT_APIKEY) {
|
|
149
|
+
return { skipped: 'CallMeBot não configurado' };
|
|
150
|
+
}
|
|
151
|
+
try {
|
|
152
|
+
const url = `https://api.callmebot.com/whatsapp.php?phone=${encodeURIComponent(env.CALLMEBOT_PHONE)}&text=${encodeURIComponent(mensagem)}&apikey=${env.CALLMEBOT_APIKEY}`;
|
|
153
|
+
const res = await fetch(url);
|
|
154
|
+
return { ok: res.ok, status: res.status };
|
|
155
|
+
} catch (err: any) {
|
|
156
|
+
console.error('CallMeBot failed:', err?.message || String(err));
|
|
157
|
+
return { ok: false, error: err?.message || String(err) };
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── processWhatsAppWebhook — CTWA (Click to WhatsApp) ────────────────────────
|
|
162
|
+
export async function processWhatsAppWebhook(env: Env, body: any, request: Request, ctx: ExecutionContext): Promise<any> {
|
|
163
|
+
const entry: any = body?.entry?.[0];
|
|
164
|
+
const change = entry?.changes?.find((c: any) => c.field === 'messages');
|
|
165
|
+
if (!change) return { skipped: 'no messages field' };
|
|
166
|
+
|
|
167
|
+
const messages = change.value?.messages;
|
|
168
|
+
if (!messages || messages.length === 0) return { skipped: 'no messages' };
|
|
169
|
+
|
|
170
|
+
const results: any[] = [];
|
|
171
|
+
|
|
172
|
+
for (const message of messages) {
|
|
173
|
+
const phone = message.from;
|
|
174
|
+
const wamid = message.id;
|
|
175
|
+
const referral = message.referral || {};
|
|
176
|
+
const ctwaClid = referral.ctwa_clid || null;
|
|
177
|
+
const adId = referral.source_id || null;
|
|
178
|
+
const sourceUrl = referral.source_url || null;
|
|
179
|
+
const headline = referral.headline || null;
|
|
180
|
+
const messageBody = message.text?.body || message.type || '';
|
|
181
|
+
|
|
182
|
+
if (!phone) { results.push({ skipped: 'no phone' }); continue; }
|
|
183
|
+
|
|
184
|
+
const phoneNorm = normalizePhone(phone) || phone;
|
|
185
|
+
const phoneHash = await sha256(phoneNorm);
|
|
186
|
+
|
|
187
|
+
// Deduplicação por wamid
|
|
188
|
+
if (env.DB && wamid) {
|
|
189
|
+
try {
|
|
190
|
+
const existing = await env.DB.prepare('SELECT id FROM whatsapp_contacts WHERE wamid = ?').bind(wamid).first();
|
|
191
|
+
if (existing) { results.push({ skipped: 'duplicate wamid', wamid }); continue; }
|
|
192
|
+
} catch { /* não bloquear se D1 falhar */ }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const eventId = `ctwa_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
196
|
+
|
|
197
|
+
if (env.DB) {
|
|
198
|
+
ctx.waitUntil(
|
|
199
|
+
env.DB.prepare(
|
|
200
|
+
`INSERT OR IGNORE INTO whatsapp_contacts (phone_hash, phone_raw, wamid, ctwa_clid, ad_id, source_url, headline, capi_sent, capi_event_id, message_body) VALUES (?,?,?,?,?,?,?,0,?,?)`
|
|
201
|
+
).bind(phoneHash, phoneNorm, wamid || null, ctwaClid, adId, sourceUrl, headline, eventId, messageBody || null).run()
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const capiEvent: Record<string, any> = {
|
|
206
|
+
event_name: 'Contact',
|
|
207
|
+
event_time: Math.floor(Date.now() / 1000),
|
|
208
|
+
event_id: eventId,
|
|
209
|
+
action_source: 'chat',
|
|
210
|
+
user_data: {
|
|
211
|
+
ph: phoneHash,
|
|
212
|
+
...(ctwaClid && { ctwa_clid: ctwaClid }),
|
|
213
|
+
client_ip_address: request.headers.get('CF-Connecting-IP') || '',
|
|
214
|
+
client_user_agent: request.headers.get('User-Agent') || '',
|
|
215
|
+
},
|
|
216
|
+
...(sourceUrl && { event_source_url: sourceUrl }),
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
ctx.waitUntil(
|
|
220
|
+
(async () => {
|
|
221
|
+
try {
|
|
222
|
+
const requestBody: Record<string, any> = { data: [capiEvent], access_token: env.META_ACCESS_TOKEN };
|
|
223
|
+
if (env.META_TEST_CODE) requestBody.test_event_code = env.META_TEST_CODE;
|
|
224
|
+
|
|
225
|
+
const res = await fetch(
|
|
226
|
+
`https://graph.facebook.com/v22.0/${env.META_PIXEL_ID}/events`,
|
|
227
|
+
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(requestBody) }
|
|
228
|
+
);
|
|
229
|
+
const data = await res.json();
|
|
230
|
+
|
|
231
|
+
if (res.ok && env.DB && wamid) {
|
|
232
|
+
await env.DB.prepare('UPDATE whatsapp_contacts SET capi_sent = 1 WHERE wamid = ?').bind(wamid).run();
|
|
233
|
+
} else if (!res.ok) {
|
|
234
|
+
console.error('[CTWA] Meta CAPI error:', res.status, (data as any).error?.message || 'unknown');
|
|
235
|
+
if (env.DB) {
|
|
236
|
+
await logApiFailure(env.DB, 'meta', 'Contact', (data as any).error?.code || res.status, (data as any).error?.message || 'CTWA CAPI error', eventId, JSON.stringify(requestBody));
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
} catch (err: any) {
|
|
240
|
+
console.error('[CTWA] Meta CAPI fetch failed:', err?.message || String(err));
|
|
241
|
+
}
|
|
242
|
+
})()
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
ctx.waitUntil(
|
|
246
|
+
saveLead(env, 'Contact', {
|
|
247
|
+
phone: phoneNorm, eventId, pageUrl: sourceUrl,
|
|
248
|
+
utmSource: 'whatsapp_ctwa', utmMedium: 'paid_social',
|
|
249
|
+
}, request, 'whatsapp')
|
|
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
|
+
|
|
268
|
+
results.push({ ok: true, phone: phoneNorm.slice(0, 4) + '****', ctwa_clid: ctwaClid ? 'present' : 'absent', event_id: eventId });
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return { processed: results.length, results };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ── verifyHmac — validação constant-time de assinatura HMAC-SHA256 ─────────────
|
|
275
|
+
export async function verifyHmac(secret: string, rawBody: string, receivedSignature: string): Promise<boolean> {
|
|
276
|
+
if (!secret || !receivedSignature) return false;
|
|
277
|
+
try {
|
|
278
|
+
const key = await crypto.subtle.importKey(
|
|
279
|
+
'raw',
|
|
280
|
+
new TextEncoder().encode(secret),
|
|
281
|
+
{ name: 'HMAC', hash: 'SHA-256' },
|
|
282
|
+
false,
|
|
283
|
+
['sign']
|
|
284
|
+
);
|
|
285
|
+
const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(rawBody));
|
|
286
|
+
const computed = Array.from(new Uint8Array(sig)).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
287
|
+
if (computed.length !== receivedSignature.length) return false;
|
|
288
|
+
let diff = 0;
|
|
289
|
+
for (let i = 0; i < computed.length; i++) {
|
|
290
|
+
diff |= computed.charCodeAt(i) ^ receivedSignature.charCodeAt(i);
|
|
291
|
+
}
|
|
292
|
+
return diff === 0;
|
|
293
|
+
} catch {
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
}
|