@erlancarreira/evolution-chat 0.1.0 → 0.1.2

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/bridge/index.cjs CHANGED
@@ -42,8 +42,14 @@ var ConversationRouter = class {
42
42
  */
43
43
  async decide(msg, now) {
44
44
  void now;
45
- if (!msg.jid.endsWith(GROUP_JID_SUFFIX)) return { action: "ignore" };
46
- const session = await this.store.getSessionByGroupJid(msg.jid);
45
+ let session = null;
46
+ if (msg.jid.endsWith(GROUP_JID_SUFFIX)) {
47
+ session = await this.store.getSessionByGroupJid(msg.jid);
48
+ } else {
49
+ const phone = msg.jid.split("@")[0] ?? "";
50
+ if (phone.length === 0) return { action: "ignore" };
51
+ session = await this.store.getSessionByVisitorPhone(phone);
52
+ }
47
53
  if (session === null) return { action: "unknown_session" };
48
54
  if (await this.store.isEcho(session.id, msg.waMessageId)) return { action: "echo" };
49
55
  if (typeof msg.text !== "string" || msg.text.length === 0) return { action: "not_text" };
@@ -189,6 +195,9 @@ function parseWebhookEvent(payload) {
189
195
  if (typeof state === "string" && state.length > 0) return { kind: "connection", state };
190
196
  return { kind: "ignored", reason: "connection.update sem state" };
191
197
  }
198
+ if (event === "group-participants.update") {
199
+ return parseGroupParticipants(data);
200
+ }
192
201
  if (event !== "messages.upsert") {
193
202
  return { kind: "ignored", reason: `evento n\xE3o tratado: ${event}` };
194
203
  }
@@ -201,6 +210,32 @@ function parseWebhookEvent(payload) {
201
210
  }
202
211
  return lastIgnored;
203
212
  }
213
+ function parseGroupParticipants(data) {
214
+ if (!isRecord(data)) return { kind: "ignored", reason: "group-participants sem data" };
215
+ const groupJid = data["id"];
216
+ if (typeof groupJid !== "string" || groupJid.length === 0) {
217
+ return { kind: "ignored", reason: "group-participants sem id (groupJid)" };
218
+ }
219
+ const rawParticipants = data["participants"];
220
+ if (!Array.isArray(rawParticipants)) {
221
+ return { kind: "ignored", reason: "group-participants sem participants[]" };
222
+ }
223
+ const participants = [];
224
+ for (const p of rawParticipants) {
225
+ if (typeof p === "string" && p.length > 0) participants.push(p);
226
+ }
227
+ if (participants.length === 0) {
228
+ return { kind: "ignored", reason: "group-participants sem participantes v\xE1lidos" };
229
+ }
230
+ const actionRaw = data["action"];
231
+ const action = typeof actionRaw === "string" ? actionRaw : "unknown";
232
+ const authorRaw = data["author"];
233
+ const author = typeof authorRaw === "string" ? authorRaw : null;
234
+ return {
235
+ kind: "group_participants",
236
+ event: { groupJid, participants, action, author, raw: data }
237
+ };
238
+ }
204
239
 
205
240
  // src/bridge/bridge.ts
206
241
  var NAME_MIN = 2;
@@ -230,6 +265,15 @@ function requireText(value, min, max, field) {
230
265
  }
231
266
  return trimmed;
232
267
  }
268
+ function isTargetGoneError(error) {
269
+ if (error === null || typeof error !== "object") return false;
270
+ const status = error.status;
271
+ if (status === 404 || status === 410) return true;
272
+ const text = `${String(error.message ?? "")} ${String(
273
+ error.body ?? ""
274
+ )}`.toLowerCase();
275
+ return /group|participant|recipient|not found|unavailable|left|deleted|blocked|gone/.test(text);
276
+ }
233
277
  var ChatBridge = class {
234
278
  constructor(deps) {
235
279
  this.deps = deps;
@@ -273,12 +317,50 @@ var ChatBridge = class {
273
317
  );
274
318
  }
275
319
  }
320
+ const existing = await store.getSessionByVisitorPhone(phone);
321
+ if (existing !== null && existing.status === "active") {
322
+ const firstTarget2 = existing.groupJid === null ? toWhatsappJid(normalizePhone(cfg.platformNumber)) : existing.groupJid;
323
+ try {
324
+ const { waMessageId: waMessageId2 } = await this.deps.client.sendText(
325
+ cfg.instance,
326
+ firstTarget2,
327
+ formatFirstMessage(existing.code, name, message)
328
+ );
329
+ await store.registerSentMessageId(existing.id, waMessageId2);
330
+ const initial2 = await store.appendMessage({
331
+ sessionId: existing.id,
332
+ direction: "visitor",
333
+ body: message,
334
+ waMessageId: waMessageId2,
335
+ status: "sent"
336
+ });
337
+ return { session: existing, messages: [initial2] };
338
+ } catch (error) {
339
+ if (isTargetGoneError(error)) {
340
+ await this.closeSessionCleanup(existing, "send_target_gone");
341
+ } else {
342
+ await store.markStatus(existing.id, "failed");
343
+ throw new ChatError("Falha ao enviar a primeira mensagem para o grupo", "send_failed", error);
344
+ }
345
+ }
346
+ }
276
347
  const code = generateSessionCode();
277
348
  const visitorJid = toWhatsappJid(phone);
278
349
  const platformJid = toWhatsappJid(normalizePhone(cfg.platformNumber));
279
350
  const participants = [.../* @__PURE__ */ new Set([visitorJid, platformJid])];
280
351
  const subject = `${cfg.projectName} \u2014 ${name} (#${code})`;
281
- const groupJid = await this.createGroupWithRetry(cfg.instance, subject, participants, platformJid);
352
+ const direct = cfg.createGroup === false;
353
+ let groupJid = null;
354
+ if (!direct) {
355
+ groupJid = await this.createGroupWithRetry(cfg.instance, subject, participants, platformJid);
356
+ const picture = (cfg.groupImage ?? "").trim();
357
+ if (picture !== "") {
358
+ try {
359
+ await this.deps.client.setGroupPicture(cfg.instance, groupJid, picture);
360
+ } catch {
361
+ }
362
+ }
363
+ }
282
364
  const session = await store.createSession({
283
365
  code,
284
366
  realtimeToken: generateRealtimeToken(),
@@ -286,14 +368,22 @@ var ChatBridge = class {
286
368
  visitorPhone: phone,
287
369
  visitorContact: input.contact ?? null,
288
370
  groupJid,
371
+ mode: direct ? "direct" : "group",
289
372
  ipHash: input.ipHash ?? null,
290
373
  userAgent: input.userAgent ?? null
291
374
  });
375
+ let firstTarget;
376
+ if (direct) {
377
+ firstTarget = platformJid;
378
+ } else {
379
+ if (groupJid === null) throw new ChatError("Falha ao criar o grupo", "group_create_failed");
380
+ firstTarget = groupJid;
381
+ }
292
382
  let waMessageId;
293
383
  try {
294
384
  ({ waMessageId } = await this.deps.client.sendText(
295
385
  cfg.instance,
296
- groupJid,
386
+ firstTarget,
297
387
  formatFirstMessage(code, name, message)
298
388
  ));
299
389
  } catch (error) {
@@ -318,7 +408,12 @@ var ChatBridge = class {
318
408
  if (session === null) throw new ChatError("Sess\xE3o n\xE3o encontrada", "session_not_found");
319
409
  if (session.status !== "active") throw new ChatError("Sess\xE3o encerrada", "session_closed");
320
410
  const body = requireText(text, MESSAGE_MIN, MESSAGE_MAX, "Mensagem");
321
- if (session.groupJid === null) throw new ChatError("Sess\xE3o sem grupo associado", "send_failed");
411
+ let target;
412
+ if (session.groupJid === null) {
413
+ target = toWhatsappJid(normalizePhone(cfg.platformNumber));
414
+ } else {
415
+ target = session.groupJid;
416
+ }
322
417
  const pending = await store.appendMessage({
323
418
  sessionId: session.id,
324
419
  direction: "visitor",
@@ -329,11 +424,16 @@ var ChatBridge = class {
329
424
  try {
330
425
  ({ waMessageId } = await this.deps.client.sendText(
331
426
  cfg.instance,
332
- session.groupJid,
427
+ target,
333
428
  formatFollowup(session.visitorName, body)
334
429
  ));
335
430
  } catch (error) {
336
431
  await store.updateMessageStatus(pending.id, "failed");
432
+ if (isTargetGoneError(error)) {
433
+ await this.closeSessionCleanup(session, "send_target_gone", {
434
+ systemMessage: "N\xE3o foi poss\xEDvel entregar a mensagem: o grupo n\xE3o est\xE1 mais dispon\xEDvel. Conversa encerrada \u2014 inicie uma nova para continuar."
435
+ });
436
+ }
337
437
  throw new ChatError("Falha ao enviar mensagem para o grupo", "send_failed", error);
338
438
  }
339
439
  await store.registerSentMessageId(session.id, waMessageId);
@@ -348,6 +448,9 @@ var ChatBridge = class {
348
448
  async handleWebhook(payload) {
349
449
  try {
350
450
  const parsed = parseWebhookEvent(payload);
451
+ if (parsed.kind === "group_participants") {
452
+ return await this.handleGroupParticipants(parsed.event);
453
+ }
351
454
  if (parsed.kind !== "message") return { handled: false };
352
455
  const now = this.clock();
353
456
  const decision = await this.router.decide(parsed.event, now);
@@ -371,6 +474,89 @@ var ChatBridge = class {
371
474
  return { handled: false };
372
475
  }
373
476
  }
477
+ /**
478
+ * Trata `group-participants.update` (Evolution): quando alguém entra, sai ou é
479
+ * removido do grupo da sessão, registra uma mensagem de sistema no chat (visível
480
+ * para visitante e time) e, se quem saiu foi o próprio visitante, encerra a sessão
481
+ * — a conversa 1:1 pelo WhatsApp não chega mais pelo grupo.
482
+ * NUNCA lança: qualquer erro é logado e devolvido como tratado (200) para a Evolution
483
+ * não reenviar.
484
+ */
485
+ async handleGroupParticipants(change) {
486
+ try {
487
+ const session = await this.deps.store.getSessionByGroupJid(change.groupJid);
488
+ if (session === null) return { handled: true };
489
+ const phones = change.participants.map((jid) => jid.split("@")[0] ?? jid);
490
+ const visitorLeft = change.participants.some((jid) => {
491
+ const phone = jid.split("@")[0] ?? "";
492
+ return phone !== "" && phone === session.visitorPhone;
493
+ });
494
+ let body;
495
+ switch (change.action) {
496
+ case "leave":
497
+ body = `Participante(s) sa\xEDram do grupo: ${phones.join(", ")}`;
498
+ break;
499
+ case "remove":
500
+ body = `Participante(s) removido(s) do grupo: ${phones.join(", ")}`;
501
+ break;
502
+ case "add":
503
+ body = `Participante(s) adicionado(s) ao grupo: ${phones.join(", ")}`;
504
+ break;
505
+ case "promote":
506
+ body = `Participante(s) promovido(s) a admin: ${phones.join(", ")}`;
507
+ break;
508
+ case "demote":
509
+ body = `Participante(s) rebaixado(s) de admin: ${phones.join(", ")}`;
510
+ break;
511
+ default:
512
+ body = `Altera\xE7\xE3o de participantes no grupo: ${phones.join(", ")}`;
513
+ }
514
+ const now = this.clock().toISOString();
515
+ const message = await this.deps.store.appendMessage({
516
+ sessionId: session.id,
517
+ direction: "system",
518
+ body,
519
+ status: "sent"
520
+ });
521
+ await this.deps.store.touchSession(session.id, now);
522
+ await this.deps.transport.publish(session.realtimeToken, { type: "message", message });
523
+ if (visitorLeft) {
524
+ await this.closeSessionCleanup(session, "visitor_left");
525
+ }
526
+ return { handled: true };
527
+ } catch (error) {
528
+ console.error("[evolution-chat] group-participants n\xE3o processado:", error);
529
+ return { handled: true };
530
+ }
531
+ }
532
+ /**
533
+ * Encerra a sessão (status closed + evento realtime) e — só quando o grupo morreu do
534
+ * lado do visitante (ele saiu ou o destino sumiu) — faz a EMPRESA SAIR do grupo na
535
+ * Evolution (best-effort) para não acumular grupos órfãos. Em fechamentos normais
536
+ * (atendente encerrou a conversa) NÃO saímos: senão o visitante ficaria sozinho no grupo.
537
+ */
538
+ async closeSessionCleanup(session, reason, opts = {}) {
539
+ const now = this.clock().toISOString();
540
+ await this.deps.store.markStatus(session.id, "closed", reason);
541
+ await this.deps.store.touchSession(session.id, now);
542
+ if (opts.systemMessage !== void 0) {
543
+ const notice = await this.deps.store.appendMessage({
544
+ sessionId: session.id,
545
+ direction: "system",
546
+ body: opts.systemMessage,
547
+ status: "sent"
548
+ });
549
+ await this.deps.transport.publish(session.realtimeToken, { type: "message", message: notice });
550
+ }
551
+ await this.deps.transport.publish(session.realtimeToken, { type: "session", status: "closed" });
552
+ if (session.groupJid !== null && (reason === "visitor_left" || reason === "send_target_gone")) {
553
+ try {
554
+ await this.deps.client.leaveGroup(this.getConfig().instance, session.groupJid);
555
+ } catch (error) {
556
+ console.error("[evolution-chat] falha ao sair do grupo \xF3rf\xE3o:", error);
557
+ }
558
+ }
559
+ }
374
560
  /** Replay para o widget: sessão por token + mensagens (opcionalmente após um ISO). */
375
561
  async history(token, afterIso) {
376
562
  const session = await this.deps.store.getSessionByToken(token);
@@ -1 +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":"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","names":[]}
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 (modo grupo e modo direto):\n// - jid de grupo (@g.us): sessão por groupJid (comportamento original).\n// - jid pessoal (1:1, modo direto): sessão pelo telefone do visitante.\n// - isEcho(sessionId, waMessageId) → echo (mensagem enviada por nós)\n// - sem texto → not_text\n// - 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(\n private readonly store: Pick<\n SessionStore,\n \"getSessionByGroupJid\" | \"getSessionByVisitorPhone\" | \"isEcho\"\n >,\n ) {}\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 let session: Awaited<ReturnType<SessionStore[\"getSessionByGroupJid\"]>> = null;\n if (msg.jid.endsWith(GROUP_JID_SUFFIX)) {\n session = await this.store.getSessionByGroupJid(msg.jid);\n } else {\n // Modo direto: o jid da conversa 1:1 é o JID pessoal do visitante (ex.: 5511...@s.whatsapp.net).\n const phone = msg.jid.split(\"@\")[0] ?? \"\";\n if (phone.length === 0) return { action: \"ignore\" };\n session = await this.store.getSessionByVisitorPhone(phone);\n }\n\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 { GroupParticipantChange, InboundMessage } from \"../types\";\n\nexport type ParsedWebhook =\n | { kind: \"message\"; event: InboundMessage }\n | { kind: \"connection\"; state: string }\n | { kind: \"group_participants\"; event: GroupParticipantChange }\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 === \"group-participants.update\") {\n return parseGroupParticipants(data);\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\n/**\n * Normaliza o evento `group-participants.update` da Evolution:\n * { id: \"…@g.us\", participants: [\"…@s.whatsapp.net\"], author: \"…@s.whatsapp.net\",\n * action: \"add\" | \"remove\" | \"leave\" | \"promote\" | \"demote\" }\n * Campos faltantes/fora do formato viram { kind: \"ignored\" } (nunca lança).\n */\nfunction parseGroupParticipants(data: unknown): ParsedWebhook {\n if (!isRecord(data)) return { kind: \"ignored\", reason: \"group-participants sem data\" };\n\n const groupJid = data[\"id\"];\n if (typeof groupJid !== \"string\" || groupJid.length === 0) {\n return { kind: \"ignored\", reason: \"group-participants sem id (groupJid)\" };\n }\n\n const rawParticipants = data[\"participants\"];\n if (!Array.isArray(rawParticipants)) {\n return { kind: \"ignored\", reason: \"group-participants sem participants[]\" };\n }\n const participants: string[] = [];\n for (const p of rawParticipants) {\n if (typeof p === \"string\" && p.length > 0) participants.push(p);\n }\n if (participants.length === 0) {\n return { kind: \"ignored\", reason: \"group-participants sem participantes válidos\" };\n }\n\n const actionRaw = data[\"action\"];\n const action = typeof actionRaw === \"string\" ? actionRaw : \"unknown\";\n const authorRaw = data[\"author\"];\n const author = typeof authorRaw === \"string\" ? authorRaw : null;\n\n return {\n kind: \"group_participants\",\n event: { groupJid, participants, action, author, raw: data },\n };\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, GroupParticipantChange } 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\n/**\n * Detecta erro da Evolution que indica que o destino sumiu: grupo apagado/saiu,\n * participante removido, número bloqueado, etc. Usado para encerrar a sessão com\n * aviso em vez de deixar o composer travado num envio que nunca chega.\n * Sinais: status 404/410 (grupo inexistente / não é participante) ou palavras-chave\n * no corpo/mensagem da resposta.\n */\nfunction isTargetGoneError(error: unknown): boolean {\n if (error === null || typeof error !== \"object\") return false;\n const status = (error as { status?: unknown }).status;\n if (status === 404 || status === 410) return true;\n const text = `${String((error as { message?: unknown }).message ?? \"\")} ${String(\n (error as { body?: unknown }).body ?? \"\",\n )}`.toLowerCase();\n return /group|participant|recipient|not found|unavailable|left|deleted|blocked|gone/.test(text);\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 // Reusa a conversa ativa do mesmo visitante (um grupo/thread por cliente) em vez de\n // abrir um grupo novo a cada reabertura — evita a proliferação de grupos no WhatsApp.\n // Só reaproveita se a sessão estiver ativa; se o grupo sumiu no WhatsApp (visitante\n // saiu/apagou), encerramos a sessão antiga e deixamos o fluxo abaixo recriar um grupo\n // novo para o mesmo visitante (auto-recuperação — sem grupo zumbi preso no histórico).\n const existing = await store.getSessionByVisitorPhone(phone);\n if (existing !== null && existing.status === \"active\") {\n const firstTarget =\n existing.groupJid === null\n ? toWhatsappJid(normalizePhone(cfg.platformNumber))\n : existing.groupJid;\n try {\n const { waMessageId } = await this.deps.client.sendText(\n cfg.instance,\n firstTarget,\n formatFirstMessage(existing.code, name, message),\n );\n await store.registerSentMessageId(existing.id, waMessageId);\n const initial = await store.appendMessage({\n sessionId: existing.id,\n direction: \"visitor\",\n body: message,\n waMessageId,\n status: \"sent\",\n });\n return { session: existing, messages: [initial] };\n } catch (error) {\n if (isTargetGoneError(error)) {\n // Grupo apagado/saído: encerra a sessão antiga e recria um grupo novo abaixo.\n await this.closeSessionCleanup(existing, \"send_target_gone\");\n } else {\n await store.markStatus(existing.id, \"failed\");\n throw new ChatError(\"Falha ao enviar a primeira mensagem para o grupo\", \"send_failed\", error);\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 const direct = cfg.createGroup === false;\n\n let groupJid: string | null = null;\n if (!direct) {\n groupJid = await this.createGroupWithRetry(cfg.instance, subject, participants, platformJid);\n // Imagem de capa do grupo (logo do site / upload do painel). Cosmético: falha não aborta.\n const picture = (cfg.groupImage ?? \"\").trim();\n if (picture !== \"\") {\n try {\n await this.deps.client.setGroupPicture(cfg.instance, groupJid, picture);\n } catch {\n // ignora falha de imagem — o atendimento segue sem a capa.\n }\n }\n }\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 mode: direct ? \"direct\" : \"group\",\n ipHash: input.ipHash ?? null,\n userAgent: input.userAgent ?? null,\n });\n\n // Em modo direto a conversa é 1:1 com a plataforma; senão, vai para o grupo.\n let firstTarget: string;\n if (direct) {\n firstTarget = platformJid;\n } else {\n if (groupJid === null) throw new ChatError(\"Falha ao criar o grupo\", \"group_create_failed\");\n firstTarget = groupJid;\n }\n\n let waMessageId: string;\n try {\n ({ waMessageId } = await this.deps.client.sendText(\n cfg.instance,\n firstTarget,\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\n // Destino: grupo (modo grupo) ou número da plataforma (modo direto 1:1, groupJid nulo).\n let target: string;\n if (session.groupJid === null) {\n target = toWhatsappJid(normalizePhone(cfg.platformNumber));\n } else {\n target = session.groupJid;\n }\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 target,\n formatFollowup(session.visitorName, body),\n ));\n } catch (error) {\n await store.updateMessageStatus(pending.id, \"failed\");\n // Se o destino sumiu (grupo saiu/apagado, número bloqueado), encerra a sessão com\n // aviso claro e sai do grupo órfão (best-effort) em vez de deixar o composer travado.\n if (isTargetGoneError(error)) {\n await this.closeSessionCleanup(session, \"send_target_gone\", {\n systemMessage:\n \"Não foi possível entregar a mensagem: o grupo não está mais disponível. Conversa encerrada — inicie uma nova para continuar.\",\n });\n }\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 === \"group_participants\") {\n return await this.handleGroupParticipants(parsed.event);\n }\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 /**\n * Trata `group-participants.update` (Evolution): quando alguém entra, sai ou é\n * removido do grupo da sessão, registra uma mensagem de sistema no chat (visível\n * para visitante e time) e, se quem saiu foi o próprio visitante, encerra a sessão\n * — a conversa 1:1 pelo WhatsApp não chega mais pelo grupo.\n * NUNCA lança: qualquer erro é logado e devolvido como tratado (200) para a Evolution\n * não reenviar.\n */\n private async handleGroupParticipants(\n change: GroupParticipantChange,\n ): Promise<{ handled: boolean }> {\n try {\n const session = await this.deps.store.getSessionByGroupJid(change.groupJid);\n if (session === null) return { handled: true }; // grupo não é desta plataforma\n\n const phones = change.participants.map((jid) => jid.split(\"@\")[0] ?? jid);\n const visitorLeft = change.participants.some((jid) => {\n const phone = jid.split(\"@\")[0] ?? \"\";\n return phone !== \"\" && phone === session.visitorPhone;\n });\n\n let body: string;\n switch (change.action) {\n case \"leave\":\n body = `Participante(s) saíram do grupo: ${phones.join(\", \")}`;\n break;\n case \"remove\":\n body = `Participante(s) removido(s) do grupo: ${phones.join(\", \")}`;\n break;\n case \"add\":\n body = `Participante(s) adicionado(s) ao grupo: ${phones.join(\", \")}`;\n break;\n case \"promote\":\n body = `Participante(s) promovido(s) a admin: ${phones.join(\", \")}`;\n break;\n case \"demote\":\n body = `Participante(s) rebaixado(s) de admin: ${phones.join(\", \")}`;\n break;\n default:\n body = `Alteração de participantes no grupo: ${phones.join(\", \")}`;\n }\n\n const now = this.clock().toISOString();\n const message = await this.deps.store.appendMessage({\n sessionId: session.id,\n direction: \"system\",\n body,\n status: \"sent\",\n });\n await this.deps.store.touchSession(session.id, now);\n await this.deps.transport.publish(session.realtimeToken, { type: \"message\", message });\n\n if (visitorLeft) {\n await this.closeSessionCleanup(session, \"visitor_left\");\n }\n\n return { handled: true };\n } catch (error) {\n console.error(\"[evolution-chat] group-participants não processado:\", error);\n return { handled: true };\n }\n }\n\n /**\n * Encerra a sessão (status closed + evento realtime) e — só quando o grupo morreu do\n * lado do visitante (ele saiu ou o destino sumiu) — faz a EMPRESA SAIR do grupo na\n * Evolution (best-effort) para não acumular grupos órfãos. Em fechamentos normais\n * (atendente encerrou a conversa) NÃO saímos: senão o visitante ficaria sozinho no grupo.\n */\n private async closeSessionCleanup(\n session: ChatSession,\n reason: string,\n opts: { systemMessage?: string } = {},\n ): Promise<void> {\n const now = this.clock().toISOString();\n await this.deps.store.markStatus(session.id, \"closed\", reason);\n await this.deps.store.touchSession(session.id, now);\n\n if (opts.systemMessage !== undefined) {\n const notice = await this.deps.store.appendMessage({\n sessionId: session.id,\n direction: \"system\",\n body: opts.systemMessage,\n status: \"sent\",\n });\n await this.deps.transport.publish(session.realtimeToken, { type: \"message\", message: notice });\n }\n\n await this.deps.transport.publish(session.realtimeToken, { type: \"session\", status: \"closed\" });\n\n if (\n session.groupJid !== null &&\n (reason === \"visitor_left\" || reason === \"send_target_gone\")\n ) {\n try {\n await this.deps.client.leaveGroup(this.getConfig().instance, session.groupJid);\n } catch (error) {\n console.error(\"[evolution-chat] falha ao sair do grupo órfão:\", error);\n }\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":"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","names":["firstTarget","waMessageId","initial"]}
@@ -1,4 +1,4 @@
1
- type ChatMessageDirection = "visitor" | "owner";
1
+ type ChatMessageDirection = "visitor" | "owner" | "system";
2
2
  type ChatMessageStatus = "pending" | "sent" | "failed";
3
3
  type ChatSessionStatus = "active" | "closed" | "failed";
4
4
  /** Mensagem persistida (domínio). */
@@ -20,6 +20,8 @@ interface ChatSession {
20
20
  visitorPhone: string;
21
21
  visitorContact: string | null;
22
22
  groupJid: string | null;
23
+ /** Modo da conversa: "group" (grupo por visitante, padrão) ou "direct" (1:1 com a plataforma). */
24
+ mode?: "group" | "direct";
23
25
  status: ChatSessionStatus;
24
26
  createdAt: string;
25
27
  lastMessageAt: string | null;
@@ -39,6 +41,10 @@ interface ChatConfig {
39
41
  closeHours: number;
40
42
  leaveOnClose: boolean;
41
43
  webhookToken: string;
44
+ /** true (padrão) = cria um grupo por visitante; false = conversa 1:1 direta com a plataforma. */
45
+ createGroup?: boolean;
46
+ /** URL pública da imagem de capa do grupo (opcional; ignora se vazia). */
47
+ groupImage?: string;
42
48
  }
43
49
  /** Evento de tempo real publicado no canal broadcast `chat:<realtimeToken>`. */
44
50
  type ChatEvent = {
@@ -71,6 +77,8 @@ interface SessionStore {
71
77
  visitorPhone: string;
72
78
  visitorContact?: string | null;
73
79
  groupJid: string | null;
80
+ /** Modo da conversa: "group" (padrão) ou "direct" (1:1 com a plataforma). Opcional. */
81
+ mode?: "group" | "direct";
74
82
  /** Hash não-reversível do IP (LGPD): usado por countRecentSessionsByIpHash. */
75
83
  ipHash?: string | null;
76
84
  /** User-Agent bruto da requisição (auditoria/forense; nunca exibido). */
@@ -78,6 +86,8 @@ interface SessionStore {
78
86
  }): Promise<ChatSession>;
79
87
  getSessionByToken(token: string): Promise<ChatSession | null>;
80
88
  getSessionByGroupJid(jid: string): Promise<ChatSession | null>;
89
+ /** Lookup da sessão ativa de um visitante pelo telefone (modo direto 1:1). */
90
+ getSessionByVisitorPhone(phone: string): Promise<ChatSession | null>;
81
91
  appendMessage(input: {
82
92
  sessionId: string;
83
93
  direction: ChatMessageDirection;
@@ -144,7 +154,7 @@ type RouterDecision = {
144
154
 
145
155
  declare class ConversationRouter {
146
156
  private readonly store;
147
- constructor(store: Pick<SessionStore, "getSessionByGroupJid" | "isEcho">);
157
+ constructor(store: Pick<SessionStore, "getSessionByGroupJid" | "getSessionByVisitorPhone" | "isEcho">);
148
158
  /**
149
159
  * `now` faz parte do contrato para que a bridge propague uma única leitura de relógio
150
160
  * por mensagem (touch/close/limite de horário nas próximas tasks); o router puro ainda
@@ -167,13 +177,25 @@ interface CreateGroupResult {
167
177
  interface EvolutionClient {
168
178
  sendText(instance: string, number: string, text: string): Promise<SendTextResult>;
169
179
  createGroup(instance: string, subject: string, participants: string[], description?: string): Promise<CreateGroupResult>;
180
+ setGroupPicture(instance: string, groupJid: string, image: string): Promise<void>;
170
181
  leaveGroup(instance: string, groupJid: string): Promise<void>;
182
+ /** Encerra a sessão WhatsApp da instância (desvincula o dispositivo). */
183
+ logout(instance: string): Promise<void>;
171
184
  getConnectionState(instance: string): Promise<"open" | "connecting" | "close">;
172
185
  connectQR(instance: string): Promise<{
173
186
  qrBase64: string | null;
174
187
  pairingCode: string | null;
175
188
  }>;
176
189
  setWebhook(instance: string, url: string, events: string[]): Promise<void>;
190
+ /** Cria a instância na Evolution (one-time). Exige a apiKey com permissão de criação. */
191
+ createInstance(instance: string, integration?: "WHATSAPP-BAILEYS" | "WHATSAPP-BUSINESS"): Promise<void>;
192
+ /**
193
+ * Garante que a instância existe (idempotente): se `getConnectionState` responde
194
+ * (qualquer estado), não faz nada; só chama `createInstance` quando a leitura falha
195
+ * (instância inexistente). Assim o LMS gerencia o ciclo de vida sem criar manualmente
196
+ * na Evolution — o único passo humano é escanear o QR de pareamento.
197
+ */
198
+ ensureInstance(instance: string): Promise<void>;
177
199
  }
178
200
 
179
201
  interface StartChatInput {
@@ -218,6 +240,22 @@ declare class ChatBridge {
218
240
  handleWebhook(payload: unknown): Promise<{
219
241
  handled: boolean;
220
242
  }>;
243
+ /**
244
+ * Trata `group-participants.update` (Evolution): quando alguém entra, sai ou é
245
+ * removido do grupo da sessão, registra uma mensagem de sistema no chat (visível
246
+ * para visitante e time) e, se quem saiu foi o próprio visitante, encerra a sessão
247
+ * — a conversa 1:1 pelo WhatsApp não chega mais pelo grupo.
248
+ * NUNCA lança: qualquer erro é logado e devolvido como tratado (200) para a Evolution
249
+ * não reenviar.
250
+ */
251
+ private handleGroupParticipants;
252
+ /**
253
+ * Encerra a sessão (status closed + evento realtime) e — só quando o grupo morreu do
254
+ * lado do visitante (ele saiu ou o destino sumiu) — faz a EMPRESA SAIR do grupo na
255
+ * Evolution (best-effort) para não acumular grupos órfãos. Em fechamentos normais
256
+ * (atendente encerrou a conversa) NÃO saímos: senão o visitante ficaria sozinho no grupo.
257
+ */
258
+ private closeSessionCleanup;
221
259
  /** Replay para o widget: sessão por token + mensagens (opcionalmente após um ISO). */
222
260
  history(token: string, afterIso?: string | null): Promise<{
223
261
  session: ChatSession | null;