@erlancarreira/evolution-chat 0.1.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 +202 -0
- package/api/index.cjs +278 -0
- package/api/index.cjs.map +1 -0
- package/api/index.d.cts +65 -0
- package/api/index.d.ts +65 -0
- package/api/index.js +244 -0
- package/api/index.js.map +1 -0
- package/bridge/index.cjs +412 -0
- package/bridge/index.cjs.map +1 -0
- package/bridge/index.d.cts +245 -0
- package/bridge/index.d.ts +245 -0
- package/bridge/index.js +381 -0
- package/bridge/index.js.map +1 -0
- package/next/index.cjs +218 -0
- package/next/index.cjs.map +1 -0
- package/next/index.d.cts +205 -0
- package/next/index.d.ts +205 -0
- package/next/index.js +190 -0
- package/next/index.js.map +1 -0
- package/package.json +44 -0
- package/transports/supabase/index.cjs +74 -0
- package/transports/supabase/index.cjs.map +1 -0
- package/transports/supabase/index.d.cts +45 -0
- package/transports/supabase/index.d.ts +45 -0
- package/transports/supabase/index.js +48 -0
- package/transports/supabase/index.js.map +1 -0
- package/widget/index.cjs +1045 -0
- package/widget/index.cjs.map +1 -0
- package/widget/index.d.cts +122 -0
- package/widget/index.d.ts +122 -0
- package/widget/index.js +1009 -0
- package/widget/index.js.map +1 -0
- package/widget/styles.css +355 -0
- package/widget/styles.css.map +1 -0
- package/widget/styles.d.cts +2 -0
- package/widget/styles.d.ts +2 -0
- package/widget-embed/evolution-chat.iife.js +28706 -0
package/bridge/index.cjs
ADDED
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/bridge/index.ts
|
|
21
|
+
var bridge_exports = {};
|
|
22
|
+
__export(bridge_exports, {
|
|
23
|
+
ChatBridge: () => ChatBridge,
|
|
24
|
+
ChatError: () => ChatError,
|
|
25
|
+
ConversationRouter: () => ConversationRouter,
|
|
26
|
+
formatFirstMessage: () => formatFirstMessage,
|
|
27
|
+
formatFollowup: () => formatFollowup
|
|
28
|
+
});
|
|
29
|
+
module.exports = __toCommonJS(bridge_exports);
|
|
30
|
+
|
|
31
|
+
// src/bridge/router.ts
|
|
32
|
+
var GROUP_JID_SUFFIX = "@g.us";
|
|
33
|
+
var ConversationRouter = class {
|
|
34
|
+
constructor(store) {
|
|
35
|
+
this.store = store;
|
|
36
|
+
}
|
|
37
|
+
store;
|
|
38
|
+
/**
|
|
39
|
+
* `now` faz parte do contrato para que a bridge propague uma única leitura de relógio
|
|
40
|
+
* por mensagem (touch/close/limite de horário nas próximas tasks); o router puro ainda
|
|
41
|
+
* não a consome.
|
|
42
|
+
*/
|
|
43
|
+
async decide(msg, now) {
|
|
44
|
+
void now;
|
|
45
|
+
if (!msg.jid.endsWith(GROUP_JID_SUFFIX)) return { action: "ignore" };
|
|
46
|
+
const session = await this.store.getSessionByGroupJid(msg.jid);
|
|
47
|
+
if (session === null) return { action: "unknown_session" };
|
|
48
|
+
if (await this.store.isEcho(session.id, msg.waMessageId)) return { action: "echo" };
|
|
49
|
+
if (typeof msg.text !== "string" || msg.text.length === 0) return { action: "not_text" };
|
|
50
|
+
const direction = msg.fromMe ? "owner" : "visitor";
|
|
51
|
+
return { action: "route", session, direction, text: msg.text ?? "" };
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// src/bridge/format.ts
|
|
56
|
+
function formatFirstMessage(code, name, text) {
|
|
57
|
+
return `\u{1F195} #${code} \u2014 ${name} (site):
|
|
58
|
+
${text}`;
|
|
59
|
+
}
|
|
60
|
+
function formatFollowup(name, text) {
|
|
61
|
+
return `${name} (site): ${text}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// src/errors.ts
|
|
65
|
+
var ChatError = class extends Error {
|
|
66
|
+
code;
|
|
67
|
+
cause;
|
|
68
|
+
constructor(message, code, cause) {
|
|
69
|
+
super(message);
|
|
70
|
+
this.name = "ChatError";
|
|
71
|
+
this.code = code;
|
|
72
|
+
this.cause = cause;
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// src/api/ids.ts
|
|
77
|
+
var SESSION_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789";
|
|
78
|
+
var SESSION_CODE_LENGTH = 4;
|
|
79
|
+
var SESSION_ACCEPT_LIMIT = 256 - 256 % SESSION_ALPHABET.length;
|
|
80
|
+
var BASE64URL_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
|
81
|
+
var TOKEN_BYTES = 24;
|
|
82
|
+
function randomChar(alphabet, acceptLimit) {
|
|
83
|
+
const buffer = new Uint8Array(1);
|
|
84
|
+
for (; ; ) {
|
|
85
|
+
globalThis.crypto.getRandomValues(buffer);
|
|
86
|
+
const byte = buffer[0];
|
|
87
|
+
if (byte !== void 0 && byte < acceptLimit) {
|
|
88
|
+
return alphabet.charAt(byte % alphabet.length);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function generateSessionCode() {
|
|
93
|
+
return Array.from({ length: SESSION_CODE_LENGTH }, () => randomChar(SESSION_ALPHABET, SESSION_ACCEPT_LIMIT)).join("");
|
|
94
|
+
}
|
|
95
|
+
function generateRealtimeToken() {
|
|
96
|
+
const bytes = new Uint8Array(TOKEN_BYTES);
|
|
97
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
98
|
+
let token = "";
|
|
99
|
+
let acc = 0;
|
|
100
|
+
let bits = 0;
|
|
101
|
+
for (const byte of bytes) {
|
|
102
|
+
acc = acc << 8 | byte;
|
|
103
|
+
bits += 8;
|
|
104
|
+
while (bits >= 6) {
|
|
105
|
+
bits -= 6;
|
|
106
|
+
token += BASE64URL_ALPHABET.charAt(acc >>> bits & 63);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return token;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// src/api/phone.ts
|
|
113
|
+
var NON_DIGITS = /\D+/g;
|
|
114
|
+
function normalizePhone(raw, defaultDdi = "55") {
|
|
115
|
+
const digits = raw.replace(NON_DIGITS, "");
|
|
116
|
+
if (!digits) throw new ChatError("Telefone vazio", "invalid_input");
|
|
117
|
+
const explicitDdi = raw.includes("+");
|
|
118
|
+
const withDdi = digits.length <= 11 && !explicitDdi && !digits.startsWith(defaultDdi) ? defaultDdi + digits : digits;
|
|
119
|
+
if (withDdi.length < 10 || withDdi.length > 15) {
|
|
120
|
+
throw new ChatError("Telefone inv\xE1lido", "invalid_input");
|
|
121
|
+
}
|
|
122
|
+
return withDdi;
|
|
123
|
+
}
|
|
124
|
+
function toWhatsappJid(phoneDigits) {
|
|
125
|
+
return `${phoneDigits}@s.whatsapp.net`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// src/api/webhook-parser.ts
|
|
129
|
+
function isRecord(value) {
|
|
130
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
131
|
+
}
|
|
132
|
+
function textFromMessage(message) {
|
|
133
|
+
if (!isRecord(message)) return null;
|
|
134
|
+
const conversation = message["conversation"];
|
|
135
|
+
if (typeof conversation === "string") {
|
|
136
|
+
const trimmed = conversation.trim();
|
|
137
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
138
|
+
}
|
|
139
|
+
const extended = message["extendedTextMessage"];
|
|
140
|
+
if (isRecord(extended)) {
|
|
141
|
+
const text = extended["text"];
|
|
142
|
+
if (typeof text === "string") {
|
|
143
|
+
const trimmed = text.trim();
|
|
144
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
function timestampSeconds(value) {
|
|
150
|
+
const seconds = typeof value === "number" ? value : typeof value === "string" ? Number(value) : Number.NaN;
|
|
151
|
+
return Number.isFinite(seconds) ? Math.floor(seconds) : 0;
|
|
152
|
+
}
|
|
153
|
+
function parseMessageItem(item) {
|
|
154
|
+
if (!isRecord(item)) return { kind: "ignored", reason: "messages.upsert sem data v\xE1lido" };
|
|
155
|
+
const key = isRecord(item["key"]) ? item["key"] : null;
|
|
156
|
+
const waMessageId = key?.["id"];
|
|
157
|
+
const jid = key?.["remoteJid"];
|
|
158
|
+
if (typeof waMessageId !== "string" || waMessageId.length === 0) {
|
|
159
|
+
return { kind: "ignored", reason: "mensagem sem key.id" };
|
|
160
|
+
}
|
|
161
|
+
if (typeof jid !== "string" || jid.length === 0) {
|
|
162
|
+
return { kind: "ignored", reason: "mensagem sem key.remoteJid" };
|
|
163
|
+
}
|
|
164
|
+
const text = textFromMessage(item["message"]);
|
|
165
|
+
if (text === null) {
|
|
166
|
+
return { kind: "ignored", reason: "mensagem sem texto (conversation/extendedTextMessage)" };
|
|
167
|
+
}
|
|
168
|
+
const senderJid = key?.["participant"];
|
|
169
|
+
return {
|
|
170
|
+
kind: "message",
|
|
171
|
+
event: {
|
|
172
|
+
waMessageId,
|
|
173
|
+
jid,
|
|
174
|
+
fromMe: key?.["fromMe"] === true,
|
|
175
|
+
senderJid: typeof senderJid === "string" ? senderJid : null,
|
|
176
|
+
text,
|
|
177
|
+
timestamp: timestampSeconds(item["messageTimestamp"]),
|
|
178
|
+
raw: item
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
function parseWebhookEvent(payload) {
|
|
183
|
+
if (!isRecord(payload)) return { kind: "ignored", reason: "payload n\xE3o \xE9 um objeto" };
|
|
184
|
+
const event = payload["event"];
|
|
185
|
+
if (typeof event !== "string") return { kind: "ignored", reason: "payload sem campo event" };
|
|
186
|
+
const data = payload["data"];
|
|
187
|
+
if (event === "connection.update") {
|
|
188
|
+
const state = isRecord(data) ? data["state"] : void 0;
|
|
189
|
+
if (typeof state === "string" && state.length > 0) return { kind: "connection", state };
|
|
190
|
+
return { kind: "ignored", reason: "connection.update sem state" };
|
|
191
|
+
}
|
|
192
|
+
if (event !== "messages.upsert") {
|
|
193
|
+
return { kind: "ignored", reason: `evento n\xE3o tratado: ${event}` };
|
|
194
|
+
}
|
|
195
|
+
const items = Array.isArray(data) ? data : [data];
|
|
196
|
+
let lastIgnored = { kind: "ignored", reason: "messages.upsert sem data v\xE1lido" };
|
|
197
|
+
for (const item of items) {
|
|
198
|
+
const parsed = parseMessageItem(item);
|
|
199
|
+
if (parsed.kind !== "ignored") return parsed;
|
|
200
|
+
lastIgnored = parsed;
|
|
201
|
+
}
|
|
202
|
+
return lastIgnored;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// src/bridge/bridge.ts
|
|
206
|
+
var NAME_MIN = 2;
|
|
207
|
+
var NAME_MAX = 60;
|
|
208
|
+
var MESSAGE_MIN = 1;
|
|
209
|
+
var MESSAGE_MAX = 1e3;
|
|
210
|
+
var RATE_LIMIT_WINDOW_MS = 10 * 60 * 1e3;
|
|
211
|
+
var RATE_LIMIT_MAX_SESSIONS = 5;
|
|
212
|
+
function honeypotSession(atIso) {
|
|
213
|
+
return {
|
|
214
|
+
id: "honeypot",
|
|
215
|
+
code: "XXXX",
|
|
216
|
+
realtimeToken: "",
|
|
217
|
+
visitorName: "",
|
|
218
|
+
visitorPhone: "",
|
|
219
|
+
visitorContact: null,
|
|
220
|
+
groupJid: null,
|
|
221
|
+
status: "closed",
|
|
222
|
+
createdAt: atIso,
|
|
223
|
+
lastMessageAt: null
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
function requireText(value, min, max, field) {
|
|
227
|
+
const trimmed = typeof value === "string" ? value.trim() : "";
|
|
228
|
+
if (trimmed.length < min || trimmed.length > max) {
|
|
229
|
+
throw new ChatError(`${field} deve ter entre ${min} e ${max} caracteres`, "invalid_input");
|
|
230
|
+
}
|
|
231
|
+
return trimmed;
|
|
232
|
+
}
|
|
233
|
+
var ChatBridge = class {
|
|
234
|
+
constructor(deps) {
|
|
235
|
+
this.deps = deps;
|
|
236
|
+
this.router = new ConversationRouter(deps.store);
|
|
237
|
+
this.clock = deps.clock ?? (() => /* @__PURE__ */ new Date());
|
|
238
|
+
}
|
|
239
|
+
deps;
|
|
240
|
+
router;
|
|
241
|
+
clock;
|
|
242
|
+
config = null;
|
|
243
|
+
/** Config injetada pelas rotas a cada request (setConfig → uso → getConfig). */
|
|
244
|
+
getConfig() {
|
|
245
|
+
if (this.config === null) {
|
|
246
|
+
throw new ChatError("Chat n\xE3o configurado", "store_error");
|
|
247
|
+
}
|
|
248
|
+
return this.config;
|
|
249
|
+
}
|
|
250
|
+
setConfig(cfg) {
|
|
251
|
+
this.config = cfg;
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Abre uma conversa: cria o grupo na Evolution, envia a primeira mensagem e persiste a
|
|
255
|
+
* sessão. Não publica no canal realtime — quem abriu o chat já tem a própria mensagem.
|
|
256
|
+
*/
|
|
257
|
+
async startChat(input) {
|
|
258
|
+
const atIso = this.clock().toISOString();
|
|
259
|
+
if (typeof input.honeypot === "string" && input.honeypot.trim().length > 0) {
|
|
260
|
+
return { session: honeypotSession(atIso), messages: [] };
|
|
261
|
+
}
|
|
262
|
+
const name = requireText(input.name, NAME_MIN, NAME_MAX, "Nome");
|
|
263
|
+
const message = requireText(input.message, MESSAGE_MIN, MESSAGE_MAX, "Mensagem");
|
|
264
|
+
const phone = normalizePhone(input.phone);
|
|
265
|
+
const cfg = this.getConfig();
|
|
266
|
+
const store = this.deps.store;
|
|
267
|
+
if (typeof input.ipHash === "string" && input.ipHash !== "") {
|
|
268
|
+
const recent = await store.countRecentSessionsByIpHash(input.ipHash, RATE_LIMIT_WINDOW_MS);
|
|
269
|
+
if (recent >= RATE_LIMIT_MAX_SESSIONS) {
|
|
270
|
+
throw new ChatError(
|
|
271
|
+
`Limite de ${RATE_LIMIT_MAX_SESSIONS} sess\xF5es por IP em 10 minutos excedido`,
|
|
272
|
+
"rate_limited"
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
const code = generateSessionCode();
|
|
277
|
+
const visitorJid = toWhatsappJid(phone);
|
|
278
|
+
const platformJid = toWhatsappJid(normalizePhone(cfg.platformNumber));
|
|
279
|
+
const participants = [.../* @__PURE__ */ new Set([visitorJid, platformJid])];
|
|
280
|
+
const subject = `${cfg.projectName} \u2014 ${name} (#${code})`;
|
|
281
|
+
const groupJid = await this.createGroupWithRetry(cfg.instance, subject, participants, platformJid);
|
|
282
|
+
const session = await store.createSession({
|
|
283
|
+
code,
|
|
284
|
+
realtimeToken: generateRealtimeToken(),
|
|
285
|
+
visitorName: name,
|
|
286
|
+
visitorPhone: phone,
|
|
287
|
+
visitorContact: input.contact ?? null,
|
|
288
|
+
groupJid,
|
|
289
|
+
ipHash: input.ipHash ?? null,
|
|
290
|
+
userAgent: input.userAgent ?? null
|
|
291
|
+
});
|
|
292
|
+
let waMessageId;
|
|
293
|
+
try {
|
|
294
|
+
({ waMessageId } = await this.deps.client.sendText(
|
|
295
|
+
cfg.instance,
|
|
296
|
+
groupJid,
|
|
297
|
+
formatFirstMessage(code, name, message)
|
|
298
|
+
));
|
|
299
|
+
} catch (error) {
|
|
300
|
+
await store.markStatus(session.id, "failed");
|
|
301
|
+
throw new ChatError("Falha ao enviar a primeira mensagem para o grupo", "send_failed", error);
|
|
302
|
+
}
|
|
303
|
+
await store.registerSentMessageId(session.id, waMessageId);
|
|
304
|
+
const initial = await store.appendMessage({
|
|
305
|
+
sessionId: session.id,
|
|
306
|
+
direction: "visitor",
|
|
307
|
+
body: message,
|
|
308
|
+
waMessageId,
|
|
309
|
+
status: "sent"
|
|
310
|
+
});
|
|
311
|
+
return { session, messages: [initial] };
|
|
312
|
+
}
|
|
313
|
+
/** Relay site → grupo: persiste pending, envia, promove para sent (ou failed). */
|
|
314
|
+
async sendVisitorMessage(token, text) {
|
|
315
|
+
const cfg = this.getConfig();
|
|
316
|
+
const { store } = this.deps;
|
|
317
|
+
const session = await store.getSessionByToken(token);
|
|
318
|
+
if (session === null) throw new ChatError("Sess\xE3o n\xE3o encontrada", "session_not_found");
|
|
319
|
+
if (session.status !== "active") throw new ChatError("Sess\xE3o encerrada", "session_closed");
|
|
320
|
+
const body = requireText(text, MESSAGE_MIN, MESSAGE_MAX, "Mensagem");
|
|
321
|
+
if (session.groupJid === null) throw new ChatError("Sess\xE3o sem grupo associado", "send_failed");
|
|
322
|
+
const pending = await store.appendMessage({
|
|
323
|
+
sessionId: session.id,
|
|
324
|
+
direction: "visitor",
|
|
325
|
+
body,
|
|
326
|
+
status: "pending"
|
|
327
|
+
});
|
|
328
|
+
let waMessageId;
|
|
329
|
+
try {
|
|
330
|
+
({ waMessageId } = await this.deps.client.sendText(
|
|
331
|
+
cfg.instance,
|
|
332
|
+
session.groupJid,
|
|
333
|
+
formatFollowup(session.visitorName, body)
|
|
334
|
+
));
|
|
335
|
+
} catch (error) {
|
|
336
|
+
await store.updateMessageStatus(pending.id, "failed");
|
|
337
|
+
throw new ChatError("Falha ao enviar mensagem para o grupo", "send_failed", error);
|
|
338
|
+
}
|
|
339
|
+
await store.registerSentMessageId(session.id, waMessageId);
|
|
340
|
+
await store.updateMessageStatus(pending.id, "sent");
|
|
341
|
+
await store.touchSession(session.id, this.clock().toISOString());
|
|
342
|
+
return { ...pending, status: "sent", waMessageId };
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Entrada do webhook da Evolution. NUNCA lança: um 500 aqui faz a Evolution reenviar
|
|
346
|
+
* a mesma mensagem para sempre. Qualquer problema é logado e devolvido como não tratado.
|
|
347
|
+
*/
|
|
348
|
+
async handleWebhook(payload) {
|
|
349
|
+
try {
|
|
350
|
+
const parsed = parseWebhookEvent(payload);
|
|
351
|
+
if (parsed.kind !== "message") return { handled: false };
|
|
352
|
+
const now = this.clock();
|
|
353
|
+
const decision = await this.router.decide(parsed.event, now);
|
|
354
|
+
if (decision.action !== "route") return { handled: false };
|
|
355
|
+
const { session, direction, text } = decision;
|
|
356
|
+
const waMessageId = parsed.event.waMessageId;
|
|
357
|
+
const existing = await this.deps.store.findMessageByWaId(session.id, waMessageId);
|
|
358
|
+
if (existing !== null) return { handled: true };
|
|
359
|
+
const message = await this.deps.store.appendMessage({
|
|
360
|
+
sessionId: session.id,
|
|
361
|
+
direction,
|
|
362
|
+
body: text,
|
|
363
|
+
waMessageId,
|
|
364
|
+
status: "sent"
|
|
365
|
+
});
|
|
366
|
+
await this.deps.store.touchSession(session.id, now.toISOString());
|
|
367
|
+
await this.deps.transport.publish(session.realtimeToken, { type: "message", message });
|
|
368
|
+
return { handled: true };
|
|
369
|
+
} catch (error) {
|
|
370
|
+
console.error("[evolution-chat] webhook n\xE3o processado:", error);
|
|
371
|
+
return { handled: false };
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
/** Replay para o widget: sessão por token + mensagens (opcionalmente após um ISO). */
|
|
375
|
+
async history(token, afterIso) {
|
|
376
|
+
const session = await this.deps.store.getSessionByToken(token);
|
|
377
|
+
if (session === null) return { session: null, messages: [] };
|
|
378
|
+
const messages = await this.deps.store.listMessages(session.id, afterIso);
|
|
379
|
+
return { session, messages };
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* createGroup com uma única retratação: um participante inválido (ex.: o número do
|
|
383
|
+
* visitante, vindo de um formulário web) não pode abortar o atendimento — a segunda
|
|
384
|
+
* tentativa cria o grupo só com a plataforma, o número que controlamos e é válido.
|
|
385
|
+
*
|
|
386
|
+
* Atenção (F1): createGroup NÃO é idempotente. Se a 1ª chamada criar o grupo e o erro
|
|
387
|
+
* observado for um timeout, a retratação pode orfanar um segundo grupo; a compensação
|
|
388
|
+
* (limpeza/reconciliação de órfãos) é preocupação das Tasks 7/8, não deste fix.
|
|
389
|
+
*/
|
|
390
|
+
async createGroupWithRetry(instance, subject, participants, platformJid) {
|
|
391
|
+
const attempts = participants.length > 1 ? [participants, [platformJid]] : [participants];
|
|
392
|
+
let lastError;
|
|
393
|
+
for (const attempt of attempts) {
|
|
394
|
+
try {
|
|
395
|
+
const { groupJid } = await this.deps.client.createGroup(instance, subject, attempt);
|
|
396
|
+
return groupJid;
|
|
397
|
+
} catch (error) {
|
|
398
|
+
lastError = error;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
throw new ChatError("Falha ao criar o grupo na Evolution", "group_create_failed", lastError);
|
|
402
|
+
}
|
|
403
|
+
};
|
|
404
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
405
|
+
0 && (module.exports = {
|
|
406
|
+
ChatBridge,
|
|
407
|
+
ChatError,
|
|
408
|
+
ConversationRouter,
|
|
409
|
+
formatFirstMessage,
|
|
410
|
+
formatFollowup
|
|
411
|
+
});
|
|
412
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/bridge/index.ts","../src/bridge/router.ts","../src/bridge/format.ts","../src/errors.ts","../src/api/ids.ts","../src/api/phone.ts","../src/api/webhook-parser.ts","../src/bridge/bridge.ts"],"sourcesContent":["// src/bridge/index.ts — superfície pública do subpath \"@erlancarreira/evolution-chat/bridge\".\n// Portas (contratos) + ConversationRouter (decisão de roteamento) + ChatBridge (orquestração)\n// e a Strategy de formatação. Adapters concretos são exportados pelos seus próprios subpaths\n// (./transports/supabase, ./next).\n\nexport * from \"./types\";\nexport * from \"./router\";\nexport * from \"./format\";\nexport * from \"./bridge\";\n\n// Erros de domínio compartilhados: o consumidor externo (ex.: stores do LMS) precisa\n// importar ChatError/ChatErrorCode daqui para que `instanceof ChatError` funcione —\n// cópias locais do tipo nunca casam com os erros lançados pelas rotas do SDK.\nexport * from \"../errors\";\n\n// Tipos de domínio que aparecem nas assinaturas das portas, reexportados para que o\n// consumidor do subpath ./bridge não precise importar do caminho interno src/types.\nexport type {\n ChatEvent,\n ChatMessage,\n ChatMessageDirection,\n ChatMessageStatus,\n ChatSession,\n ChatSessionStatus,\n} from \"../types\";\n","// src/bridge/router.ts — SRP: decidir O QUE FAZER com uma mensagem inbound do webhook.\n//\n// Puro: não persiste, não publica, não chama a Evolution. Recebe o recorte mínimo do\n// SessionStore (Pick) e devolve um RouterDecision discriminado; quem executa é a bridge.\n//\n// Regras (ordem fixada pelo plano):\n// 1. jid não termina em \"@g.us\" → ignore (sem tocar no store)\n// 2. sem sessão para o groupJid → unknown_session\n// 3. isEcho(sessionId, waMessageId) → echo (mensagem enviada por nós)\n// 4. sem texto → not_text\n// 5. caso contrário → route, direction = fromMe ? owner : visitor\n\nimport type { InboundMessage } from \"../types\";\nimport type { ChatMessageDirection } from \"../types\";\nimport type { RouterDecision, SessionStore } from \"./types\";\n\n/** Sufixo que identifica um Jid de grupo no WhatsApp. */\nconst GROUP_JID_SUFFIX = \"@g.us\";\n\nexport class ConversationRouter {\n constructor(private readonly store: Pick<SessionStore, \"getSessionByGroupJid\" | \"isEcho\">) {}\n\n /**\n * `now` faz parte do contrato para que a bridge propague uma única leitura de relógio\n * por mensagem (touch/close/limite de horário nas próximas tasks); o router puro ainda\n * não a consome.\n */\n async decide(msg: InboundMessage, now: Date): Promise<RouterDecision> {\n void now;\n\n if (!msg.jid.endsWith(GROUP_JID_SUFFIX)) return { action: \"ignore\" };\n\n const session = await this.store.getSessionByGroupJid(msg.jid);\n if (session === null) return { action: \"unknown_session\" };\n\n if (await this.store.isEcho(session.id, msg.waMessageId)) return { action: \"echo\" };\n\n // `text` é string|null no contrato do parser; a checagem defensiva cobre chamadores JS.\n if (typeof msg.text !== \"string\" || msg.text.length === 0) return { action: \"not_text\" };\n\n const direction: ChatMessageDirection = msg.fromMe ? \"owner\" : \"visitor\";\n return { action: \"route\", session, direction, text: msg.text ?? \"\" };\n }\n}\n","// src/bridge/format.ts — Strategy de formatação do texto que entra no grupo WhatsApp.\n//\n// Isolado de propósito: o corpo persistido no store é SEMPRE o texto cru do visitante\n// (é o que o widget mostra), e só o que viaja para a Evolution recebe o prefixo que\n// identifica origem/código/nome. Trocar a identidade visual do relay = mexer aqui.\n\n/** Primeira mensagem da conversa no grupo: destaca código, nome e quebra de linha. */\nexport function formatFirstMessage(code: string, name: string, text: string): string {\n return `🆕 #${code} — ${name} (site):\\n${text}`;\n}\n\n/** Demais mensagens vindas do site: prefixo curto na mesma linha. */\nexport function formatFollowup(name: string, text: string): string {\n return `${name} (site): ${text}`;\n}\n","// src/errors.ts — erro de domínio canônico do SDK.\n//\n// ChatError carrega um `code` estável para mapear em HTTP nas rotas sem depender\n// de instanceof entre bundles (cada entry do tsup inline-iza a classe; usamos\n// `.code` para decidir o status). O erro de transporte da Evolution vive em\n// src/api/client.ts (EvolutionApiError), junto do cliente que o lança.\n\nexport type ChatErrorCode =\n | \"invalid_input\"\n | \"rate_limited\"\n | \"group_create_failed\"\n | \"send_failed\"\n | \"session_not_found\"\n | \"session_closed\"\n | \"disabled\"\n | \"unauthorized\"\n | \"store_error\"\n | \"webhook_invalid\";\n\nexport class ChatError extends Error {\n public readonly code: ChatErrorCode;\n public override readonly cause?: unknown;\n\n constructor(message: string, code: ChatErrorCode, cause?: unknown) {\n super(message);\n this.name = \"ChatError\";\n this.code = code;\n this.cause = cause;\n }\n}\n","// src/api/ids.ts — geração de ids com webcrypto (isomórfico, sem node:crypto).\n\nconst SESSION_ALPHABET = \"ABCDEFGHJKMNPQRSTUVWXYZ23456789\"; // sem 0/O/1/I\nconst SESSION_CODE_LENGTH = 4;\n// Rejection sampling: só aceita bytes < 248 (múltiplo de 31 ≤ 256), evitando viés de módulo.\nconst SESSION_ACCEPT_LIMIT = 256 - (256 % SESSION_ALPHABET.length);\n\nconst BASE64URL_ALPHABET = \"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_\";\nconst TOKEN_BYTES = 24; // 24 bytes → exatamente 32 caracteres base64url, sem padding\n\nfunction randomChar(alphabet: string, acceptLimit: number): string {\n const buffer = new Uint8Array(1);\n for (;;) {\n globalThis.crypto.getRandomValues(buffer);\n const byte = buffer[0];\n if (byte !== undefined && byte < acceptLimit) {\n return alphabet.charAt(byte % alphabet.length);\n }\n }\n}\n\nexport function generateSessionCode(): string {\n return Array.from({ length: SESSION_CODE_LENGTH }, () => randomChar(SESSION_ALPHABET, SESSION_ACCEPT_LIMIT)).join(\"\");\n}\n\nexport function generateRealtimeToken(): string {\n const bytes = new Uint8Array(TOKEN_BYTES);\n globalThis.crypto.getRandomValues(bytes);\n let token = \"\";\n let acc = 0;\n let bits = 0;\n for (const byte of bytes) {\n acc = (acc << 8) | byte;\n bits += 8;\n while (bits >= 6) {\n bits -= 6;\n token += BASE64URL_ALPHABET.charAt((acc >>> bits) & 0x3f);\n }\n }\n return token;\n}\n","import { ChatError } from \"../errors\";\n\nconst NON_DIGITS = /\\D+/g;\n\nexport function normalizePhone(raw: string, defaultDdi = \"55\"): string {\n const digits = raw.replace(NON_DIGITS, \"\");\n if (!digits) throw new ChatError(\"Telefone vazio\", \"invalid_input\");\n // Prefixo \"+\" explícito (E.164) sinaliza DDI já presente — ex.: \"+1 415 555 2671\" permanece \"14155552671\".\n const explicitDdi = raw.includes(\"+\");\n const withDdi = digits.length <= 11 && !explicitDdi && !digits.startsWith(defaultDdi) ? defaultDdi + digits : digits;\n if (withDdi.length < 10 || withDdi.length > 15) {\n throw new ChatError(\"Telefone inválido\", \"invalid_input\");\n }\n return withDdi;\n}\nexport function isValidPhone(digits: string): boolean {\n return digits.length >= 10 && digits.length <= 15;\n}\nexport function toWhatsappJid(phoneDigits: string): string {\n return `${phoneDigits}@s.whatsapp.net`;\n}\n","// src/api/webhook-parser.ts — normalização defensiva de webhooks da Evolution API v2.\n//\n// Contrato: parseWebhookEvent(payload: unknown): ParsedWebhook.\n// NUNCA lança exceção — qualquer payload inesperado/inválido vira { kind: \"ignored\", reason }.\n\nimport type { InboundMessage } from \"../types\";\n\nexport type ParsedWebhook =\n | { kind: \"message\"; event: InboundMessage }\n | { kind: \"connection\"; state: string }\n | { kind: \"ignored\"; reason: string };\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\n// Texto só sai de `conversation` ou `extendedTextMessage.text`; string vazia/só espaços → null.\nfunction textFromMessage(message: unknown): string | null {\n if (!isRecord(message)) return null;\n const conversation = message[\"conversation\"];\n if (typeof conversation === \"string\") {\n const trimmed = conversation.trim();\n return trimmed.length > 0 ? trimmed : null;\n }\n const extended = message[\"extendedTextMessage\"];\n if (isRecord(extended)) {\n const text = extended[\"text\"];\n if (typeof text === \"string\") {\n const trimmed = text.trim();\n return trimmed.length > 0 ? trimmed : null;\n }\n }\n return null;\n}\n\n// messageTimestamp em segundos (aceita número ou string numérica; ausente/inválido → 0).\nfunction timestampSeconds(value: unknown): number {\n const seconds = typeof value === \"number\" ? value : typeof value === \"string\" ? Number(value) : Number.NaN;\n return Number.isFinite(seconds) ? Math.floor(seconds) : 0;\n}\n\nfunction parseMessageItem(item: unknown): ParsedWebhook {\n if (!isRecord(item)) return { kind: \"ignored\", reason: \"messages.upsert sem data válido\" };\n\n const key = isRecord(item[\"key\"]) ? item[\"key\"] : null;\n const waMessageId = key?.[\"id\"];\n const jid = key?.[\"remoteJid\"];\n if (typeof waMessageId !== \"string\" || waMessageId.length === 0) {\n return { kind: \"ignored\", reason: \"mensagem sem key.id\" };\n }\n if (typeof jid !== \"string\" || jid.length === 0) {\n return { kind: \"ignored\", reason: \"mensagem sem key.remoteJid\" };\n }\n\n const text = textFromMessage(item[\"message\"]);\n if (text === null) {\n return { kind: \"ignored\", reason: \"mensagem sem texto (conversation/extendedTextMessage)\" };\n }\n\n const senderJid = key?.[\"participant\"];\n return {\n kind: \"message\",\n event: {\n waMessageId,\n jid,\n fromMe: key?.[\"fromMe\"] === true,\n senderJid: typeof senderJid === \"string\" ? senderJid : null,\n text,\n timestamp: timestampSeconds(item[\"messageTimestamp\"]),\n raw: item,\n },\n };\n}\n\nexport function parseWebhookEvent(payload: unknown): ParsedWebhook {\n if (!isRecord(payload)) return { kind: \"ignored\", reason: \"payload não é um objeto\" };\n\n const event = payload[\"event\"];\n if (typeof event !== \"string\") return { kind: \"ignored\", reason: \"payload sem campo event\" };\n\n const data = payload[\"data\"];\n\n if (event === \"connection.update\") {\n const state = isRecord(data) ? data[\"state\"] : undefined;\n if (typeof state === \"string\" && state.length > 0) return { kind: \"connection\", state };\n return { kind: \"ignored\", reason: \"connection.update sem state\" };\n }\n\n if (event !== \"messages.upsert\") {\n return { kind: \"ignored\", reason: `evento não tratado: ${event}` };\n }\n\n // Evolution v2 pode mandar `data` como objeto único ou array de mensagens (queue-flush/bulk).\n // Contrato de evento único: devolve o PRIMEIRO item válido, não `items[0]` — senão um item\n // nulo/inválido na frente descartaria silenciosamente as mensagens válidas seguintes.\n const items: readonly unknown[] = Array.isArray(data) ? data : [data];\n let lastIgnored: ParsedWebhook = { kind: \"ignored\", reason: \"messages.upsert sem data válido\" };\n for (const item of items) {\n const parsed = parseMessageItem(item);\n if (parsed.kind !== \"ignored\") return parsed;\n lastIgnored = parsed;\n }\n return lastIgnored;\n}\n","// src/bridge/bridge.ts — orquestração do chat (caso de uso), 100% orientada a portas.\n//\n// O bridge é o único lugar que compõe as peças: valida entrada, aplica anti-abuso,\n// fala com a Evolution (client), persiste (SessionStore) e notifica o widget\n// (RealtimeTransport). Nada disso é importado concretamente — tudo entra por `deps`,\n// o que o torna testável sem rede e sem banco (ver test/bridge/bridge.test.ts).\n//\n// O ConversationRouter (Task 5) é construído a partir do próprio store: ele só enxerga\n// Pick<SessionStore, \"getSessionByGroupJid\" | \"isEcho\">, então não há dependência nova\n// para quem monta o bridge.\n//\n// Convenção de erros: toda falha esperada sai como ChatError com um código de domínio\n// (invalid_input / rate_limited / group_create_failed / send_failed / session_not_found /\n// session_closed / store_error), preservando o erro original em `cause`. As rotas (Task 8)\n// mapeiam esses códigos para status HTTP — `invalid_input` vira 422 de campo (visível ao\n// visitante); falhas de infra/config usam `store_error` (500/502), nunca um 422 indevido.\n\nimport { ChatError } from \"../errors\";\nimport { generateRealtimeToken, generateSessionCode } from \"../api/ids\";\nimport { normalizePhone, toWhatsappJid } from \"../api/phone\";\nimport { parseWebhookEvent } from \"../api/webhook-parser\";\nimport type { EvolutionClient } from \"../api/client\";\nimport type { ChatConfig, ChatMessage, ChatSession } from \"../types\";\nimport { ConversationRouter } from \"./router\";\nimport { formatFirstMessage, formatFollowup } from \"./format\";\nimport type { Clock, RealtimeTransport, SessionStore } from \"./types\";\n\nexport interface StartChatInput {\n name: string;\n phone: string;\n message: string;\n contact?: string | null;\n ipHash?: string | null;\n userAgent?: string | null;\n honeypot?: string | null;\n}\n\nexport interface ChatBridgeDeps {\n client: EvolutionClient;\n store: SessionStore;\n transport: RealtimeTransport;\n /** Relógio injetável (testes determinísticos). Default: `() => new Date()`. */\n clock?: Clock;\n}\n\n// Limites de entrada (validados aqui, exibidos como 422 pela rota).\nconst NAME_MIN = 2;\nconst NAME_MAX = 60;\nconst MESSAGE_MIN = 1;\nconst MESSAGE_MAX = 1000;\n\n// Anti-abuso: máx. de sessões abertas pelo mesmo IP dentro da janela.\nconst RATE_LIMIT_WINDOW_MS = 10 * 60 * 1000;\nconst RATE_LIMIT_MAX_SESSIONS = 5;\n\n/** Sessão fake devolvida quando o honeypot é preenchido (nada é persistido). */\nfunction honeypotSession(atIso: string): ChatSession {\n return {\n id: \"honeypot\",\n code: \"XXXX\",\n realtimeToken: \"\",\n visitorName: \"\",\n visitorPhone: \"\",\n visitorContact: null,\n groupJid: null,\n status: \"closed\",\n createdAt: atIso,\n lastMessageAt: null,\n };\n}\n\n/** Trim + faixa de tamanho; lança ChatError(\"invalid_input\") nomeando o campo. */\nfunction requireText(value: string, min: number, max: number, field: string): string {\n // `typeof` é defensivo: a rota pode entregar undefined/null vindos de JSON.\n const trimmed = typeof value === \"string\" ? value.trim() : \"\";\n if (trimmed.length < min || trimmed.length > max) {\n throw new ChatError(`${field} deve ter entre ${min} e ${max} caracteres`, \"invalid_input\");\n }\n return trimmed;\n}\n\nexport class ChatBridge {\n private readonly router: ConversationRouter;\n private readonly clock: Clock;\n private config: ChatConfig | null = null;\n\n constructor(private readonly deps: ChatBridgeDeps) {\n this.router = new ConversationRouter(deps.store);\n this.clock = deps.clock ?? (() => new Date());\n }\n\n /** Config injetada pelas rotas a cada request (setConfig → uso → getConfig). */\n getConfig(): ChatConfig {\n if (this.config === null) {\n // F3: config ausente é falha de infraestrutura, não entrada inválida do visitante.\n // `invalid_input` seria mapeado pela Task 8 a um 422 de campo exibido a quem visita.\n throw new ChatError(\"Chat não configurado\", \"store_error\");\n }\n return this.config;\n }\n\n setConfig(cfg: ChatConfig): void {\n this.config = cfg;\n }\n\n /**\n * Abre uma conversa: cria o grupo na Evolution, envia a primeira mensagem e persiste a\n * sessão. Não publica no canal realtime — quem abriu o chat já tem a própria mensagem.\n */\n async startChat(input: StartChatInput): Promise<{ session: ChatSession; messages: ChatMessage[] }> {\n const atIso = this.clock().toISOString();\n\n // Anti-bot silencioso: campo invisível preenchido → finge sucesso e não toca em nada.\n if (typeof input.honeypot === \"string\" && input.honeypot.trim().length > 0) {\n return { session: honeypotSession(atIso), messages: [] };\n }\n\n const name = requireText(input.name, NAME_MIN, NAME_MAX, \"Nome\");\n const message = requireText(input.message, MESSAGE_MIN, MESSAGE_MAX, \"Mensagem\");\n const phone = normalizePhone(input.phone); // ChatError(\"invalid_input\") se não for número útil\n const cfg = this.getConfig();\n const store = this.deps.store;\n\n if (typeof input.ipHash === \"string\" && input.ipHash !== \"\") {\n const recent = await store.countRecentSessionsByIpHash(input.ipHash, RATE_LIMIT_WINDOW_MS);\n if (recent >= RATE_LIMIT_MAX_SESSIONS) {\n throw new ChatError(\n `Limite de ${RATE_LIMIT_MAX_SESSIONS} sessões por IP em 10 minutos excedido`,\n \"rate_limited\",\n );\n }\n }\n\n const code = generateSessionCode();\n const visitorJid = toWhatsappJid(phone);\n const platformJid = toWhatsappJid(normalizePhone(cfg.platformNumber));\n // visitor == platform (a plataforma se auto-atende) → lista dedupe, uma única chamada.\n const participants = [...new Set([visitorJid, platformJid])];\n const subject = `${cfg.projectName} — ${name} (#${code})`;\n\n const groupJid = await this.createGroupWithRetry(cfg.instance, subject, participants, platformJid);\n\n const session = await store.createSession({\n code,\n realtimeToken: generateRealtimeToken(),\n visitorName: name,\n visitorPhone: phone,\n visitorContact: input.contact ?? null,\n groupJid,\n ipHash: input.ipHash ?? null,\n userAgent: input.userAgent ?? null,\n });\n\n let waMessageId: string;\n try {\n ({ waMessageId } = await this.deps.client.sendText(\n cfg.instance,\n groupJid,\n formatFirstMessage(code, name, message),\n ));\n } catch (error) {\n // Grupo existe, mas a conversa não começou: marca failed para não virar sessão zumbi.\n await store.markStatus(session.id, \"failed\");\n throw new ChatError(\"Falha ao enviar a primeira mensagem para o grupo\", \"send_failed\", error);\n }\n\n // Registra o id antes de qualquer eco chegar do webhook.\n await store.registerSentMessageId(session.id, waMessageId);\n const initial = await store.appendMessage({\n sessionId: session.id,\n direction: \"visitor\",\n body: message,\n waMessageId,\n status: \"sent\",\n });\n\n return { session, messages: [initial] };\n }\n\n /** Relay site → grupo: persiste pending, envia, promove para sent (ou failed). */\n async sendVisitorMessage(token: string, text: string): Promise<ChatMessage> {\n const cfg = this.getConfig();\n const { store } = this.deps;\n\n const session = await store.getSessionByToken(token);\n if (session === null) throw new ChatError(\"Sessão não encontrada\", \"session_not_found\");\n if (session.status !== \"active\") throw new ChatError(\"Sessão encerrada\", \"session_closed\");\n const body = requireText(text, MESSAGE_MIN, MESSAGE_MAX, \"Mensagem\");\n if (session.groupJid === null) throw new ChatError(\"Sessão sem grupo associado\", \"send_failed\");\n\n const pending = await store.appendMessage({\n sessionId: session.id,\n direction: \"visitor\",\n body,\n status: \"pending\",\n });\n\n let waMessageId: string;\n try {\n ({ waMessageId } = await this.deps.client.sendText(\n cfg.instance,\n session.groupJid,\n formatFollowup(session.visitorName, body),\n ));\n } catch (error) {\n await store.updateMessageStatus(pending.id, \"failed\");\n throw new ChatError(\"Falha ao enviar mensagem para o grupo\", \"send_failed\", error);\n }\n\n await store.registerSentMessageId(session.id, waMessageId);\n await store.updateMessageStatus(pending.id, \"sent\");\n await store.touchSession(session.id, this.clock().toISOString());\n\n // Devolve o estado já promovido (cópia): o chamador não depende de o store mutar ou não\n // a referência que recebeu no appendMessage.\n return { ...pending, status: \"sent\", waMessageId };\n }\n\n /**\n * Entrada do webhook da Evolution. NUNCA lança: um 500 aqui faz a Evolution reenviar\n * a mesma mensagem para sempre. Qualquer problema é logado e devolvido como não tratado.\n */\n async handleWebhook(payload: unknown): Promise<{ handled: boolean }> {\n try {\n const parsed = parseWebhookEvent(payload);\n if (parsed.kind !== \"message\") return { handled: false }; // connection.update / ignored\n\n const now = this.clock();\n const decision = await this.router.decide(parsed.event, now);\n if (decision.action !== \"route\") return { handled: false }; // echo / unknown_session / not_text / ignore\n\n const { session, direction, text } = decision;\n const waMessageId = parsed.event.waMessageId;\n\n // F2: idempotência de reentrega. A Evolution reenvia a mesma mensagem sempre que\n // devolvemos handled:false (ex.: publish falhou). Sem esta guarda, o reenvio\n // duplicaria append + publish. O isEcho do router continua cobrindo só o eco de\n // mensagens NOSSAS (outbound); aqui é inbound repetido.\n const existing = await this.deps.store.findMessageByWaId(session.id, waMessageId);\n if (existing !== null) return { handled: true }; // já processada: só reconhece\n\n const message = await this.deps.store.appendMessage({\n sessionId: session.id,\n direction,\n body: text,\n waMessageId,\n status: \"sent\",\n });\n await this.deps.store.touchSession(session.id, now.toISOString());\n await this.deps.transport.publish(session.realtimeToken, { type: \"message\", message });\n return { handled: true };\n } catch (error) {\n console.error(\"[evolution-chat] webhook não processado:\", error);\n return { handled: false };\n }\n }\n\n /** Replay para o widget: sessão por token + mensagens (opcionalmente após um ISO). */\n async history(\n token: string,\n afterIso?: string | null,\n ): Promise<{ session: ChatSession | null; messages: ChatMessage[] }> {\n const session = await this.deps.store.getSessionByToken(token);\n if (session === null) return { session: null, messages: [] };\n const messages = await this.deps.store.listMessages(session.id, afterIso);\n return { session, messages };\n }\n\n /**\n * createGroup com uma única retratação: um participante inválido (ex.: o número do\n * visitante, vindo de um formulário web) não pode abortar o atendimento — a segunda\n * tentativa cria o grupo só com a plataforma, o número que controlamos e é válido.\n *\n * Atenção (F1): createGroup NÃO é idempotente. Se a 1ª chamada criar o grupo e o erro\n * observado for um timeout, a retratação pode orfanar um segundo grupo; a compensação\n * (limpeza/reconciliação de órfãos) é preocupação das Tasks 7/8, não deste fix.\n */\n private async createGroupWithRetry(\n instance: string,\n subject: string,\n participants: string[],\n platformJid: string,\n ): Promise<string> {\n const attempts: string[][] =\n participants.length > 1 ? [participants, [platformJid]] : [participants];\n let lastError: unknown;\n\n for (const attempt of attempts) {\n try {\n const { groupJid } = await this.deps.client.createGroup(instance, subject, attempt);\n return groupJid;\n } catch (error) {\n lastError = error;\n }\n }\n\n throw new ChatError(\"Falha ao criar o grupo na Evolution\", \"group_create_failed\", lastError);\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACiBA,IAAM,mBAAmB;AAElB,IAAM,qBAAN,MAAyB;AAAA,EAC9B,YAA6B,OAA8D;AAA9D;AAAA,EAA+D;AAAA,EAA/D;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAO7B,MAAM,OAAO,KAAqB,KAAoC;AACpE,SAAK;AAEL,QAAI,CAAC,IAAI,IAAI,SAAS,gBAAgB,EAAG,QAAO,EAAE,QAAQ,SAAS;AAEnE,UAAM,UAAU,MAAM,KAAK,MAAM,qBAAqB,IAAI,GAAG;AAC7D,QAAI,YAAY,KAAM,QAAO,EAAE,QAAQ,kBAAkB;AAEzD,QAAI,MAAM,KAAK,MAAM,OAAO,QAAQ,IAAI,IAAI,WAAW,EAAG,QAAO,EAAE,QAAQ,OAAO;AAGlF,QAAI,OAAO,IAAI,SAAS,YAAY,IAAI,KAAK,WAAW,EAAG,QAAO,EAAE,QAAQ,WAAW;AAEvF,UAAM,YAAkC,IAAI,SAAS,UAAU;AAC/D,WAAO,EAAE,QAAQ,SAAS,SAAS,WAAW,MAAM,IAAI,QAAQ,GAAG;AAAA,EACrE;AACF;;;ACpCO,SAAS,mBAAmB,MAAc,MAAc,MAAsB;AACnF,SAAO,cAAO,IAAI,WAAM,IAAI;AAAA,EAAa,IAAI;AAC/C;AAGO,SAAS,eAAe,MAAc,MAAsB;AACjE,SAAO,GAAG,IAAI,YAAY,IAAI;AAChC;;;ACKO,IAAM,YAAN,cAAwB,MAAM;AAAA,EACnB;AAAA,EACS;AAAA,EAEzB,YAAY,SAAiB,MAAqB,OAAiB;AACjE,UAAM,OAAO;AACb,SAAK,OAAO;AACZ,SAAK,OAAO;AACZ,SAAK,QAAQ;AAAA,EACf;AACF;;;AC3BA,IAAM,mBAAmB;AACzB,IAAM,sBAAsB;AAE5B,IAAM,uBAAuB,MAAO,MAAM,iBAAiB;AAE3D,IAAM,qBAAqB;AAC3B,IAAM,cAAc;AAEpB,SAAS,WAAW,UAAkB,aAA6B;AACjE,QAAM,SAAS,IAAI,WAAW,CAAC;AAC/B,aAAS;AACP,eAAW,OAAO,gBAAgB,MAAM;AACxC,UAAM,OAAO,OAAO,CAAC;AACrB,QAAI,SAAS,UAAa,OAAO,aAAa;AAC5C,aAAO,SAAS,OAAO,OAAO,SAAS,MAAM;AAAA,IAC/C;AAAA,EACF;AACF;AAEO,SAAS,sBAA8B;AAC5C,SAAO,MAAM,KAAK,EAAE,QAAQ,oBAAoB,GAAG,MAAM,WAAW,kBAAkB,oBAAoB,CAAC,EAAE,KAAK,EAAE;AACtH;AAEO,SAAS,wBAAgC;AAC9C,QAAM,QAAQ,IAAI,WAAW,WAAW;AACxC,aAAW,OAAO,gBAAgB,KAAK;AACvC,MAAI,QAAQ;AACZ,MAAI,MAAM;AACV,MAAI,OAAO;AACX,aAAW,QAAQ,OAAO;AACxB,UAAO,OAAO,IAAK;AACnB,YAAQ;AACR,WAAO,QAAQ,GAAG;AAChB,cAAQ;AACR,eAAS,mBAAmB,OAAQ,QAAQ,OAAQ,EAAI;AAAA,IAC1D;AAAA,EACF;AACA,SAAO;AACT;;;ACtCA,IAAM,aAAa;AAEZ,SAAS,eAAe,KAAa,aAAa,MAAc;AACrE,QAAM,SAAS,IAAI,QAAQ,YAAY,EAAE;AACzC,MAAI,CAAC,OAAQ,OAAM,IAAI,UAAU,kBAAkB,eAAe;AAElE,QAAM,cAAc,IAAI,SAAS,GAAG;AACpC,QAAM,UAAU,OAAO,UAAU,MAAM,CAAC,eAAe,CAAC,OAAO,WAAW,UAAU,IAAI,aAAa,SAAS;AAC9G,MAAI,QAAQ,SAAS,MAAM,QAAQ,SAAS,IAAI;AAC9C,UAAM,IAAI,UAAU,wBAAqB,eAAe;AAAA,EAC1D;AACA,SAAO;AACT;AAIO,SAAS,cAAc,aAA6B;AACzD,SAAO,GAAG,WAAW;AACvB;;;ACRA,SAAS,SAAS,OAAkD;AAClE,SAAO,OAAO,UAAU,YAAY,UAAU,QAAQ,CAAC,MAAM,QAAQ,KAAK;AAC5E;AAGA,SAAS,gBAAgB,SAAiC;AACxD,MAAI,CAAC,SAAS,OAAO,EAAG,QAAO;AAC/B,QAAM,eAAe,QAAQ,cAAc;AAC3C,MAAI,OAAO,iBAAiB,UAAU;AACpC,UAAM,UAAU,aAAa,KAAK;AAClC,WAAO,QAAQ,SAAS,IAAI,UAAU;AAAA,EACxC;AACA,QAAM,WAAW,QAAQ,qBAAqB;AAC9C,MAAI,SAAS,QAAQ,GAAG;AACtB,UAAM,OAAO,SAAS,MAAM;AAC5B,QAAI,OAAO,SAAS,UAAU;AAC5B,YAAM,UAAU,KAAK,KAAK;AAC1B,aAAO,QAAQ,SAAS,IAAI,UAAU;AAAA,IACxC;AAAA,EACF;AACA,SAAO;AACT;AAGA,SAAS,iBAAiB,OAAwB;AAChD,QAAM,UAAU,OAAO,UAAU,WAAW,QAAQ,OAAO,UAAU,WAAW,OAAO,KAAK,IAAI,OAAO;AACvG,SAAO,OAAO,SAAS,OAAO,IAAI,KAAK,MAAM,OAAO,IAAI;AAC1D;AAEA,SAAS,iBAAiB,MAA8B;AACtD,MAAI,CAAC,SAAS,IAAI,EAAG,QAAO,EAAE,MAAM,WAAW,QAAQ,qCAAkC;AAEzF,QAAM,MAAM,SAAS,KAAK,KAAK,CAAC,IAAI,KAAK,KAAK,IAAI;AAClD,QAAM,cAAc,MAAM,IAAI;AAC9B,QAAM,MAAM,MAAM,WAAW;AAC7B,MAAI,OAAO,gBAAgB,YAAY,YAAY,WAAW,GAAG;AAC/D,WAAO,EAAE,MAAM,WAAW,QAAQ,sBAAsB;AAAA,EAC1D;AACA,MAAI,OAAO,QAAQ,YAAY,IAAI,WAAW,GAAG;AAC/C,WAAO,EAAE,MAAM,WAAW,QAAQ,6BAA6B;AAAA,EACjE;AAEA,QAAM,OAAO,gBAAgB,KAAK,SAAS,CAAC;AAC5C,MAAI,SAAS,MAAM;AACjB,WAAO,EAAE,MAAM,WAAW,QAAQ,wDAAwD;AAAA,EAC5F;AAEA,QAAM,YAAY,MAAM,aAAa;AACrC,SAAO;AAAA,IACL,MAAM;AAAA,IACN,OAAO;AAAA,MACL;AAAA,MACA;AAAA,MACA,QAAQ,MAAM,QAAQ,MAAM;AAAA,MAC5B,WAAW,OAAO,cAAc,WAAW,YAAY;AAAA,MACvD;AAAA,MACA,WAAW,iBAAiB,KAAK,kBAAkB,CAAC;AAAA,MACpD,KAAK;AAAA,IACP;AAAA,EACF;AACF;AAEO,SAAS,kBAAkB,SAAiC;AACjE,MAAI,CAAC,SAAS,OAAO,EAAG,QAAO,EAAE,MAAM,WAAW,QAAQ,gCAA0B;AAEpF,QAAM,QAAQ,QAAQ,OAAO;AAC7B,MAAI,OAAO,UAAU,SAAU,QAAO,EAAE,MAAM,WAAW,QAAQ,0BAA0B;AAE3F,QAAM,OAAO,QAAQ,MAAM;AAE3B,MAAI,UAAU,qBAAqB;AACjC,UAAM,QAAQ,SAAS,IAAI,IAAI,KAAK,OAAO,IAAI;AAC/C,QAAI,OAAO,UAAU,YAAY,MAAM,SAAS,EAAG,QAAO,EAAE,MAAM,cAAc,MAAM;AACtF,WAAO,EAAE,MAAM,WAAW,QAAQ,8BAA8B;AAAA,EAClE;AAEA,MAAI,UAAU,mBAAmB;AAC/B,WAAO,EAAE,MAAM,WAAW,QAAQ,0BAAuB,KAAK,GAAG;AAAA,EACnE;AAKA,QAAM,QAA4B,MAAM,QAAQ,IAAI,IAAI,OAAO,CAAC,IAAI;AACpE,MAAI,cAA6B,EAAE,MAAM,WAAW,QAAQ,qCAAkC;AAC9F,aAAW,QAAQ,OAAO;AACxB,UAAM,SAAS,iBAAiB,IAAI;AACpC,QAAI,OAAO,SAAS,UAAW,QAAO;AACtC,kBAAc;AAAA,EAChB;AACA,SAAO;AACT;;;ACzDA,IAAM,WAAW;AACjB,IAAM,WAAW;AACjB,IAAM,cAAc;AACpB,IAAM,cAAc;AAGpB,IAAM,uBAAuB,KAAK,KAAK;AACvC,IAAM,0BAA0B;AAGhC,SAAS,gBAAgB,OAA4B;AACnD,SAAO;AAAA,IACL,IAAI;AAAA,IACJ,MAAM;AAAA,IACN,eAAe;AAAA,IACf,aAAa;AAAA,IACb,cAAc;AAAA,IACd,gBAAgB;AAAA,IAChB,UAAU;AAAA,IACV,QAAQ;AAAA,IACR,WAAW;AAAA,IACX,eAAe;AAAA,EACjB;AACF;AAGA,SAAS,YAAY,OAAe,KAAa,KAAa,OAAuB;AAEnF,QAAM,UAAU,OAAO,UAAU,WAAW,MAAM,KAAK,IAAI;AAC3D,MAAI,QAAQ,SAAS,OAAO,QAAQ,SAAS,KAAK;AAChD,UAAM,IAAI,UAAU,GAAG,KAAK,mBAAmB,GAAG,MAAM,GAAG,eAAe,eAAe;AAAA,EAC3F;AACA,SAAO;AACT;AAEO,IAAM,aAAN,MAAiB;AAAA,EAKtB,YAA6B,MAAsB;AAAtB;AAC3B,SAAK,SAAS,IAAI,mBAAmB,KAAK,KAAK;AAC/C,SAAK,QAAQ,KAAK,UAAU,MAAM,oBAAI,KAAK;AAAA,EAC7C;AAAA,EAH6B;AAAA,EAJZ;AAAA,EACA;AAAA,EACT,SAA4B;AAAA;AAAA,EAQpC,YAAwB;AACtB,QAAI,KAAK,WAAW,MAAM;AAGxB,YAAM,IAAI,UAAU,2BAAwB,aAAa;AAAA,IAC3D;AACA,WAAO,KAAK;AAAA,EACd;AAAA,EAEA,UAAU,KAAuB;AAC/B,SAAK,SAAS;AAAA,EAChB;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,MAAM,UAAU,OAAmF;AACjG,UAAM,QAAQ,KAAK,MAAM,EAAE,YAAY;AAGvC,QAAI,OAAO,MAAM,aAAa,YAAY,MAAM,SAAS,KAAK,EAAE,SAAS,GAAG;AAC1E,aAAO,EAAE,SAAS,gBAAgB,KAAK,GAAG,UAAU,CAAC,EAAE;AAAA,IACzD;AAEA,UAAM,OAAO,YAAY,MAAM,MAAM,UAAU,UAAU,MAAM;AAC/D,UAAM,UAAU,YAAY,MAAM,SAAS,aAAa,aAAa,UAAU;AAC/E,UAAM,QAAQ,eAAe,MAAM,KAAK;AACxC,UAAM,MAAM,KAAK,UAAU;AAC3B,UAAM,QAAQ,KAAK,KAAK;AAExB,QAAI,OAAO,MAAM,WAAW,YAAY,MAAM,WAAW,IAAI;AAC3D,YAAM,SAAS,MAAM,MAAM,4BAA4B,MAAM,QAAQ,oBAAoB;AACzF,UAAI,UAAU,yBAAyB;AACrC,cAAM,IAAI;AAAA,UACR,aAAa,uBAAuB;AAAA,UACpC;AAAA,QACF;AAAA,MACF;AAAA,IACF;AAEA,UAAM,OAAO,oBAAoB;AACjC,UAAM,aAAa,cAAc,KAAK;AACtC,UAAM,cAAc,cAAc,eAAe,IAAI,cAAc,CAAC;AAEpE,UAAM,eAAe,CAAC,GAAG,oBAAI,IAAI,CAAC,YAAY,WAAW,CAAC,CAAC;AAC3D,UAAM,UAAU,GAAG,IAAI,WAAW,WAAM,IAAI,MAAM,IAAI;AAEtD,UAAM,WAAW,MAAM,KAAK,qBAAqB,IAAI,UAAU,SAAS,cAAc,WAAW;AAEjG,UAAM,UAAU,MAAM,MAAM,cAAc;AAAA,MACxC;AAAA,MACA,eAAe,sBAAsB;AAAA,MACrC,aAAa;AAAA,MACb,cAAc;AAAA,MACd,gBAAgB,MAAM,WAAW;AAAA,MACjC;AAAA,MACA,QAAQ,MAAM,UAAU;AAAA,MACxB,WAAW,MAAM,aAAa;AAAA,IAChC,CAAC;AAED,QAAI;AACJ,QAAI;AACF,OAAC,EAAE,YAAY,IAAI,MAAM,KAAK,KAAK,OAAO;AAAA,QACxC,IAAI;AAAA,QACJ;AAAA,QACA,mBAAmB,MAAM,MAAM,OAAO;AAAA,MACxC;AAAA,IACF,SAAS,OAAO;AAEd,YAAM,MAAM,WAAW,QAAQ,IAAI,QAAQ;AAC3C,YAAM,IAAI,UAAU,oDAAoD,eAAe,KAAK;AAAA,IAC9F;AAGA,UAAM,MAAM,sBAAsB,QAAQ,IAAI,WAAW;AACzD,UAAM,UAAU,MAAM,MAAM,cAAc;AAAA,MACxC,WAAW,QAAQ;AAAA,MACnB,WAAW;AAAA,MACX,MAAM;AAAA,MACN;AAAA,MACA,QAAQ;AAAA,IACV,CAAC;AAED,WAAO,EAAE,SAAS,UAAU,CAAC,OAAO,EAAE;AAAA,EACxC;AAAA;AAAA,EAGA,MAAM,mBAAmB,OAAe,MAAoC;AAC1E,UAAM,MAAM,KAAK,UAAU;AAC3B,UAAM,EAAE,MAAM,IAAI,KAAK;AAEvB,UAAM,UAAU,MAAM,MAAM,kBAAkB,KAAK;AACnD,QAAI,YAAY,KAAM,OAAM,IAAI,UAAU,+BAAyB,mBAAmB;AACtF,QAAI,QAAQ,WAAW,SAAU,OAAM,IAAI,UAAU,uBAAoB,gBAAgB;AACzF,UAAM,OAAO,YAAY,MAAM,aAAa,aAAa,UAAU;AACnE,QAAI,QAAQ,aAAa,KAAM,OAAM,IAAI,UAAU,iCAA8B,aAAa;AAE9F,UAAM,UAAU,MAAM,MAAM,cAAc;AAAA,MACxC,WAAW,QAAQ;AAAA,MACnB,WAAW;AAAA,MACX;AAAA,MACA,QAAQ;AAAA,IACV,CAAC;AAED,QAAI;AACJ,QAAI;AACF,OAAC,EAAE,YAAY,IAAI,MAAM,KAAK,KAAK,OAAO;AAAA,QACxC,IAAI;AAAA,QACJ,QAAQ;AAAA,QACR,eAAe,QAAQ,aAAa,IAAI;AAAA,MAC1C;AAAA,IACF,SAAS,OAAO;AACd,YAAM,MAAM,oBAAoB,QAAQ,IAAI,QAAQ;AACpD,YAAM,IAAI,UAAU,yCAAyC,eAAe,KAAK;AAAA,IACnF;AAEA,UAAM,MAAM,sBAAsB,QAAQ,IAAI,WAAW;AACzD,UAAM,MAAM,oBAAoB,QAAQ,IAAI,MAAM;AAClD,UAAM,MAAM,aAAa,QAAQ,IAAI,KAAK,MAAM,EAAE,YAAY,CAAC;AAI/D,WAAO,EAAE,GAAG,SAAS,QAAQ,QAAQ,YAAY;AAAA,EACnD;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,MAAM,cAAc,SAAiD;AACnE,QAAI;AACF,YAAM,SAAS,kBAAkB,OAAO;AACxC,UAAI,OAAO,SAAS,UAAW,QAAO,EAAE,SAAS,MAAM;AAEvD,YAAM,MAAM,KAAK,MAAM;AACvB,YAAM,WAAW,MAAM,KAAK,OAAO,OAAO,OAAO,OAAO,GAAG;AAC3D,UAAI,SAAS,WAAW,QAAS,QAAO,EAAE,SAAS,MAAM;AAEzD,YAAM,EAAE,SAAS,WAAW,KAAK,IAAI;AACrC,YAAM,cAAc,OAAO,MAAM;AAMjC,YAAM,WAAW,MAAM,KAAK,KAAK,MAAM,kBAAkB,QAAQ,IAAI,WAAW;AAChF,UAAI,aAAa,KAAM,QAAO,EAAE,SAAS,KAAK;AAE9C,YAAM,UAAU,MAAM,KAAK,KAAK,MAAM,cAAc;AAAA,QAClD,WAAW,QAAQ;AAAA,QACnB;AAAA,QACA,MAAM;AAAA,QACN;AAAA,QACA,QAAQ;AAAA,MACV,CAAC;AACD,YAAM,KAAK,KAAK,MAAM,aAAa,QAAQ,IAAI,IAAI,YAAY,CAAC;AAChE,YAAM,KAAK,KAAK,UAAU,QAAQ,QAAQ,eAAe,EAAE,MAAM,WAAW,QAAQ,CAAC;AACrF,aAAO,EAAE,SAAS,KAAK;AAAA,IACzB,SAAS,OAAO;AACd,cAAQ,MAAM,+CAA4C,KAAK;AAC/D,aAAO,EAAE,SAAS,MAAM;AAAA,IAC1B;AAAA,EACF;AAAA;AAAA,EAGA,MAAM,QACJ,OACA,UACmE;AACnE,UAAM,UAAU,MAAM,KAAK,KAAK,MAAM,kBAAkB,KAAK;AAC7D,QAAI,YAAY,KAAM,QAAO,EAAE,SAAS,MAAM,UAAU,CAAC,EAAE;AAC3D,UAAM,WAAW,MAAM,KAAK,KAAK,MAAM,aAAa,QAAQ,IAAI,QAAQ;AACxE,WAAO,EAAE,SAAS,SAAS;AAAA,EAC7B;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAWA,MAAc,qBACZ,UACA,SACA,cACA,aACiB;AACjB,UAAM,WACJ,aAAa,SAAS,IAAI,CAAC,cAAc,CAAC,WAAW,CAAC,IAAI,CAAC,YAAY;AACzE,QAAI;AAEJ,eAAW,WAAW,UAAU;AAC9B,UAAI;AACF,cAAM,EAAE,SAAS,IAAI,MAAM,KAAK,KAAK,OAAO,YAAY,UAAU,SAAS,OAAO;AAClF,eAAO;AAAA,MACT,SAAS,OAAO;AACd,oBAAY;AAAA,MACd;AAAA,IACF;AAEA,UAAM,IAAI,UAAU,uCAAuC,uBAAuB,SAAS;AAAA,EAC7F;AACF;","names":[]}
|