cdp-edge 2.0.2 → 2.0.4
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 +325 -308
- package/contracts/agent-versions.json +364 -0
- package/dist/commands/install.js +1 -1
- package/dist/commands/setup.js +1 -1
- package/extracted-skill/tracking-events-generator/agents/browser-tracking.md +2 -2
- package/extracted-skill/tracking-events-generator/agents/master-orchestrator.md +14 -20
- package/extracted-skill/tracking-events-generator/agents/premium-tracking-intelligence-agent.md +4 -4
- package/extracted-skill/tracking-events-generator/agents/server-tracking.md +13 -13
- package/extracted-skill/tracking-events-generator/agents/spotify-agent.md +1 -1
- package/extracted-skill/tracking-events-generator/agents/webhook-agent.md +4 -4
- package/extracted-skill/tracking-events-generator/agents/youtube-agent.md +3 -3
- package/package.json +81 -76
- package/server-edge-tracker/index.js +780 -0
- package/server-edge-tracker/modules/db.js +531 -0
- package/server-edge-tracker/modules/dispatch/ga4.js +65 -0
- package/server-edge-tracker/modules/dispatch/meta.js +103 -0
- package/server-edge-tracker/modules/dispatch/platforms.js +237 -0
- package/server-edge-tracker/modules/dispatch/tiktok.js +100 -0
- package/server-edge-tracker/modules/dispatch/whatsapp.js +233 -0
- package/server-edge-tracker/modules/intelligence.js +204 -0
- package/server-edge-tracker/modules/ml/bidding.js +245 -0
- package/server-edge-tracker/modules/ml/fraud.js +301 -0
- package/server-edge-tracker/modules/ml/ltv.js +320 -0
- package/server-edge-tracker/modules/ml/segmentation.js +316 -0
- package/server-edge-tracker/modules/utils.js +89 -0
- package/server-edge-tracker/schema-indexes.sql +67 -0
- package/server-edge-tracker/wrangler.toml +2 -0
|
@@ -0,0 +1,237 @@
|
|
|
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
|
+
|
|
9
|
+
// ── Pinterest Conversions API v5 ──────────────────────────────────────────────
|
|
10
|
+
export async function sendPinterestCapi(env, eventName, payload, request, ctx) {
|
|
11
|
+
if (!env.PINTEREST_ACCESS_TOKEN || !env.PINTEREST_AD_ACCOUNT_ID) {
|
|
12
|
+
return { skipped: 'Pinterest credentials not set' };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const { email, phone, userId, eventId, pageUrl, value, currency, contentIds, contentName } = payload;
|
|
16
|
+
const phoneNorm = normalizePhone(phone);
|
|
17
|
+
|
|
18
|
+
const pinterestEventMap = {
|
|
19
|
+
PageView: 'pagevisit', ViewContent: 'pagevisit', Lead: 'lead', Purchase: 'checkout',
|
|
20
|
+
AddToCart: 'addtocart', InitiateCheckout: 'checkout', CompleteRegistration: 'signup',
|
|
21
|
+
Search: 'search', Contact: 'lead',
|
|
22
|
+
};
|
|
23
|
+
const pEvent = pinterestEventMap[eventName] || 'custom';
|
|
24
|
+
|
|
25
|
+
const userData = {
|
|
26
|
+
...(email && { em: [await sha256(email)] }),
|
|
27
|
+
...(phoneNorm && { ph: [await sha256(phoneNorm)] }),
|
|
28
|
+
...(userId && { external_id: [await sha256(String(userId))] }),
|
|
29
|
+
client_ip_address: request.headers.get('CF-Connecting-IP') || '',
|
|
30
|
+
client_user_agent: request.headers.get('User-Agent') || '',
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const body = {
|
|
34
|
+
data: [{
|
|
35
|
+
event_name: pEvent,
|
|
36
|
+
action_source: 'web',
|
|
37
|
+
event_time: Math.floor(Date.now() / 1000),
|
|
38
|
+
event_id: eventId || `cdp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
|
39
|
+
event_source_url: pageUrl || '',
|
|
40
|
+
user_data: userData,
|
|
41
|
+
custom_data: {
|
|
42
|
+
currency: (currency || 'BRL').toUpperCase(),
|
|
43
|
+
value: value ? String(parseFloat(value)) : '0',
|
|
44
|
+
...(contentIds?.length > 0 && { content_ids: contentIds.map(String) }),
|
|
45
|
+
...(contentName && { content_name: contentName }),
|
|
46
|
+
content_type: 'product',
|
|
47
|
+
},
|
|
48
|
+
}],
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const res = await fetch(
|
|
53
|
+
`https://api.pinterest.com/v5/ad_accounts/${env.PINTEREST_AD_ACCOUNT_ID}/events`,
|
|
54
|
+
{ method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${env.PINTEREST_ACCESS_TOKEN}` }, body: JSON.stringify(body) }
|
|
55
|
+
);
|
|
56
|
+
const data = await res.json();
|
|
57
|
+
if (!res.ok) {
|
|
58
|
+
const msg = data.message || data.code || String(res.status);
|
|
59
|
+
console.error('Pinterest CAPI error:', res.status, msg);
|
|
60
|
+
if (env.DB && ctx) ctx.waitUntil(logApiFailure(env.DB, 'pinterest', eventName, String(res.status), msg, body.data[0].event_id, JSON.stringify(body)));
|
|
61
|
+
}
|
|
62
|
+
return data;
|
|
63
|
+
} catch (err) {
|
|
64
|
+
console.error('Pinterest CAPI fetch failed:', err.message);
|
|
65
|
+
if (env.DB && ctx) ctx.waitUntil(logApiFailure(env.DB, 'pinterest', eventName, 'FETCH_ERROR', err.message, null, JSON.stringify(body)));
|
|
66
|
+
return { error: err.message };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── Reddit Conversions API v2.0 ───────────────────────────────────────────────
|
|
71
|
+
export async function sendRedditCapi(env, eventName, payload, request, ctx) {
|
|
72
|
+
if (!env.REDDIT_ACCESS_TOKEN || !env.REDDIT_AD_ACCOUNT_ID) {
|
|
73
|
+
return { skipped: 'Reddit credentials not set' };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const { email, phone, userId, eventId, pageUrl, value, currency } = payload;
|
|
77
|
+
const phoneNorm = normalizePhone(phone);
|
|
78
|
+
|
|
79
|
+
const redditEventMap = {
|
|
80
|
+
PageView: 'PageVisit', ViewContent: 'ViewContent', Lead: 'Lead', Purchase: 'Purchase',
|
|
81
|
+
AddToCart: 'AddToCart', InitiateCheckout: 'Purchase', CompleteRegistration: 'SignUp',
|
|
82
|
+
Search: 'Search', Contact: 'Lead',
|
|
83
|
+
};
|
|
84
|
+
const rEvent = redditEventMap[eventName] || 'Custom';
|
|
85
|
+
|
|
86
|
+
const user = {
|
|
87
|
+
...(email && { email: { value: await sha256(email) } }),
|
|
88
|
+
...(phoneNorm && { phoneNumber: { value: await sha256(phoneNorm) } }),
|
|
89
|
+
...(userId && { externalId: { value: await sha256(String(userId)) } }),
|
|
90
|
+
ipAddress: { value: request.headers.get('CF-Connecting-IP') || '' },
|
|
91
|
+
userAgent: { value: request.headers.get('User-Agent') || '' },
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const event = {
|
|
95
|
+
event_at: new Date().toISOString(),
|
|
96
|
+
event_type: { tracking_type: rEvent, ...(eventName === 'InitiateCheckout' && { conversion_type: 'BEGIN_CHECKOUT' }) },
|
|
97
|
+
click_id: payload.rdtClid || '',
|
|
98
|
+
event_metadata: {
|
|
99
|
+
currency: (currency || 'BRL').toUpperCase(),
|
|
100
|
+
value_decimal: String(value || 0),
|
|
101
|
+
item_count: '1',
|
|
102
|
+
conversion_id: eventId || `cdp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
|
103
|
+
},
|
|
104
|
+
user,
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const body = { events: [event] };
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
const res = await fetch(
|
|
111
|
+
`https://ads-api.reddit.com/api/v2.0/conversions/events/${env.REDDIT_AD_ACCOUNT_ID}`,
|
|
112
|
+
{ method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${env.REDDIT_ACCESS_TOKEN}` }, body: JSON.stringify(body) }
|
|
113
|
+
);
|
|
114
|
+
if (!res.ok) {
|
|
115
|
+
const txt = await res.text();
|
|
116
|
+
console.error('Reddit CAPI error:', txt);
|
|
117
|
+
if (env.DB && ctx) ctx.waitUntil(logApiFailure(env.DB, 'reddit', eventName, String(res.status), txt, event.event_metadata.conversion_id, JSON.stringify(body)));
|
|
118
|
+
return { error: `HTTP ${res.status}` };
|
|
119
|
+
}
|
|
120
|
+
return await res.json();
|
|
121
|
+
} catch (err) {
|
|
122
|
+
console.error('Reddit CAPI fetch failed:', err.message);
|
|
123
|
+
if (env.DB && ctx) ctx.waitUntil(logApiFailure(env.DB, 'reddit', eventName, 'FETCH_ERROR', err.message, null, JSON.stringify(body)));
|
|
124
|
+
return { error: err.message };
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── LinkedIn Conversions API (LinkedIn-Version: 202401) ───────────────────────
|
|
129
|
+
export async function sendLinkedInCapi(env, eventName, payload, request, ctx) {
|
|
130
|
+
if (!env.LINKEDIN_ACCESS_TOKEN || !env.LINKEDIN_CONVERSION_ID) {
|
|
131
|
+
return { skipped: 'LinkedIn credentials not set' };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const { email, phone, firstName, lastName, userId, eventId, pageUrl, value, currency } = payload;
|
|
135
|
+
const phoneNorm = normalizePhone(phone);
|
|
136
|
+
|
|
137
|
+
const linkedInEventMap = {
|
|
138
|
+
Lead: 'LEAD', Purchase: 'PURCHASE', CompleteRegistration: 'REGISTRATION',
|
|
139
|
+
AddToCart: 'ADD_TO_CART', InitiateCheckout: 'OTHER', ViewContent: 'OTHER',
|
|
140
|
+
PageView: 'OTHER', Contact: 'LEAD',
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const userInfo = {
|
|
144
|
+
...(email && { 'SHA256_EMAIL': await sha256(email) }),
|
|
145
|
+
...(phoneNorm && { 'SHA256_PHONE': await sha256(phoneNorm) }),
|
|
146
|
+
...(firstName && { 'SHA256_FIRST_NAME': await sha256(firstName.toLowerCase().trim()) }),
|
|
147
|
+
...(lastName && { 'SHA256_LAST_NAME': await sha256(lastName.toLowerCase().trim()) }),
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const body = {
|
|
151
|
+
conversion: `urn:li:conversion:${env.LINKEDIN_CONVERSION_ID}`,
|
|
152
|
+
conversionHappenedAt: Date.now(),
|
|
153
|
+
conversionValue: value ? { currencyCode: (currency || 'BRL').toUpperCase(), amount: String(parseFloat(value)) } : undefined,
|
|
154
|
+
eventId: eventId || `cdp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
|
155
|
+
...(Object.keys(userInfo).length > 0 && { user: { userIds: Object.entries(userInfo).map(([idType, idValue]) => ({ idType, idValue })) } }),
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
const res = await fetch('https://api.linkedin.com/rest/conversionEvents', {
|
|
160
|
+
method: 'POST',
|
|
161
|
+
headers: {
|
|
162
|
+
'Content-Type': 'application/json',
|
|
163
|
+
Authorization: `Bearer ${env.LINKEDIN_ACCESS_TOKEN}`,
|
|
164
|
+
'LinkedIn-Version': '202401',
|
|
165
|
+
'X-Restli-Protocol-Version': '2.0.0',
|
|
166
|
+
},
|
|
167
|
+
body: JSON.stringify(body),
|
|
168
|
+
});
|
|
169
|
+
if (!res.ok) {
|
|
170
|
+
const txt = await res.text();
|
|
171
|
+
console.error('LinkedIn CAPI error:', txt);
|
|
172
|
+
if (env.DB && ctx) ctx.waitUntil(logApiFailure(env.DB, 'linkedin', eventName, String(res.status), txt, body.eventId, JSON.stringify(body)));
|
|
173
|
+
return { error: `HTTP ${res.status}` };
|
|
174
|
+
}
|
|
175
|
+
return { ok: true };
|
|
176
|
+
} catch (err) {
|
|
177
|
+
console.error('LinkedIn CAPI fetch failed:', err.message);
|
|
178
|
+
if (env.DB && ctx) ctx.waitUntil(logApiFailure(env.DB, 'linkedin', eventName, 'FETCH_ERROR', err.message, null, JSON.stringify(body)));
|
|
179
|
+
return { error: err.message };
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ── Spotify Conversions API v1 ────────────────────────────────────────────────
|
|
184
|
+
export async function sendSpotifyCapi(env, eventName, payload, request, ctx) {
|
|
185
|
+
if (!env.SPOTIFY_ACCESS_TOKEN || !env.SPOTIFY_AD_ACCOUNT_ID) {
|
|
186
|
+
return { skipped: 'Spotify credentials not set' };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const { email, phone, userId, eventId, pageUrl, value, currency } = payload;
|
|
190
|
+
const phoneNorm = normalizePhone(phone);
|
|
191
|
+
|
|
192
|
+
const spotifyEventMap = {
|
|
193
|
+
Purchase: 'PURCHASE', Lead: 'LEAD', CompleteRegistration: 'SIGN_UP',
|
|
194
|
+
AddToCart: 'ADD_TO_CART', InitiateCheckout: 'INITIATE_CHECKOUT',
|
|
195
|
+
ViewContent: 'VIEW_CONTENT', PageView: 'PAGE_VIEW', Contact: 'LEAD',
|
|
196
|
+
};
|
|
197
|
+
const spEvent = spotifyEventMap[eventName] || 'CUSTOM';
|
|
198
|
+
|
|
199
|
+
const user = {
|
|
200
|
+
...(email && { hashed_email: await sha256(email) }),
|
|
201
|
+
...(phoneNorm && { hashed_phone: await sha256(phoneNorm) }),
|
|
202
|
+
...(userId && { user_id: userId }),
|
|
203
|
+
ip_address: request.headers.get('CF-Connecting-IP') || '',
|
|
204
|
+
user_agent: request.headers.get('User-Agent') || '',
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const body = {
|
|
208
|
+
data: [{
|
|
209
|
+
event_id: eventId || `cdp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
|
210
|
+
event_type: spEvent,
|
|
211
|
+
event_time: Math.floor(Date.now() / 1000),
|
|
212
|
+
url: pageUrl || '',
|
|
213
|
+
user,
|
|
214
|
+
...(value !== undefined && {
|
|
215
|
+
value: { currency: (currency || 'BRL').toUpperCase(), amount: parseFloat(value) },
|
|
216
|
+
}),
|
|
217
|
+
}],
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
try {
|
|
221
|
+
const res = await fetch(
|
|
222
|
+
`https://advertising-api.spotify.com/conversion/v1/accounts/${env.SPOTIFY_AD_ACCOUNT_ID}/events`,
|
|
223
|
+
{ method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${env.SPOTIFY_ACCESS_TOKEN}` }, body: JSON.stringify(body) }
|
|
224
|
+
);
|
|
225
|
+
if (!res.ok) {
|
|
226
|
+
const txt = await res.text();
|
|
227
|
+
console.error('Spotify CAPI error:', txt);
|
|
228
|
+
if (env.DB && ctx) ctx.waitUntil(logApiFailure(env.DB, 'spotify', eventName, String(res.status), txt, body.data[0].event_id, JSON.stringify(body)));
|
|
229
|
+
return { error: `HTTP ${res.status}` };
|
|
230
|
+
}
|
|
231
|
+
return await res.json();
|
|
232
|
+
} catch (err) {
|
|
233
|
+
console.error('Spotify CAPI fetch failed:', err.message);
|
|
234
|
+
if (env.DB && ctx) ctx.waitUntil(logApiFailure(env.DB, 'spotify', eventName, 'FETCH_ERROR', err.message, null, JSON.stringify(body)));
|
|
235
|
+
return { error: err.message };
|
|
236
|
+
}
|
|
237
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
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
|
+
|
|
9
|
+
export async function sendTikTokApi(env, eventName, payload, request, ctx) {
|
|
10
|
+
if (!env.TIKTOK_ACCESS_TOKEN) return { skipped: 'TIKTOK_ACCESS_TOKEN not set' };
|
|
11
|
+
|
|
12
|
+
const pixelId = env.TIKTOK_PIXEL_ID;
|
|
13
|
+
if (!pixelId || pixelId === 'SEU_TIKTOK_PIXEL_ID') return { skipped: 'TIKTOK_PIXEL_ID not configured' };
|
|
14
|
+
|
|
15
|
+
const {
|
|
16
|
+
email, phone, firstName, lastName,
|
|
17
|
+
fbp, fbc, ttp, ttclid, userId,
|
|
18
|
+
eventId, pageUrl,
|
|
19
|
+
value, currency,
|
|
20
|
+
contentIds, contentName, contentType,
|
|
21
|
+
} = payload;
|
|
22
|
+
|
|
23
|
+
const phoneNorm = normalizePhone(phone);
|
|
24
|
+
|
|
25
|
+
const user = {
|
|
26
|
+
...(email && { email: await sha256(email) }),
|
|
27
|
+
...(phoneNorm && { phone_number: await sha256(phoneNorm) }),
|
|
28
|
+
...(userId && { external_id: await sha256(String(userId)) }),
|
|
29
|
+
...(ttp && { ttp }),
|
|
30
|
+
...(ttclid && { ttclid }),
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const properties = {
|
|
34
|
+
...(value !== undefined && { value: parseFloat(value) }),
|
|
35
|
+
...(currency && { currency: String(currency).toUpperCase() }),
|
|
36
|
+
...(contentIds && contentIds.length > 0 && {
|
|
37
|
+
contents: contentIds.map(id => ({
|
|
38
|
+
content_id: String(id),
|
|
39
|
+
content_name: contentName || '',
|
|
40
|
+
content_type: contentType || 'product',
|
|
41
|
+
quantity: 1,
|
|
42
|
+
price: value ? parseFloat(value) : 0,
|
|
43
|
+
})),
|
|
44
|
+
}),
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const event = {
|
|
48
|
+
event: eventName,
|
|
49
|
+
event_time: Math.floor(Date.now() / 1000),
|
|
50
|
+
event_id: eventId || `cdp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
|
51
|
+
user,
|
|
52
|
+
page: {
|
|
53
|
+
url: pageUrl || `https://${env.SITE_DOMAIN}`,
|
|
54
|
+
referrer: request.headers.get('Referer') || '',
|
|
55
|
+
},
|
|
56
|
+
...(Object.keys(properties).length > 0 && { properties }),
|
|
57
|
+
context: {
|
|
58
|
+
ip: request.headers.get('CF-Connecting-IP') || '',
|
|
59
|
+
user_agent: request.headers.get('User-Agent') || '',
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const body = {
|
|
64
|
+
event_source: 'web',
|
|
65
|
+
event_source_id: pixelId,
|
|
66
|
+
data: [event],
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// Endpoint canônico: sempre /v1.3/event/track/
|
|
70
|
+
const endpoint = 'https://business-api.tiktok.com/open_api/v1.3/event/track/';
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
const res = await fetch(endpoint, {
|
|
74
|
+
method: 'POST',
|
|
75
|
+
headers: {
|
|
76
|
+
'Content-Type': 'application/json',
|
|
77
|
+
'Access-Token': env.TIKTOK_ACCESS_TOKEN,
|
|
78
|
+
},
|
|
79
|
+
body: JSON.stringify(body),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const data = await res.json();
|
|
83
|
+
if (!res.ok || data.code !== 0) {
|
|
84
|
+
console.error('TikTok Events API error:', res.status, data.message || data.code || 'unknown');
|
|
85
|
+
|
|
86
|
+
if (env.DB && ctx) {
|
|
87
|
+
ctx.waitUntil(logApiFailure(env.DB, 'tiktok', eventName, String(data.code || res.status), data.message || 'TikTok API error', event.event_id, JSON.stringify(body)));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return data;
|
|
91
|
+
} catch (err) {
|
|
92
|
+
console.error('TikTok Events API fetch failed:', err.message);
|
|
93
|
+
|
|
94
|
+
if (env.DB && ctx) {
|
|
95
|
+
ctx.waitUntil(logApiFailure(env.DB, 'tiktok', eventName, 'FETCH_ERROR', err.message, null, JSON.stringify(body)));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return { error: err.message };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
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
|
+
|
|
9
|
+
// ── sendWhatsApp — envia mensagem via Meta Cloud API ──────────────────────────
|
|
10
|
+
export async function sendWhatsApp(env, tipo, payload, options = {}) {
|
|
11
|
+
if (!env.WHATSAPP_PHONE_NUMBER_ID || !env.WHATSAPP_ACCESS_TOKEN || !env.WA_NOTIFY_NUMBER) {
|
|
12
|
+
return { skipped: 'WhatsApp não configurado' };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const to = options.to || env.WA_NOTIFY_NUMBER;
|
|
16
|
+
|
|
17
|
+
if (options.template) {
|
|
18
|
+
const { name, language = 'pt_BR', components = [] } = options.template;
|
|
19
|
+
const body = { messaging_product: 'whatsapp', to, type: 'template', template: { name, language: { code: language }, components } };
|
|
20
|
+
return _sendWARequest(env, body);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (options.mediaType && options.mediaUrl) {
|
|
24
|
+
const mediaPayload = { link: options.mediaUrl };
|
|
25
|
+
if (options.caption) mediaPayload.caption = options.caption;
|
|
26
|
+
if (options.filename) mediaPayload.filename = options.filename;
|
|
27
|
+
const body = { messaging_product: 'whatsapp', to, type: options.mediaType, [options.mediaType]: mediaPayload };
|
|
28
|
+
return _sendWARequest(env, body);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (options.interactive === 'buttons' && options.buttons?.length) {
|
|
32
|
+
const body = {
|
|
33
|
+
messaging_product: 'whatsapp', to, type: 'interactive',
|
|
34
|
+
interactive: {
|
|
35
|
+
type: 'button',
|
|
36
|
+
body: { text: options.bodyText || '' },
|
|
37
|
+
action: {
|
|
38
|
+
buttons: options.buttons.slice(0, 3).map(b => ({ type: 'reply', reply: { id: b.id, title: b.title } })),
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
return _sendWARequest(env, body);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (options.interactive === 'list' && options.rows?.length) {
|
|
46
|
+
const body = {
|
|
47
|
+
messaging_product: 'whatsapp', to, type: 'interactive',
|
|
48
|
+
interactive: {
|
|
49
|
+
type: 'list',
|
|
50
|
+
body: { text: options.bodyText || '' },
|
|
51
|
+
action: { button: options.listButton || 'Ver opções', sections: [{ rows: options.rows.slice(0, 10) }] },
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
return _sendWARequest(env, body);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Text fallback (dentro da janela de 24h)
|
|
58
|
+
const nome = [payload.firstName, payload.lastName].filter(Boolean).join(' ') || 'sem nome';
|
|
59
|
+
const valor = payload.value ? `R$ ${parseFloat(payload.value).toFixed(2)}` : '—';
|
|
60
|
+
const utm = payload.utmSource || 'direto';
|
|
61
|
+
const produto = payload.contentName || '';
|
|
62
|
+
|
|
63
|
+
let texto = '';
|
|
64
|
+
if (tipo === 'Purchase') {
|
|
65
|
+
texto =
|
|
66
|
+
`🛒 *Nova Venda!*\n\n` +
|
|
67
|
+
`👤 ${nome}\n📧 ${payload.email || '—'}\n📱 ${payload.phone || '—'}\n` +
|
|
68
|
+
`💰 ${valor}\n${produto ? `📦 ${produto}\n` : ''}` +
|
|
69
|
+
`🔗 UTM: ${utm}\n🕐 ${new Date().toLocaleString('pt-BR', { timeZone: 'America/Sao_Paulo' })}`;
|
|
70
|
+
} else if (tipo === 'Lead') {
|
|
71
|
+
texto =
|
|
72
|
+
`📋 *Novo Lead!*\n\n` +
|
|
73
|
+
`📧 ${payload.email || '—'}\n🔗 UTM: ${utm}\n` +
|
|
74
|
+
`🌐 ${payload.pageUrl || '—'}\n🕐 ${new Date().toLocaleString('pt-BR', { timeZone: 'America/Sao_Paulo' })}`;
|
|
75
|
+
} else {
|
|
76
|
+
return { skipped: `tipo ${tipo} não suportado sem template` };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return _sendWARequest(env, { messaging_product: 'whatsapp', to, type: 'text', text: { body: texto } });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── _sendWARequest — executor interno ─────────────────────────────────────────
|
|
83
|
+
async function _sendWARequest(env, body) {
|
|
84
|
+
try {
|
|
85
|
+
const res = await fetch(`https://graph.facebook.com/v22.0/${env.WHATSAPP_PHONE_NUMBER_ID}/messages`, {
|
|
86
|
+
method: 'POST',
|
|
87
|
+
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${env.WHATSAPP_ACCESS_TOKEN}` },
|
|
88
|
+
body: JSON.stringify(body),
|
|
89
|
+
});
|
|
90
|
+
const data = await res.json();
|
|
91
|
+
if (!res.ok) console.error('WhatsApp Meta API error:', res.status, data.error?.message || 'unknown');
|
|
92
|
+
return { ok: res.ok, status: res.status, data };
|
|
93
|
+
} catch (err) {
|
|
94
|
+
console.error('WhatsApp Meta API failed:', err.message);
|
|
95
|
+
return { ok: false, error: err.message };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── sendCallMeBot — alertas de sistema via WhatsApp ───────────────────────────
|
|
100
|
+
export async function sendCallMeBot(env, mensagem) {
|
|
101
|
+
if (!env.CALLMEBOT_PHONE || !env.CALLMEBOT_APIKEY) {
|
|
102
|
+
return { skipped: 'CallMeBot não configurado' };
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
const url = `https://api.callmebot.com/whatsapp.php?phone=${encodeURIComponent(env.CALLMEBOT_PHONE)}&text=${encodeURIComponent(mensagem)}&apikey=${env.CALLMEBOT_APIKEY}`;
|
|
106
|
+
const res = await fetch(url);
|
|
107
|
+
return { ok: res.ok, status: res.status };
|
|
108
|
+
} catch (err) {
|
|
109
|
+
console.error('CallMeBot failed:', err.message);
|
|
110
|
+
return { ok: false, error: err.message };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── processWhatsAppWebhook — CTWA (Click to WhatsApp) ────────────────────────
|
|
115
|
+
export async function processWhatsAppWebhook(env, body, request, ctx) {
|
|
116
|
+
const entry = body?.entry?.[0];
|
|
117
|
+
const change = entry?.changes?.find(c => c.field === 'messages');
|
|
118
|
+
if (!change) return { skipped: 'no messages field' };
|
|
119
|
+
|
|
120
|
+
const messages = change.value?.messages;
|
|
121
|
+
if (!messages || messages.length === 0) return { skipped: 'no messages' };
|
|
122
|
+
|
|
123
|
+
const results = [];
|
|
124
|
+
|
|
125
|
+
for (const message of messages) {
|
|
126
|
+
const phone = message.from;
|
|
127
|
+
const wamid = message.id;
|
|
128
|
+
const referral = message.referral || {};
|
|
129
|
+
const ctwaClid = referral.ctwa_clid || null;
|
|
130
|
+
const adId = referral.source_id || null;
|
|
131
|
+
const sourceUrl = referral.source_url || null;
|
|
132
|
+
const headline = referral.headline || null;
|
|
133
|
+
const messageBody = message.text?.body || message.type || '';
|
|
134
|
+
|
|
135
|
+
if (!phone) { results.push({ skipped: 'no phone' }); continue; }
|
|
136
|
+
|
|
137
|
+
const phoneNorm = normalizePhone(phone) || phone;
|
|
138
|
+
const phoneHash = await sha256(phoneNorm);
|
|
139
|
+
|
|
140
|
+
// Deduplicação por wamid
|
|
141
|
+
if (env.DB && wamid) {
|
|
142
|
+
try {
|
|
143
|
+
const existing = await env.DB.prepare('SELECT id FROM whatsapp_contacts WHERE wamid = ?').bind(wamid).first();
|
|
144
|
+
if (existing) { results.push({ skipped: 'duplicate wamid', wamid }); continue; }
|
|
145
|
+
} catch { /* não bloquear se D1 falhar */ }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const eventId = `ctwa_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
149
|
+
|
|
150
|
+
if (env.DB) {
|
|
151
|
+
ctx.waitUntil(
|
|
152
|
+
env.DB.prepare(
|
|
153
|
+
`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,?,?)`
|
|
154
|
+
).bind(phoneHash, phoneNorm, wamid || null, ctwaClid, adId, sourceUrl, headline, eventId, messageBody || null).run()
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const capiEvent = {
|
|
159
|
+
event_name: 'Contact',
|
|
160
|
+
event_time: Math.floor(Date.now() / 1000),
|
|
161
|
+
event_id: eventId,
|
|
162
|
+
action_source: 'chat',
|
|
163
|
+
user_data: {
|
|
164
|
+
ph: phoneHash,
|
|
165
|
+
...(ctwaClid && { ctwa_clid: ctwaClid }),
|
|
166
|
+
client_ip_address: request.headers.get('CF-Connecting-IP') || '',
|
|
167
|
+
client_user_agent: request.headers.get('User-Agent') || '',
|
|
168
|
+
},
|
|
169
|
+
...(sourceUrl && { event_source_url: sourceUrl }),
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
ctx.waitUntil(
|
|
173
|
+
(async () => {
|
|
174
|
+
try {
|
|
175
|
+
const requestBody = { data: [capiEvent], access_token: env.META_ACCESS_TOKEN };
|
|
176
|
+
if (env.META_TEST_CODE) requestBody.test_event_code = env.META_TEST_CODE;
|
|
177
|
+
|
|
178
|
+
const res = await fetch(
|
|
179
|
+
`https://graph.facebook.com/v22.0/${env.META_PIXEL_ID}/events`,
|
|
180
|
+
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(requestBody) }
|
|
181
|
+
);
|
|
182
|
+
const data = await res.json();
|
|
183
|
+
|
|
184
|
+
if (res.ok && env.DB && wamid) {
|
|
185
|
+
await env.DB.prepare('UPDATE whatsapp_contacts SET capi_sent = 1 WHERE wamid = ?').bind(wamid).run();
|
|
186
|
+
} else if (!res.ok) {
|
|
187
|
+
console.error('[CTWA] Meta CAPI error:', res.status, data.error?.message || 'unknown');
|
|
188
|
+
if (env.DB) {
|
|
189
|
+
await logApiFailure(env.DB, 'meta', 'Contact', data.error?.code || res.status, data.error?.message || 'CTWA CAPI error', eventId, JSON.stringify(requestBody));
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
} catch (err) {
|
|
193
|
+
console.error('[CTWA] Meta CAPI fetch failed:', err.message);
|
|
194
|
+
}
|
|
195
|
+
})()
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
ctx.waitUntil(
|
|
199
|
+
saveLead(env, 'Contact', {
|
|
200
|
+
phone: phoneNorm, eventId, pageUrl: sourceUrl,
|
|
201
|
+
utmSource: 'whatsapp_ctwa', utmMedium: 'paid_social',
|
|
202
|
+
}, request, 'whatsapp')
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
results.push({ ok: true, phone: phoneNorm.slice(0, 4) + '****', ctwa_clid: ctwaClid ? 'present' : 'absent', event_id: eventId });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return { processed: results.length, results };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ── verifyHmac — validação constant-time de assinatura HMAC-SHA256 ─────────────
|
|
212
|
+
export async function verifyHmac(secret, rawBody, receivedSignature) {
|
|
213
|
+
if (!secret || !receivedSignature) return false;
|
|
214
|
+
try {
|
|
215
|
+
const key = await crypto.subtle.importKey(
|
|
216
|
+
'raw',
|
|
217
|
+
new TextEncoder().encode(secret),
|
|
218
|
+
{ name: 'HMAC', hash: 'SHA-256' },
|
|
219
|
+
false,
|
|
220
|
+
['sign']
|
|
221
|
+
);
|
|
222
|
+
const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(rawBody));
|
|
223
|
+
const computed = Array.from(new Uint8Array(sig)).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
224
|
+
if (computed.length !== receivedSignature.length) return false;
|
|
225
|
+
let diff = 0;
|
|
226
|
+
for (let i = 0; i < computed.length; i++) {
|
|
227
|
+
diff |= computed.charCodeAt(i) ^ receivedSignature.charCodeAt(i);
|
|
228
|
+
}
|
|
229
|
+
return diff === 0;
|
|
230
|
+
} catch {
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
}
|