@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/next/index.d.cts CHANGED
@@ -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 = {
@@ -58,6 +64,8 @@ interface SessionStore {
58
64
  visitorPhone: string;
59
65
  visitorContact?: string | null;
60
66
  groupJid: string | null;
67
+ /** Modo da conversa: "group" (padrão) ou "direct" (1:1 com a plataforma). Opcional. */
68
+ mode?: "group" | "direct";
61
69
  /** Hash não-reversível do IP (LGPD): usado por countRecentSessionsByIpHash. */
62
70
  ipHash?: string | null;
63
71
  /** User-Agent bruto da requisição (auditoria/forense; nunca exibido). */
@@ -65,6 +73,8 @@ interface SessionStore {
65
73
  }): Promise<ChatSession>;
66
74
  getSessionByToken(token: string): Promise<ChatSession | null>;
67
75
  getSessionByGroupJid(jid: string): Promise<ChatSession | null>;
76
+ /** Lookup da sessão ativa de um visitante pelo telefone (modo direto 1:1). */
77
+ getSessionByVisitorPhone(phone: string): Promise<ChatSession | null>;
68
78
  appendMessage(input: {
69
79
  sessionId: string;
70
80
  direction: ChatMessageDirection;
@@ -111,13 +121,25 @@ interface CreateGroupResult {
111
121
  interface EvolutionClient {
112
122
  sendText(instance: string, number: string, text: string): Promise<SendTextResult>;
113
123
  createGroup(instance: string, subject: string, participants: string[], description?: string): Promise<CreateGroupResult>;
124
+ setGroupPicture(instance: string, groupJid: string, image: string): Promise<void>;
114
125
  leaveGroup(instance: string, groupJid: string): Promise<void>;
126
+ /** Encerra a sessão WhatsApp da instância (desvincula o dispositivo). */
127
+ logout(instance: string): Promise<void>;
115
128
  getConnectionState(instance: string): Promise<"open" | "connecting" | "close">;
116
129
  connectQR(instance: string): Promise<{
117
130
  qrBase64: string | null;
118
131
  pairingCode: string | null;
119
132
  }>;
120
133
  setWebhook(instance: string, url: string, events: string[]): Promise<void>;
134
+ /** Cria a instância na Evolution (one-time). Exige a apiKey com permissão de criação. */
135
+ createInstance(instance: string, integration?: "WHATSAPP-BAILEYS" | "WHATSAPP-BUSINESS"): Promise<void>;
136
+ /**
137
+ * Garante que a instância existe (idempotente): se `getConnectionState` responde
138
+ * (qualquer estado), não faz nada; só chama `createInstance` quando a leitura falha
139
+ * (instância inexistente). Assim o LMS gerencia o ciclo de vida sem criar manualmente
140
+ * na Evolution — o único passo humano é escanear o QR de pareamento.
141
+ */
142
+ ensureInstance(instance: string): Promise<void>;
121
143
  }
122
144
 
123
145
  interface StartChatInput {
@@ -162,6 +184,22 @@ declare class ChatBridge {
162
184
  handleWebhook(payload: unknown): Promise<{
163
185
  handled: boolean;
164
186
  }>;
187
+ /**
188
+ * Trata `group-participants.update` (Evolution): quando alguém entra, sai ou é
189
+ * removido do grupo da sessão, registra uma mensagem de sistema no chat (visível
190
+ * para visitante e time) e, se quem saiu foi o próprio visitante, encerra a sessão
191
+ * — a conversa 1:1 pelo WhatsApp não chega mais pelo grupo.
192
+ * NUNCA lança: qualquer erro é logado e devolvido como tratado (200) para a Evolution
193
+ * não reenviar.
194
+ */
195
+ private handleGroupParticipants;
196
+ /**
197
+ * Encerra a sessão (status closed + evento realtime) e — só quando o grupo morreu do
198
+ * lado do visitante (ele saiu ou o destino sumiu) — faz a EMPRESA SAIR do grupo na
199
+ * Evolution (best-effort) para não acumular grupos órfãos. Em fechamentos normais
200
+ * (atendente encerrou a conversa) NÃO saímos: senão o visitante ficaria sozinho no grupo.
201
+ */
202
+ private closeSessionCleanup;
165
203
  /** Replay para o widget: sessão por token + mensagens (opcionalmente após um ISO). */
166
204
  history(token: string, afterIso?: string | null): Promise<{
167
205
  session: ChatSession | null;
package/next/index.d.ts CHANGED
@@ -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 = {
@@ -58,6 +64,8 @@ interface SessionStore {
58
64
  visitorPhone: string;
59
65
  visitorContact?: string | null;
60
66
  groupJid: string | null;
67
+ /** Modo da conversa: "group" (padrão) ou "direct" (1:1 com a plataforma). Opcional. */
68
+ mode?: "group" | "direct";
61
69
  /** Hash não-reversível do IP (LGPD): usado por countRecentSessionsByIpHash. */
62
70
  ipHash?: string | null;
63
71
  /** User-Agent bruto da requisição (auditoria/forense; nunca exibido). */
@@ -65,6 +73,8 @@ interface SessionStore {
65
73
  }): Promise<ChatSession>;
66
74
  getSessionByToken(token: string): Promise<ChatSession | null>;
67
75
  getSessionByGroupJid(jid: string): Promise<ChatSession | null>;
76
+ /** Lookup da sessão ativa de um visitante pelo telefone (modo direto 1:1). */
77
+ getSessionByVisitorPhone(phone: string): Promise<ChatSession | null>;
68
78
  appendMessage(input: {
69
79
  sessionId: string;
70
80
  direction: ChatMessageDirection;
@@ -111,13 +121,25 @@ interface CreateGroupResult {
111
121
  interface EvolutionClient {
112
122
  sendText(instance: string, number: string, text: string): Promise<SendTextResult>;
113
123
  createGroup(instance: string, subject: string, participants: string[], description?: string): Promise<CreateGroupResult>;
124
+ setGroupPicture(instance: string, groupJid: string, image: string): Promise<void>;
114
125
  leaveGroup(instance: string, groupJid: string): Promise<void>;
126
+ /** Encerra a sessão WhatsApp da instância (desvincula o dispositivo). */
127
+ logout(instance: string): Promise<void>;
115
128
  getConnectionState(instance: string): Promise<"open" | "connecting" | "close">;
116
129
  connectQR(instance: string): Promise<{
117
130
  qrBase64: string | null;
118
131
  pairingCode: string | null;
119
132
  }>;
120
133
  setWebhook(instance: string, url: string, events: string[]): Promise<void>;
134
+ /** Cria a instância na Evolution (one-time). Exige a apiKey com permissão de criação. */
135
+ createInstance(instance: string, integration?: "WHATSAPP-BAILEYS" | "WHATSAPP-BUSINESS"): Promise<void>;
136
+ /**
137
+ * Garante que a instância existe (idempotente): se `getConnectionState` responde
138
+ * (qualquer estado), não faz nada; só chama `createInstance` quando a leitura falha
139
+ * (instância inexistente). Assim o LMS gerencia o ciclo de vida sem criar manualmente
140
+ * na Evolution — o único passo humano é escanear o QR de pareamento.
141
+ */
142
+ ensureInstance(instance: string): Promise<void>;
121
143
  }
122
144
 
123
145
  interface StartChatInput {
@@ -162,6 +184,22 @@ declare class ChatBridge {
162
184
  handleWebhook(payload: unknown): Promise<{
163
185
  handled: boolean;
164
186
  }>;
187
+ /**
188
+ * Trata `group-participants.update` (Evolution): quando alguém entra, sai ou é
189
+ * removido do grupo da sessão, registra uma mensagem de sistema no chat (visível
190
+ * para visitante e time) e, se quem saiu foi o próprio visitante, encerra a sessão
191
+ * — a conversa 1:1 pelo WhatsApp não chega mais pelo grupo.
192
+ * NUNCA lança: qualquer erro é logado e devolvido como tratado (200) para a Evolution
193
+ * não reenviar.
194
+ */
195
+ private handleGroupParticipants;
196
+ /**
197
+ * Encerra a sessão (status closed + evento realtime) e — só quando o grupo morreu do
198
+ * lado do visitante (ele saiu ou o destino sumiu) — faz a EMPRESA SAIR do grupo na
199
+ * Evolution (best-effort) para não acumular grupos órfãos. Em fechamentos normais
200
+ * (atendente encerrou a conversa) NÃO saímos: senão o visitante ficaria sozinho no grupo.
201
+ */
202
+ private closeSessionCleanup;
165
203
  /** Replay para o widget: sessão por token + mensagens (opcionalmente após um ISO). */
166
204
  history(token: string, afterIso?: string | null): Promise<{
167
205
  session: ChatSession | null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@erlancarreira/evolution-chat",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Chat widget + Evolution API bridge (WhatsApp) — reutilizável, hexagonal, SOLID",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -20,6 +20,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/transports/supabase/index.ts
21
21
  var supabase_exports = {};
22
22
  __export(supabase_exports, {
23
+ __resetRealtimeClientCache: () => __resetRealtimeClientCache,
23
24
  createSupabaseRealtimeHandle: () => createSupabaseRealtimeHandle,
24
25
  createSupabaseTransport: () => createSupabaseTransport
25
26
  });
@@ -28,6 +29,27 @@ var import_supabase_js = require("@supabase/supabase-js");
28
29
  var CHANNEL_PREFIX = "chat:";
29
30
  var BROADCAST_EVENT = "chat";
30
31
  var channelName = (realtimeToken) => `${CHANNEL_PREFIX}${realtimeToken}`;
32
+ var clientCache = /* @__PURE__ */ new Map();
33
+ function getRealtimeClient(url, anonKey) {
34
+ const cached = clientCache.get(url);
35
+ if (cached) return cached;
36
+ const client = (0, import_supabase_js.createClient)(url, anonKey, {
37
+ auth: {
38
+ // O widget só assina um canal broadcast público: não precisa de sessão persistida,
39
+ // nem de refresh de token, nem de ler a URL. Desligar o persist + usar storageKey
40
+ // próprio garante que este client NÃO concorra com o auth do app pela mesma chave.
41
+ persistSession: false,
42
+ autoRefreshToken: false,
43
+ detectSessionInUrl: false,
44
+ storageKey: "evolution-chat-realtime"
45
+ }
46
+ });
47
+ clientCache.set(url, client);
48
+ return client;
49
+ }
50
+ function __resetRealtimeClientCache() {
51
+ clientCache.clear();
52
+ }
31
53
  var STATUS_BY_REALTIME_STATE = {
32
54
  SUBSCRIBED: "open",
33
55
  CHANNEL_ERROR: "closed",
@@ -50,7 +72,7 @@ function createSupabaseTransport(admin) {
50
72
  };
51
73
  }
52
74
  function createSupabaseRealtimeHandle(url, anonKey) {
53
- const client = (0, import_supabase_js.createClient)(url, anonKey);
75
+ const client = getRealtimeClient(url, anonKey);
54
76
  return {
55
77
  subscribe(realtimeToken, onEvent, onStatus) {
56
78
  const channel = client.channel(channelName(realtimeToken));
@@ -68,6 +90,7 @@ function createSupabaseRealtimeHandle(url, anonKey) {
68
90
  }
69
91
  // Annotate the CommonJS export names for ESM import in node:
70
92
  0 && (module.exports = {
93
+ __resetRealtimeClientCache,
71
94
  createSupabaseRealtimeHandle,
72
95
  createSupabaseTransport
73
96
  });
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/transports/supabase/index.ts"],"sourcesContent":["// src/transports/supabase/index.ts — adapter Supabase Realtime (broadcast) para as portas\n// RealtimeTransport (server publica) e RealtimeHandle (widget assina).\n//\n// Canal por sessão: `chat:<realtimeToken>`, evento broadcast `chat`, payload = ChatEvent.\n// O token aleatório de 24 bytes (192 bits, src/api/ids.ts) gerado na criação da sessão é a\n// única credencial do canal (broadcast não passa por RLS), então quem não tem o token não escuta.\n//\n// Server (createSupabaseTransport): recebe um SupabaseClient já construído pelo hospedeiro\n// (tipicamente com a service key). Não assinamos o canal: no supabase-js v2, `send()` num\n// canal não-joined cai no endpoint REST de broadcast (POST /realtime/v1/api/broadcast), que\n// é exatamente o caminho recomendado para publicar sem manter um WebSocket aberto. Basta\n// aguardar o `send()` — ele resolve com 'ok' | 'timed out' | 'error' | …\n//\n// Widget (createSupabaseRealtimeHandle): constrói seu próprio client com a anon key e\n// assina o canal; `subscribe` devolve o `unsubscribe` (deixa o canal) e reporta a conexão\n// via onStatus (\"open\" em SUBSCRIBED, \"closed\" em CHANNEL_ERROR/TIMED_OUT/CLOSED).\n\nimport { createClient, type SupabaseClient } from \"@supabase/supabase-js\";\nimport type { ChatEvent, RealtimeHandle, RealtimeTransport } from \"../../bridge\";\n\n/** Prefixo do canal por sessão: `chat:<realtimeToken>`. */\nconst CHANNEL_PREFIX = \"chat:\";\n/** Nome do evento broadcast (o único usado pelo SDK). */\nconst BROADCAST_EVENT = \"chat\";\n\nconst channelName = (realtimeToken: string): string => `${CHANNEL_PREFIX}${realtimeToken}`;\n\n/**\n * Estados de assinatura do Realtime → \"open\"/\"closed\" da porta.\n * Comparar via lookup (e não `status === \"SUBSCRIBED\"`) porque REALTIME_SUBSCRIBE_STATES é\n * um enum string não reexportado por @supabase/supabase-js; o valor em runtime é a string.\n * Desconhecidos caem em \"closed\" (conservador: o widget mostra \"reconectando\").\n */\nconst STATUS_BY_REALTIME_STATE: Record<string, \"open\" | \"closed\"> = {\n SUBSCRIBED: \"open\",\n CHANNEL_ERROR: \"closed\",\n TIMED_OUT: \"closed\",\n CLOSED: \"closed\",\n};\n\n/** Publica `event` no canal broadcast da sessão. Rejeita se o broadcast não for aceito. */\nexport function createSupabaseTransport(admin: SupabaseClient): RealtimeTransport {\n return {\n async publish(realtimeToken: string, event: ChatEvent): Promise<void> {\n const channel = channelName(realtimeToken);\n const response = await admin.channel(channel).send({\n type: \"broadcast\",\n event: BROADCAST_EVENT,\n payload: event,\n });\n // 'ok' | 'timed out' | 'error' | 'rate limited' | 'channel error' | …\n // Rejeitar em não-'ok' é intencional: o bridge (Task 6) captura e devolve\n // handled:false, acionando a reentrega idempotente do webhook.\n if (response !== \"ok\") {\n throw new Error(`Supabase broadcast \"${channel}\" não entregue: ${response}`);\n }\n },\n };\n}\n\n/**\n * Handle client-side (widget): `subscribe(token, onEvent, onStatus?)` assina\n * `chat:<token>` e devolve o unsubscribe. O client é criado uma única vez na fábrica.\n */\nexport function createSupabaseRealtimeHandle(url: string, anonKey: string): RealtimeHandle {\n const client = createClient(url, anonKey);\n\n return {\n subscribe(realtimeToken, onEvent, onStatus) {\n const channel = client.channel(channelName(realtimeToken));\n\n channel.on(\"broadcast\", { event: BROADCAST_EVENT }, (message: { payload: unknown }) => {\n onEvent(message.payload as ChatEvent);\n });\n\n channel.subscribe((status: string) => {\n onStatus?.(STATUS_BY_REALTIME_STATE[status] ?? \"closed\");\n });\n\n return () => {\n void channel.unsubscribe();\n };\n },\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAiBA,yBAAkD;AAIlD,IAAM,iBAAiB;AAEvB,IAAM,kBAAkB;AAExB,IAAM,cAAc,CAAC,kBAAkC,GAAG,cAAc,GAAG,aAAa;AAQxF,IAAM,2BAA8D;AAAA,EAClE,YAAY;AAAA,EACZ,eAAe;AAAA,EACf,WAAW;AAAA,EACX,QAAQ;AACV;AAGO,SAAS,wBAAwB,OAA0C;AAChF,SAAO;AAAA,IACL,MAAM,QAAQ,eAAuB,OAAiC;AACpE,YAAM,UAAU,YAAY,aAAa;AACzC,YAAM,WAAW,MAAM,MAAM,QAAQ,OAAO,EAAE,KAAK;AAAA,QACjD,MAAM;AAAA,QACN,OAAO;AAAA,QACP,SAAS;AAAA,MACX,CAAC;AAID,UAAI,aAAa,MAAM;AACrB,cAAM,IAAI,MAAM,uBAAuB,OAAO,sBAAmB,QAAQ,EAAE;AAAA,MAC7E;AAAA,IACF;AAAA,EACF;AACF;AAMO,SAAS,6BAA6B,KAAa,SAAiC;AACzF,QAAM,aAAS,iCAAa,KAAK,OAAO;AAExC,SAAO;AAAA,IACL,UAAU,eAAe,SAAS,UAAU;AAC1C,YAAM,UAAU,OAAO,QAAQ,YAAY,aAAa,CAAC;AAEzD,cAAQ,GAAG,aAAa,EAAE,OAAO,gBAAgB,GAAG,CAAC,YAAkC;AACrF,gBAAQ,QAAQ,OAAoB;AAAA,MACtC,CAAC;AAED,cAAQ,UAAU,CAAC,WAAmB;AACpC,mBAAW,yBAAyB,MAAM,KAAK,QAAQ;AAAA,MACzD,CAAC;AAED,aAAO,MAAM;AACX,aAAK,QAAQ,YAAY;AAAA,MAC3B;AAAA,IACF;AAAA,EACF;AACF;","names":[]}
1
+ {"version":3,"sources":["../../src/transports/supabase/index.ts"],"sourcesContent":["// src/transports/supabase/index.ts — adapter Supabase Realtime (broadcast) para as portas\n// RealtimeTransport (server publica) e RealtimeHandle (widget assina).\n//\n// Canal por sessão: `chat:<realtimeToken>`, evento broadcast `chat`, payload = ChatEvent.\n// O token aleatório de 24 bytes (192 bits, src/api/ids.ts) gerado na criação da sessão é a\n// única credencial do canal (broadcast não passa por RLS), então quem não tem o token não escuta.\n//\n// Server (createSupabaseTransport): recebe um SupabaseClient já construído pelo hospedeiro\n// (tipicamente com a service key). Não assinamos o canal: no supabase-js v2, `send()` num\n// canal não-joined cai no endpoint REST de broadcast (POST /realtime/v1/api/broadcast), que\n// é exatamente o caminho recomendado para publicar sem manter um WebSocket aberto. Basta\n// aguardar o `send()` — ele resolve com 'ok' | 'timed out' | 'error' | …\n//\n// Widget (createSupabaseRealtimeHandle): constrói seu próprio client com a anon key e\n// assina o canal; `subscribe` devolve o `unsubscribe` (deixa o canal) e reporta a conexão\n// via onStatus (\"open\" em SUBSCRIBED, \"closed\" em CHANNEL_ERROR/TIMED_OUT/CLOSED).\n\nimport { createClient, type SupabaseClient } from \"@supabase/supabase-js\";\nimport type { ChatEvent, RealtimeHandle, RealtimeTransport } from \"../../bridge\";\n\n/** Prefixo do canal por sessão: `chat:<realtimeToken>`. */\nconst CHANNEL_PREFIX = \"chat:\";\n/** Nome do evento broadcast (o único usado pelo SDK). */\nconst BROADCAST_EVENT = \"chat\";\n\nconst channelName = (realtimeToken: string): string => `${CHANNEL_PREFIX}${realtimeToken}`;\n\n// Um único client por (url) por página. Reconstruir o client a cada render (StrictMode,\n// HMR, múltiplos mounts do widget) cria várias instâncias de GoTrueClient competindo pela\n// MESMA chave de localStorage do client de autenticação do app → dispara o aviso\n// \"Multiple GoTrueClient instances detected\". Cacheamos e isolamos o auth do realtime.\nconst clientCache = new Map<string, SupabaseClient>();\n\nfunction getRealtimeClient(url: string, anonKey: string): SupabaseClient {\n const cached = clientCache.get(url);\n if (cached) return cached;\n\n const client = createClient(url, anonKey, {\n auth: {\n // O widget só assina um canal broadcast público: não precisa de sessão persistida,\n // nem de refresh de token, nem de ler a URL. Desligar o persist + usar storageKey\n // próprio garante que este client NÃO concorra com o auth do app pela mesma chave.\n persistSession: false,\n autoRefreshToken: false,\n detectSessionInUrl: false,\n storageKey: \"evolution-chat-realtime\",\n },\n });\n\n clientCache.set(url, client);\n return client;\n}\n\n/** Test-only: esvazia o cache de clientes entre testes. */\nexport function __resetRealtimeClientCache(): void {\n clientCache.clear();\n}\n\n/**\n * Estados de assinatura do Realtime → \"open\"/\"closed\" da porta.\n * Comparar via lookup (e não `status === \"SUBSCRIBED\"`) porque REALTIME_SUBSCRIBE_STATES é\n * um enum string não reexportado por @supabase/supabase-js; o valor em runtime é a string.\n * Desconhecidos caem em \"closed\" (conservador: o widget mostra \"reconectando\").\n */\nconst STATUS_BY_REALTIME_STATE: Record<string, \"open\" | \"closed\"> = {\n SUBSCRIBED: \"open\",\n CHANNEL_ERROR: \"closed\",\n TIMED_OUT: \"closed\",\n CLOSED: \"closed\",\n};\n\n/** Publica `event` no canal broadcast da sessão. Rejeita se o broadcast não for aceito. */\nexport function createSupabaseTransport(admin: SupabaseClient): RealtimeTransport {\n return {\n async publish(realtimeToken: string, event: ChatEvent): Promise<void> {\n const channel = channelName(realtimeToken);\n const response = await admin.channel(channel).send({\n type: \"broadcast\",\n event: BROADCAST_EVENT,\n payload: event,\n });\n // 'ok' | 'timed out' | 'error' | 'rate limited' | 'channel error' | …\n // Rejeitar em não-'ok' é intencional: o bridge (Task 6) captura e devolve\n // handled:false, acionando a reentrega idempotente do webhook.\n if (response !== \"ok\") {\n throw new Error(`Supabase broadcast \"${channel}\" não entregue: ${response}`);\n }\n },\n };\n}\n\n/**\n * Handle client-side (widget): `subscribe(token, onEvent, onStatus?)` assina\n * `chat:<token>` e devolve o unsubscribe. O client é criado uma única vez na fábrica.\n */\nexport function createSupabaseRealtimeHandle(url: string, anonKey: string): RealtimeHandle {\n const client = getRealtimeClient(url, anonKey);\n\n return {\n subscribe(realtimeToken, onEvent, onStatus) {\n const channel = client.channel(channelName(realtimeToken));\n\n channel.on(\"broadcast\", { event: BROADCAST_EVENT }, (message: { payload: unknown }) => {\n onEvent(message.payload as ChatEvent);\n });\n\n channel.subscribe((status: string) => {\n onStatus?.(STATUS_BY_REALTIME_STATE[status] ?? \"closed\");\n });\n\n return () => {\n void channel.unsubscribe();\n };\n },\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAiBA,yBAAkD;AAIlD,IAAM,iBAAiB;AAEvB,IAAM,kBAAkB;AAExB,IAAM,cAAc,CAAC,kBAAkC,GAAG,cAAc,GAAG,aAAa;AAMxF,IAAM,cAAc,oBAAI,IAA4B;AAEpD,SAAS,kBAAkB,KAAa,SAAiC;AACvE,QAAM,SAAS,YAAY,IAAI,GAAG;AAClC,MAAI,OAAQ,QAAO;AAEnB,QAAM,aAAS,iCAAa,KAAK,SAAS;AAAA,IACxC,MAAM;AAAA;AAAA;AAAA;AAAA,MAIJ,gBAAgB;AAAA,MAChB,kBAAkB;AAAA,MAClB,oBAAoB;AAAA,MACpB,YAAY;AAAA,IACd;AAAA,EACF,CAAC;AAED,cAAY,IAAI,KAAK,MAAM;AAC3B,SAAO;AACT;AAGO,SAAS,6BAAmC;AACjD,cAAY,MAAM;AACpB;AAQA,IAAM,2BAA8D;AAAA,EAClE,YAAY;AAAA,EACZ,eAAe;AAAA,EACf,WAAW;AAAA,EACX,QAAQ;AACV;AAGO,SAAS,wBAAwB,OAA0C;AAChF,SAAO;AAAA,IACL,MAAM,QAAQ,eAAuB,OAAiC;AACpE,YAAM,UAAU,YAAY,aAAa;AACzC,YAAM,WAAW,MAAM,MAAM,QAAQ,OAAO,EAAE,KAAK;AAAA,QACjD,MAAM;AAAA,QACN,OAAO;AAAA,QACP,SAAS;AAAA,MACX,CAAC;AAID,UAAI,aAAa,MAAM;AACrB,cAAM,IAAI,MAAM,uBAAuB,OAAO,sBAAmB,QAAQ,EAAE;AAAA,MAC7E;AAAA,IACF;AAAA,EACF;AACF;AAMO,SAAS,6BAA6B,KAAa,SAAiC;AACzF,QAAM,SAAS,kBAAkB,KAAK,OAAO;AAE7C,SAAO;AAAA,IACL,UAAU,eAAe,SAAS,UAAU;AAC1C,YAAM,UAAU,OAAO,QAAQ,YAAY,aAAa,CAAC;AAEzD,cAAQ,GAAG,aAAa,EAAE,OAAO,gBAAgB,GAAG,CAAC,YAAkC;AACrF,gBAAQ,QAAQ,OAAoB;AAAA,MACtC,CAAC;AAED,cAAQ,UAAU,CAAC,WAAmB;AACpC,mBAAW,yBAAyB,MAAM,KAAK,QAAQ;AAAA,MACzD,CAAC;AAED,aAAO,MAAM;AACX,aAAK,QAAQ,YAAY;AAAA,MAC3B;AAAA,IACF;AAAA,EACF;AACF;","names":[]}
@@ -1,6 +1,6 @@
1
1
  import { SupabaseClient } from '@supabase/supabase-js';
2
2
 
3
- type ChatMessageDirection = "visitor" | "owner";
3
+ type ChatMessageDirection = "visitor" | "owner" | "system";
4
4
  type ChatMessageStatus = "pending" | "sent" | "failed";
5
5
  type ChatSessionStatus = "active" | "closed" | "failed";
6
6
  /** Mensagem persistida (domínio). */
@@ -34,6 +34,8 @@ interface RealtimeHandle {
34
34
  subscribe(realtimeToken: string, onEvent: (e: ChatEvent) => void, onStatus?: (s: "open" | "closed") => void): () => void;
35
35
  }
36
36
 
37
+ /** Test-only: esvazia o cache de clientes entre testes. */
38
+ declare function __resetRealtimeClientCache(): void;
37
39
  /** Publica `event` no canal broadcast da sessão. Rejeita se o broadcast não for aceito. */
38
40
  declare function createSupabaseTransport(admin: SupabaseClient): RealtimeTransport;
39
41
  /**
@@ -42,4 +44,4 @@ declare function createSupabaseTransport(admin: SupabaseClient): RealtimeTranspo
42
44
  */
43
45
  declare function createSupabaseRealtimeHandle(url: string, anonKey: string): RealtimeHandle;
44
46
 
45
- export { createSupabaseRealtimeHandle, createSupabaseTransport };
47
+ export { __resetRealtimeClientCache, createSupabaseRealtimeHandle, createSupabaseTransport };
@@ -1,6 +1,6 @@
1
1
  import { SupabaseClient } from '@supabase/supabase-js';
2
2
 
3
- type ChatMessageDirection = "visitor" | "owner";
3
+ type ChatMessageDirection = "visitor" | "owner" | "system";
4
4
  type ChatMessageStatus = "pending" | "sent" | "failed";
5
5
  type ChatSessionStatus = "active" | "closed" | "failed";
6
6
  /** Mensagem persistida (domínio). */
@@ -34,6 +34,8 @@ interface RealtimeHandle {
34
34
  subscribe(realtimeToken: string, onEvent: (e: ChatEvent) => void, onStatus?: (s: "open" | "closed") => void): () => void;
35
35
  }
36
36
 
37
+ /** Test-only: esvazia o cache de clientes entre testes. */
38
+ declare function __resetRealtimeClientCache(): void;
37
39
  /** Publica `event` no canal broadcast da sessão. Rejeita se o broadcast não for aceito. */
38
40
  declare function createSupabaseTransport(admin: SupabaseClient): RealtimeTransport;
39
41
  /**
@@ -42,4 +44,4 @@ declare function createSupabaseTransport(admin: SupabaseClient): RealtimeTranspo
42
44
  */
43
45
  declare function createSupabaseRealtimeHandle(url: string, anonKey: string): RealtimeHandle;
44
46
 
45
- export { createSupabaseRealtimeHandle, createSupabaseTransport };
47
+ export { __resetRealtimeClientCache, createSupabaseRealtimeHandle, createSupabaseTransport };
@@ -3,6 +3,27 @@ import { createClient } from "@supabase/supabase-js";
3
3
  var CHANNEL_PREFIX = "chat:";
4
4
  var BROADCAST_EVENT = "chat";
5
5
  var channelName = (realtimeToken) => `${CHANNEL_PREFIX}${realtimeToken}`;
6
+ var clientCache = /* @__PURE__ */ new Map();
7
+ function getRealtimeClient(url, anonKey) {
8
+ const cached = clientCache.get(url);
9
+ if (cached) return cached;
10
+ const client = createClient(url, anonKey, {
11
+ auth: {
12
+ // O widget só assina um canal broadcast público: não precisa de sessão persistida,
13
+ // nem de refresh de token, nem de ler a URL. Desligar o persist + usar storageKey
14
+ // próprio garante que este client NÃO concorra com o auth do app pela mesma chave.
15
+ persistSession: false,
16
+ autoRefreshToken: false,
17
+ detectSessionInUrl: false,
18
+ storageKey: "evolution-chat-realtime"
19
+ }
20
+ });
21
+ clientCache.set(url, client);
22
+ return client;
23
+ }
24
+ function __resetRealtimeClientCache() {
25
+ clientCache.clear();
26
+ }
6
27
  var STATUS_BY_REALTIME_STATE = {
7
28
  SUBSCRIBED: "open",
8
29
  CHANNEL_ERROR: "closed",
@@ -25,7 +46,7 @@ function createSupabaseTransport(admin) {
25
46
  };
26
47
  }
27
48
  function createSupabaseRealtimeHandle(url, anonKey) {
28
- const client = createClient(url, anonKey);
49
+ const client = getRealtimeClient(url, anonKey);
29
50
  return {
30
51
  subscribe(realtimeToken, onEvent, onStatus) {
31
52
  const channel = client.channel(channelName(realtimeToken));
@@ -42,6 +63,7 @@ function createSupabaseRealtimeHandle(url, anonKey) {
42
63
  };
43
64
  }
44
65
  export {
66
+ __resetRealtimeClientCache,
45
67
  createSupabaseRealtimeHandle,
46
68
  createSupabaseTransport
47
69
  };
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/transports/supabase/index.ts"],"sourcesContent":["// src/transports/supabase/index.ts — adapter Supabase Realtime (broadcast) para as portas\n// RealtimeTransport (server publica) e RealtimeHandle (widget assina).\n//\n// Canal por sessão: `chat:<realtimeToken>`, evento broadcast `chat`, payload = ChatEvent.\n// O token aleatório de 24 bytes (192 bits, src/api/ids.ts) gerado na criação da sessão é a\n// única credencial do canal (broadcast não passa por RLS), então quem não tem o token não escuta.\n//\n// Server (createSupabaseTransport): recebe um SupabaseClient já construído pelo hospedeiro\n// (tipicamente com a service key). Não assinamos o canal: no supabase-js v2, `send()` num\n// canal não-joined cai no endpoint REST de broadcast (POST /realtime/v1/api/broadcast), que\n// é exatamente o caminho recomendado para publicar sem manter um WebSocket aberto. Basta\n// aguardar o `send()` — ele resolve com 'ok' | 'timed out' | 'error' | …\n//\n// Widget (createSupabaseRealtimeHandle): constrói seu próprio client com a anon key e\n// assina o canal; `subscribe` devolve o `unsubscribe` (deixa o canal) e reporta a conexão\n// via onStatus (\"open\" em SUBSCRIBED, \"closed\" em CHANNEL_ERROR/TIMED_OUT/CLOSED).\n\nimport { createClient, type SupabaseClient } from \"@supabase/supabase-js\";\nimport type { ChatEvent, RealtimeHandle, RealtimeTransport } from \"../../bridge\";\n\n/** Prefixo do canal por sessão: `chat:<realtimeToken>`. */\nconst CHANNEL_PREFIX = \"chat:\";\n/** Nome do evento broadcast (o único usado pelo SDK). */\nconst BROADCAST_EVENT = \"chat\";\n\nconst channelName = (realtimeToken: string): string => `${CHANNEL_PREFIX}${realtimeToken}`;\n\n/**\n * Estados de assinatura do Realtime → \"open\"/\"closed\" da porta.\n * Comparar via lookup (e não `status === \"SUBSCRIBED\"`) porque REALTIME_SUBSCRIBE_STATES é\n * um enum string não reexportado por @supabase/supabase-js; o valor em runtime é a string.\n * Desconhecidos caem em \"closed\" (conservador: o widget mostra \"reconectando\").\n */\nconst STATUS_BY_REALTIME_STATE: Record<string, \"open\" | \"closed\"> = {\n SUBSCRIBED: \"open\",\n CHANNEL_ERROR: \"closed\",\n TIMED_OUT: \"closed\",\n CLOSED: \"closed\",\n};\n\n/** Publica `event` no canal broadcast da sessão. Rejeita se o broadcast não for aceito. */\nexport function createSupabaseTransport(admin: SupabaseClient): RealtimeTransport {\n return {\n async publish(realtimeToken: string, event: ChatEvent): Promise<void> {\n const channel = channelName(realtimeToken);\n const response = await admin.channel(channel).send({\n type: \"broadcast\",\n event: BROADCAST_EVENT,\n payload: event,\n });\n // 'ok' | 'timed out' | 'error' | 'rate limited' | 'channel error' | …\n // Rejeitar em não-'ok' é intencional: o bridge (Task 6) captura e devolve\n // handled:false, acionando a reentrega idempotente do webhook.\n if (response !== \"ok\") {\n throw new Error(`Supabase broadcast \"${channel}\" não entregue: ${response}`);\n }\n },\n };\n}\n\n/**\n * Handle client-side (widget): `subscribe(token, onEvent, onStatus?)` assina\n * `chat:<token>` e devolve o unsubscribe. O client é criado uma única vez na fábrica.\n */\nexport function createSupabaseRealtimeHandle(url: string, anonKey: string): RealtimeHandle {\n const client = createClient(url, anonKey);\n\n return {\n subscribe(realtimeToken, onEvent, onStatus) {\n const channel = client.channel(channelName(realtimeToken));\n\n channel.on(\"broadcast\", { event: BROADCAST_EVENT }, (message: { payload: unknown }) => {\n onEvent(message.payload as ChatEvent);\n });\n\n channel.subscribe((status: string) => {\n onStatus?.(STATUS_BY_REALTIME_STATE[status] ?? \"closed\");\n });\n\n return () => {\n void channel.unsubscribe();\n };\n },\n };\n}\n"],"mappings":";AAiBA,SAAS,oBAAyC;AAIlD,IAAM,iBAAiB;AAEvB,IAAM,kBAAkB;AAExB,IAAM,cAAc,CAAC,kBAAkC,GAAG,cAAc,GAAG,aAAa;AAQxF,IAAM,2BAA8D;AAAA,EAClE,YAAY;AAAA,EACZ,eAAe;AAAA,EACf,WAAW;AAAA,EACX,QAAQ;AACV;AAGO,SAAS,wBAAwB,OAA0C;AAChF,SAAO;AAAA,IACL,MAAM,QAAQ,eAAuB,OAAiC;AACpE,YAAM,UAAU,YAAY,aAAa;AACzC,YAAM,WAAW,MAAM,MAAM,QAAQ,OAAO,EAAE,KAAK;AAAA,QACjD,MAAM;AAAA,QACN,OAAO;AAAA,QACP,SAAS;AAAA,MACX,CAAC;AAID,UAAI,aAAa,MAAM;AACrB,cAAM,IAAI,MAAM,uBAAuB,OAAO,sBAAmB,QAAQ,EAAE;AAAA,MAC7E;AAAA,IACF;AAAA,EACF;AACF;AAMO,SAAS,6BAA6B,KAAa,SAAiC;AACzF,QAAM,SAAS,aAAa,KAAK,OAAO;AAExC,SAAO;AAAA,IACL,UAAU,eAAe,SAAS,UAAU;AAC1C,YAAM,UAAU,OAAO,QAAQ,YAAY,aAAa,CAAC;AAEzD,cAAQ,GAAG,aAAa,EAAE,OAAO,gBAAgB,GAAG,CAAC,YAAkC;AACrF,gBAAQ,QAAQ,OAAoB;AAAA,MACtC,CAAC;AAED,cAAQ,UAAU,CAAC,WAAmB;AACpC,mBAAW,yBAAyB,MAAM,KAAK,QAAQ;AAAA,MACzD,CAAC;AAED,aAAO,MAAM;AACX,aAAK,QAAQ,YAAY;AAAA,MAC3B;AAAA,IACF;AAAA,EACF;AACF;","names":[]}
1
+ {"version":3,"sources":["../../src/transports/supabase/index.ts"],"sourcesContent":["// src/transports/supabase/index.ts — adapter Supabase Realtime (broadcast) para as portas\n// RealtimeTransport (server publica) e RealtimeHandle (widget assina).\n//\n// Canal por sessão: `chat:<realtimeToken>`, evento broadcast `chat`, payload = ChatEvent.\n// O token aleatório de 24 bytes (192 bits, src/api/ids.ts) gerado na criação da sessão é a\n// única credencial do canal (broadcast não passa por RLS), então quem não tem o token não escuta.\n//\n// Server (createSupabaseTransport): recebe um SupabaseClient já construído pelo hospedeiro\n// (tipicamente com a service key). Não assinamos o canal: no supabase-js v2, `send()` num\n// canal não-joined cai no endpoint REST de broadcast (POST /realtime/v1/api/broadcast), que\n// é exatamente o caminho recomendado para publicar sem manter um WebSocket aberto. Basta\n// aguardar o `send()` — ele resolve com 'ok' | 'timed out' | 'error' | …\n//\n// Widget (createSupabaseRealtimeHandle): constrói seu próprio client com a anon key e\n// assina o canal; `subscribe` devolve o `unsubscribe` (deixa o canal) e reporta a conexão\n// via onStatus (\"open\" em SUBSCRIBED, \"closed\" em CHANNEL_ERROR/TIMED_OUT/CLOSED).\n\nimport { createClient, type SupabaseClient } from \"@supabase/supabase-js\";\nimport type { ChatEvent, RealtimeHandle, RealtimeTransport } from \"../../bridge\";\n\n/** Prefixo do canal por sessão: `chat:<realtimeToken>`. */\nconst CHANNEL_PREFIX = \"chat:\";\n/** Nome do evento broadcast (o único usado pelo SDK). */\nconst BROADCAST_EVENT = \"chat\";\n\nconst channelName = (realtimeToken: string): string => `${CHANNEL_PREFIX}${realtimeToken}`;\n\n// Um único client por (url) por página. Reconstruir o client a cada render (StrictMode,\n// HMR, múltiplos mounts do widget) cria várias instâncias de GoTrueClient competindo pela\n// MESMA chave de localStorage do client de autenticação do app → dispara o aviso\n// \"Multiple GoTrueClient instances detected\". Cacheamos e isolamos o auth do realtime.\nconst clientCache = new Map<string, SupabaseClient>();\n\nfunction getRealtimeClient(url: string, anonKey: string): SupabaseClient {\n const cached = clientCache.get(url);\n if (cached) return cached;\n\n const client = createClient(url, anonKey, {\n auth: {\n // O widget só assina um canal broadcast público: não precisa de sessão persistida,\n // nem de refresh de token, nem de ler a URL. Desligar o persist + usar storageKey\n // próprio garante que este client NÃO concorra com o auth do app pela mesma chave.\n persistSession: false,\n autoRefreshToken: false,\n detectSessionInUrl: false,\n storageKey: \"evolution-chat-realtime\",\n },\n });\n\n clientCache.set(url, client);\n return client;\n}\n\n/** Test-only: esvazia o cache de clientes entre testes. */\nexport function __resetRealtimeClientCache(): void {\n clientCache.clear();\n}\n\n/**\n * Estados de assinatura do Realtime → \"open\"/\"closed\" da porta.\n * Comparar via lookup (e não `status === \"SUBSCRIBED\"`) porque REALTIME_SUBSCRIBE_STATES é\n * um enum string não reexportado por @supabase/supabase-js; o valor em runtime é a string.\n * Desconhecidos caem em \"closed\" (conservador: o widget mostra \"reconectando\").\n */\nconst STATUS_BY_REALTIME_STATE: Record<string, \"open\" | \"closed\"> = {\n SUBSCRIBED: \"open\",\n CHANNEL_ERROR: \"closed\",\n TIMED_OUT: \"closed\",\n CLOSED: \"closed\",\n};\n\n/** Publica `event` no canal broadcast da sessão. Rejeita se o broadcast não for aceito. */\nexport function createSupabaseTransport(admin: SupabaseClient): RealtimeTransport {\n return {\n async publish(realtimeToken: string, event: ChatEvent): Promise<void> {\n const channel = channelName(realtimeToken);\n const response = await admin.channel(channel).send({\n type: \"broadcast\",\n event: BROADCAST_EVENT,\n payload: event,\n });\n // 'ok' | 'timed out' | 'error' | 'rate limited' | 'channel error' | …\n // Rejeitar em não-'ok' é intencional: o bridge (Task 6) captura e devolve\n // handled:false, acionando a reentrega idempotente do webhook.\n if (response !== \"ok\") {\n throw new Error(`Supabase broadcast \"${channel}\" não entregue: ${response}`);\n }\n },\n };\n}\n\n/**\n * Handle client-side (widget): `subscribe(token, onEvent, onStatus?)` assina\n * `chat:<token>` e devolve o unsubscribe. O client é criado uma única vez na fábrica.\n */\nexport function createSupabaseRealtimeHandle(url: string, anonKey: string): RealtimeHandle {\n const client = getRealtimeClient(url, anonKey);\n\n return {\n subscribe(realtimeToken, onEvent, onStatus) {\n const channel = client.channel(channelName(realtimeToken));\n\n channel.on(\"broadcast\", { event: BROADCAST_EVENT }, (message: { payload: unknown }) => {\n onEvent(message.payload as ChatEvent);\n });\n\n channel.subscribe((status: string) => {\n onStatus?.(STATUS_BY_REALTIME_STATE[status] ?? \"closed\");\n });\n\n return () => {\n void channel.unsubscribe();\n };\n },\n };\n}\n"],"mappings":";AAiBA,SAAS,oBAAyC;AAIlD,IAAM,iBAAiB;AAEvB,IAAM,kBAAkB;AAExB,IAAM,cAAc,CAAC,kBAAkC,GAAG,cAAc,GAAG,aAAa;AAMxF,IAAM,cAAc,oBAAI,IAA4B;AAEpD,SAAS,kBAAkB,KAAa,SAAiC;AACvE,QAAM,SAAS,YAAY,IAAI,GAAG;AAClC,MAAI,OAAQ,QAAO;AAEnB,QAAM,SAAS,aAAa,KAAK,SAAS;AAAA,IACxC,MAAM;AAAA;AAAA;AAAA;AAAA,MAIJ,gBAAgB;AAAA,MAChB,kBAAkB;AAAA,MAClB,oBAAoB;AAAA,MACpB,YAAY;AAAA,IACd;AAAA,EACF,CAAC;AAED,cAAY,IAAI,KAAK,MAAM;AAC3B,SAAO;AACT;AAGO,SAAS,6BAAmC;AACjD,cAAY,MAAM;AACpB;AAQA,IAAM,2BAA8D;AAAA,EAClE,YAAY;AAAA,EACZ,eAAe;AAAA,EACf,WAAW;AAAA,EACX,QAAQ;AACV;AAGO,SAAS,wBAAwB,OAA0C;AAChF,SAAO;AAAA,IACL,MAAM,QAAQ,eAAuB,OAAiC;AACpE,YAAM,UAAU,YAAY,aAAa;AACzC,YAAM,WAAW,MAAM,MAAM,QAAQ,OAAO,EAAE,KAAK;AAAA,QACjD,MAAM;AAAA,QACN,OAAO;AAAA,QACP,SAAS;AAAA,MACX,CAAC;AAID,UAAI,aAAa,MAAM;AACrB,cAAM,IAAI,MAAM,uBAAuB,OAAO,sBAAmB,QAAQ,EAAE;AAAA,MAC7E;AAAA,IACF;AAAA,EACF;AACF;AAMO,SAAS,6BAA6B,KAAa,SAAiC;AACzF,QAAM,SAAS,kBAAkB,KAAK,OAAO;AAE7C,SAAO;AAAA,IACL,UAAU,eAAe,SAAS,UAAU;AAC1C,YAAM,UAAU,OAAO,QAAQ,YAAY,aAAa,CAAC;AAEzD,cAAQ,GAAG,aAAa,EAAE,OAAO,gBAAgB,GAAG,CAAC,YAAkC;AACrF,gBAAQ,QAAQ,OAAoB;AAAA,MACtC,CAAC;AAED,cAAQ,UAAU,CAAC,WAAmB;AACpC,mBAAW,yBAAyB,MAAM,KAAK,QAAQ;AAAA,MACzD,CAAC;AAED,aAAO,MAAM;AACX,aAAK,QAAQ,YAAY;AAAA,MAC3B;AAAA,IACF;AAAA,EACF;AACF;","names":[]}
package/widget/index.cjs CHANGED
@@ -268,6 +268,17 @@ var WIDGET_CSS = `/*
268
268
  .ecw-item { display: flex; flex-direction: column; max-width: 82%; }
269
269
  .ecw-item--visitor { align-self: flex-end; align-items: flex-end; }
270
270
  .ecw-item--owner { align-self: flex-start; align-items: flex-start; }
271
+ .ecw-item--system { align-self: center; align-items: center; max-width: 92%; }
272
+ .ecw-system {
273
+ padding: 4px 10px;
274
+ border-radius: 999px;
275
+ background: var(--ecw-surface);
276
+ border: 1px solid var(--ecw-border);
277
+ color: var(--ecw-muted);
278
+ font-size: 11px;
279
+ text-align: center;
280
+ overflow-wrap: anywhere;
281
+ }
271
282
  .ecw-bubble {
272
283
  padding: 8px 12px;
273
284
  border-radius: 12px;
@@ -351,6 +362,8 @@ var WIDGET_CSS = `/*
351
362
  .ecw-closed { display: flex; flex-direction: column; gap: 8px; padding: 10px 12px; border-top: 1px solid var(--ecw-border); background: var(--ecw-surface); }
352
363
 
353
364
  .ecw-footer { padding: 6px 12px 10px; font-size: 11px; color: var(--ecw-muted); text-align: center; background: var(--ecw-surface); }
365
+ .ecw-powered-link { color: var(--ecw-accent); font-weight: 600; text-decoration: none; }
366
+ .ecw-powered-link:hover { text-decoration: underline; }
354
367
 
355
368
  /* \u2500\u2500 utilit\xE1rios \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
356
369
  .ecw-hp { display: none !important; }
@@ -896,16 +909,18 @@ function ChatPanel({ messages, canCompose, sending, locale, tr, listRef, firstFi
896
909
  void onSend(text);
897
910
  };
898
911
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
899
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("ul", { ref: listRef, className: "ecw-list", children: messages.map((m) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("li", { className: `ecw-item ecw-item--${m.direction}`, children: [
900
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: `ecw-bubble ecw-bubble--${m.direction}`, children: m.body }),
901
- /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "ecw-meta", children: [
902
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("time", { dateTime: m.createdAt, children: formatTime(m.createdAt, locale) }),
903
- m.direction === "visitor" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(StatusMark, { status: m.status }),
904
- m.status === "failed" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", className: "ecw-retry", onClick: () => {
905
- void onRetry(m.id);
906
- }, children: tr("retry") })
907
- ] })
908
- ] }, m.id)) }),
912
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("ul", { ref: listRef, className: "ecw-list", children: messages.map(
913
+ (m) => m.direction === "system" ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("li", { className: "ecw-item ecw-item--system", role: "status", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "ecw-system", children: m.body }) }, m.id) : /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("li", { className: `ecw-item ecw-item--${m.direction}`, children: [
914
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: `ecw-bubble ecw-bubble--${m.direction}`, children: m.body }),
915
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "ecw-meta", children: [
916
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("time", { dateTime: m.createdAt, children: formatTime(m.createdAt, locale) }),
917
+ m.direction === "visitor" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(StatusMark, { status: m.status }),
918
+ m.status === "failed" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", className: "ecw-retry", onClick: () => {
919
+ void onRetry(m.id);
920
+ }, children: tr("retry") })
921
+ ] })
922
+ ] }, m.id)
923
+ ) }),
909
924
  !canCompose && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "ecw-closed", children: [
910
925
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: "ecw-notice", role: "status", children: tr("sessionClosed") }),
911
926
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", className: "ecw-submit", onClick: onNewConversation, children: tr("newConversation") })
@@ -1019,7 +1034,20 @@ function ChatWidget(props) {
1019
1034
  onSubmit: submitForm
1020
1035
  }
1021
1036
  ),
1022
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("footer", { className: "ecw-footer", children: `${tr("poweredBy")} Evolution Chat` })
1037
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("footer", { className: "ecw-footer", children: [
1038
+ tr("poweredBy"),
1039
+ " ",
1040
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1041
+ "a",
1042
+ {
1043
+ className: "ecw-powered-link",
1044
+ href: "https://erlancarreira.com.br",
1045
+ target: "_blank",
1046
+ rel: "noopener noreferrer",
1047
+ children: "Erlan Carreira"
1048
+ }
1049
+ )
1050
+ ] })
1023
1051
  ] })
1024
1052
  ] });
1025
1053
  }