@erlancarreira/evolution-chat 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,245 @@
1
+ type ChatMessageDirection = "visitor" | "owner";
2
+ type ChatMessageStatus = "pending" | "sent" | "failed";
3
+ type ChatSessionStatus = "active" | "closed" | "failed";
4
+ /** Mensagem persistida (domínio). */
5
+ interface ChatMessage {
6
+ id: string;
7
+ sessionId: string;
8
+ direction: ChatMessageDirection;
9
+ body: string;
10
+ status: ChatMessageStatus;
11
+ waMessageId: string | null;
12
+ createdAt: string;
13
+ }
14
+ /** Sessão de chat (um grupo do WhatsApp por visitante). */
15
+ interface ChatSession {
16
+ id: string;
17
+ code: string;
18
+ realtimeToken: string;
19
+ visitorName: string;
20
+ visitorPhone: string;
21
+ visitorContact: string | null;
22
+ groupJid: string | null;
23
+ status: ChatSessionStatus;
24
+ createdAt: string;
25
+ lastMessageAt: string | null;
26
+ }
27
+ /**
28
+ * Configuração resolvida da plataforma. Montada pelo consumidor (LMS) a partir
29
+ * das settings e injetada no bridge via `setConfig`.
30
+ */
31
+ interface ChatConfig {
32
+ enabled: boolean;
33
+ projectName: string;
34
+ platformNumber: string;
35
+ evolutionUrl: string;
36
+ instance: string;
37
+ apiKey: string;
38
+ welcome: string;
39
+ closeHours: number;
40
+ leaveOnClose: boolean;
41
+ webhookToken: string;
42
+ }
43
+ /** Evento de tempo real publicado no canal broadcast `chat:<realtimeToken>`. */
44
+ type ChatEvent = {
45
+ type: "message";
46
+ message: ChatMessage;
47
+ } | {
48
+ type: "session";
49
+ status: ChatSessionStatus;
50
+ };
51
+ /**
52
+ * Mensagem de entrada normalizada do webhook da Evolution, independente do
53
+ * formato bruto do payload.
54
+ */
55
+ interface InboundMessage {
56
+ waMessageId: string;
57
+ jid: string;
58
+ fromMe: boolean;
59
+ senderJid: string | null;
60
+ text: string | null;
61
+ timestamp: number;
62
+ raw: unknown;
63
+ }
64
+
65
+ /** Persistência de sessões/mensagens vista pela bridge. */
66
+ interface SessionStore {
67
+ createSession(input: {
68
+ code: string;
69
+ realtimeToken: string;
70
+ visitorName: string;
71
+ visitorPhone: string;
72
+ visitorContact?: string | null;
73
+ groupJid: string | null;
74
+ /** Hash não-reversível do IP (LGPD): usado por countRecentSessionsByIpHash. */
75
+ ipHash?: string | null;
76
+ /** User-Agent bruto da requisição (auditoria/forense; nunca exibido). */
77
+ userAgent?: string | null;
78
+ }): Promise<ChatSession>;
79
+ getSessionByToken(token: string): Promise<ChatSession | null>;
80
+ getSessionByGroupJid(jid: string): Promise<ChatSession | null>;
81
+ appendMessage(input: {
82
+ sessionId: string;
83
+ direction: ChatMessageDirection;
84
+ body: string;
85
+ waMessageId?: string | null;
86
+ status?: ChatMessageStatus;
87
+ }): Promise<ChatMessage>;
88
+ /** Atualiza o status de uma mensagem já persistida (pending → sent/failed). */
89
+ updateMessageStatus(id: string, status: ChatMessageStatus): Promise<void>;
90
+ listMessages(sessionId: string, afterIso?: string | null): Promise<ChatMessage[]>;
91
+ /**
92
+ * Busca uma mensagem já persistida pelo `waMessageId` dentro da sessão (`null` se não
93
+ * existir). Suporta a idempotência de reentrega do webhook: a Evolution reenvia a
94
+ * mesma mensagem quando devolvemos `handled:false`, e o bridge precisa reconhecer o
95
+ * duplicado em vez de anexar/publicar de novo.
96
+ */
97
+ findMessageByWaId(sessionId: string, waMessageId: string): Promise<ChatMessage | null>;
98
+ /** Registra o waMessageId de uma mensagem enviada por ESTA instância (dedupe de eco). */
99
+ registerSentMessageId(sessionId: string, waMessageId: string): Promise<void>;
100
+ isEcho(sessionId: string, waMessageId: string): Promise<boolean>;
101
+ touchSession(sessionId: string, atIso: string): Promise<void>;
102
+ setGroupJid(sessionId: string, groupJid: string): Promise<void>;
103
+ markStatus(sessionId: string, status: ChatSessionStatus, reason?: string): Promise<void>;
104
+ countRecentSessionsByIpHash(ipHash: string, windowMs: number): Promise<number>;
105
+ }
106
+ /** Publicação server-side de eventos para o canal de uma sessão (fire-and-forget). */
107
+ interface RealtimeTransport {
108
+ publish(realtimeToken: string, event: ChatEvent): Promise<void>;
109
+ }
110
+ /**
111
+ * Assinatura client-side do mesmo canal (usada pelo widget — Task 9).
112
+ * `subscribe` devolve o unsubscribe; `onStatus` reporta conexão do transporte.
113
+ */
114
+ interface RealtimeHandle {
115
+ subscribe(realtimeToken: string, onEvent: (e: ChatEvent) => void, onStatus?: (s: "open" | "closed") => void): () => void;
116
+ }
117
+ /** Injeção de relógio (testes determinísticos). */
118
+ type Clock = () => Date;
119
+ interface ChatLimiterResult {
120
+ success: boolean;
121
+ }
122
+ /** Rate limiter (chave → janela). Implementações: memória, Redis, … */
123
+ type ChatLimiter = (key: string, limit: number, windowMs: number) => ChatLimiterResult | Promise<ChatLimiterResult>;
124
+ /**
125
+ * Saída de ConversationRouter.decide — união discriminada por `action`: só o ramo
126
+ * "route" carrega sessão/direção/texto (obrigatórios), eliminando o risco de
127
+ * `decision.session` ser undefined em tempo de execução no consumidor (Task 6).
128
+ *
129
+ * Os `?: undefined` explícitos no 2º ramo preservam o typecheck de acessos sem
130
+ * narrowing (ex.: `d.session` → `ChatSession | undefined` em testes já escritos);
131
+ * após `if (d.action === "route")` os campos continuam obrigatórios.
132
+ */
133
+ type RouterDecision = {
134
+ action: "route";
135
+ session: ChatSession;
136
+ direction: ChatMessageDirection;
137
+ text: string;
138
+ } | {
139
+ action: "echo" | "unknown_session" | "not_text" | "ignore";
140
+ session?: undefined;
141
+ direction?: undefined;
142
+ text?: undefined;
143
+ };
144
+
145
+ declare class ConversationRouter {
146
+ private readonly store;
147
+ constructor(store: Pick<SessionStore, "getSessionByGroupJid" | "isEcho">);
148
+ /**
149
+ * `now` faz parte do contrato para que a bridge propague uma única leitura de relógio
150
+ * por mensagem (touch/close/limite de horário nas próximas tasks); o router puro ainda
151
+ * não a consome.
152
+ */
153
+ decide(msg: InboundMessage, now: Date): Promise<RouterDecision>;
154
+ }
155
+
156
+ /** Primeira mensagem da conversa no grupo: destaca código, nome e quebra de linha. */
157
+ declare function formatFirstMessage(code: string, name: string, text: string): string;
158
+ /** Demais mensagens vindas do site: prefixo curto na mesma linha. */
159
+ declare function formatFollowup(name: string, text: string): string;
160
+
161
+ interface SendTextResult {
162
+ waMessageId: string;
163
+ }
164
+ interface CreateGroupResult {
165
+ groupJid: string;
166
+ }
167
+ interface EvolutionClient {
168
+ sendText(instance: string, number: string, text: string): Promise<SendTextResult>;
169
+ createGroup(instance: string, subject: string, participants: string[], description?: string): Promise<CreateGroupResult>;
170
+ leaveGroup(instance: string, groupJid: string): Promise<void>;
171
+ getConnectionState(instance: string): Promise<"open" | "connecting" | "close">;
172
+ connectQR(instance: string): Promise<{
173
+ qrBase64: string | null;
174
+ pairingCode: string | null;
175
+ }>;
176
+ setWebhook(instance: string, url: string, events: string[]): Promise<void>;
177
+ }
178
+
179
+ interface StartChatInput {
180
+ name: string;
181
+ phone: string;
182
+ message: string;
183
+ contact?: string | null;
184
+ ipHash?: string | null;
185
+ userAgent?: string | null;
186
+ honeypot?: string | null;
187
+ }
188
+ interface ChatBridgeDeps {
189
+ client: EvolutionClient;
190
+ store: SessionStore;
191
+ transport: RealtimeTransport;
192
+ /** Relógio injetável (testes determinísticos). Default: `() => new Date()`. */
193
+ clock?: Clock;
194
+ }
195
+ declare class ChatBridge {
196
+ private readonly deps;
197
+ private readonly router;
198
+ private readonly clock;
199
+ private config;
200
+ constructor(deps: ChatBridgeDeps);
201
+ /** Config injetada pelas rotas a cada request (setConfig → uso → getConfig). */
202
+ getConfig(): ChatConfig;
203
+ setConfig(cfg: ChatConfig): void;
204
+ /**
205
+ * Abre uma conversa: cria o grupo na Evolution, envia a primeira mensagem e persiste a
206
+ * sessão. Não publica no canal realtime — quem abriu o chat já tem a própria mensagem.
207
+ */
208
+ startChat(input: StartChatInput): Promise<{
209
+ session: ChatSession;
210
+ messages: ChatMessage[];
211
+ }>;
212
+ /** Relay site → grupo: persiste pending, envia, promove para sent (ou failed). */
213
+ sendVisitorMessage(token: string, text: string): Promise<ChatMessage>;
214
+ /**
215
+ * Entrada do webhook da Evolution. NUNCA lança: um 500 aqui faz a Evolution reenviar
216
+ * a mesma mensagem para sempre. Qualquer problema é logado e devolvido como não tratado.
217
+ */
218
+ handleWebhook(payload: unknown): Promise<{
219
+ handled: boolean;
220
+ }>;
221
+ /** Replay para o widget: sessão por token + mensagens (opcionalmente após um ISO). */
222
+ history(token: string, afterIso?: string | null): Promise<{
223
+ session: ChatSession | null;
224
+ messages: ChatMessage[];
225
+ }>;
226
+ /**
227
+ * createGroup com uma única retratação: um participante inválido (ex.: o número do
228
+ * visitante, vindo de um formulário web) não pode abortar o atendimento — a segunda
229
+ * tentativa cria o grupo só com a plataforma, o número que controlamos e é válido.
230
+ *
231
+ * Atenção (F1): createGroup NÃO é idempotente. Se a 1ª chamada criar o grupo e o erro
232
+ * observado for um timeout, a retratação pode orfanar um segundo grupo; a compensação
233
+ * (limpeza/reconciliação de órfãos) é preocupação das Tasks 7/8, não deste fix.
234
+ */
235
+ private createGroupWithRetry;
236
+ }
237
+
238
+ type ChatErrorCode = "invalid_input" | "rate_limited" | "group_create_failed" | "send_failed" | "session_not_found" | "session_closed" | "disabled" | "unauthorized" | "store_error" | "webhook_invalid";
239
+ declare class ChatError extends Error {
240
+ readonly code: ChatErrorCode;
241
+ readonly cause?: unknown;
242
+ constructor(message: string, code: ChatErrorCode, cause?: unknown);
243
+ }
244
+
245
+ export { ChatBridge, type ChatBridgeDeps, ChatError, type ChatErrorCode, type ChatEvent, type ChatLimiter, type ChatLimiterResult, type ChatMessage, type ChatMessageDirection, type ChatMessageStatus, type ChatSession, type ChatSessionStatus, type Clock, ConversationRouter, type RealtimeHandle, type RealtimeTransport, type RouterDecision, type SessionStore, type StartChatInput, formatFirstMessage, formatFollowup };
@@ -0,0 +1,245 @@
1
+ type ChatMessageDirection = "visitor" | "owner";
2
+ type ChatMessageStatus = "pending" | "sent" | "failed";
3
+ type ChatSessionStatus = "active" | "closed" | "failed";
4
+ /** Mensagem persistida (domínio). */
5
+ interface ChatMessage {
6
+ id: string;
7
+ sessionId: string;
8
+ direction: ChatMessageDirection;
9
+ body: string;
10
+ status: ChatMessageStatus;
11
+ waMessageId: string | null;
12
+ createdAt: string;
13
+ }
14
+ /** Sessão de chat (um grupo do WhatsApp por visitante). */
15
+ interface ChatSession {
16
+ id: string;
17
+ code: string;
18
+ realtimeToken: string;
19
+ visitorName: string;
20
+ visitorPhone: string;
21
+ visitorContact: string | null;
22
+ groupJid: string | null;
23
+ status: ChatSessionStatus;
24
+ createdAt: string;
25
+ lastMessageAt: string | null;
26
+ }
27
+ /**
28
+ * Configuração resolvida da plataforma. Montada pelo consumidor (LMS) a partir
29
+ * das settings e injetada no bridge via `setConfig`.
30
+ */
31
+ interface ChatConfig {
32
+ enabled: boolean;
33
+ projectName: string;
34
+ platformNumber: string;
35
+ evolutionUrl: string;
36
+ instance: string;
37
+ apiKey: string;
38
+ welcome: string;
39
+ closeHours: number;
40
+ leaveOnClose: boolean;
41
+ webhookToken: string;
42
+ }
43
+ /** Evento de tempo real publicado no canal broadcast `chat:<realtimeToken>`. */
44
+ type ChatEvent = {
45
+ type: "message";
46
+ message: ChatMessage;
47
+ } | {
48
+ type: "session";
49
+ status: ChatSessionStatus;
50
+ };
51
+ /**
52
+ * Mensagem de entrada normalizada do webhook da Evolution, independente do
53
+ * formato bruto do payload.
54
+ */
55
+ interface InboundMessage {
56
+ waMessageId: string;
57
+ jid: string;
58
+ fromMe: boolean;
59
+ senderJid: string | null;
60
+ text: string | null;
61
+ timestamp: number;
62
+ raw: unknown;
63
+ }
64
+
65
+ /** Persistência de sessões/mensagens vista pela bridge. */
66
+ interface SessionStore {
67
+ createSession(input: {
68
+ code: string;
69
+ realtimeToken: string;
70
+ visitorName: string;
71
+ visitorPhone: string;
72
+ visitorContact?: string | null;
73
+ groupJid: string | null;
74
+ /** Hash não-reversível do IP (LGPD): usado por countRecentSessionsByIpHash. */
75
+ ipHash?: string | null;
76
+ /** User-Agent bruto da requisição (auditoria/forense; nunca exibido). */
77
+ userAgent?: string | null;
78
+ }): Promise<ChatSession>;
79
+ getSessionByToken(token: string): Promise<ChatSession | null>;
80
+ getSessionByGroupJid(jid: string): Promise<ChatSession | null>;
81
+ appendMessage(input: {
82
+ sessionId: string;
83
+ direction: ChatMessageDirection;
84
+ body: string;
85
+ waMessageId?: string | null;
86
+ status?: ChatMessageStatus;
87
+ }): Promise<ChatMessage>;
88
+ /** Atualiza o status de uma mensagem já persistida (pending → sent/failed). */
89
+ updateMessageStatus(id: string, status: ChatMessageStatus): Promise<void>;
90
+ listMessages(sessionId: string, afterIso?: string | null): Promise<ChatMessage[]>;
91
+ /**
92
+ * Busca uma mensagem já persistida pelo `waMessageId` dentro da sessão (`null` se não
93
+ * existir). Suporta a idempotência de reentrega do webhook: a Evolution reenvia a
94
+ * mesma mensagem quando devolvemos `handled:false`, e o bridge precisa reconhecer o
95
+ * duplicado em vez de anexar/publicar de novo.
96
+ */
97
+ findMessageByWaId(sessionId: string, waMessageId: string): Promise<ChatMessage | null>;
98
+ /** Registra o waMessageId de uma mensagem enviada por ESTA instância (dedupe de eco). */
99
+ registerSentMessageId(sessionId: string, waMessageId: string): Promise<void>;
100
+ isEcho(sessionId: string, waMessageId: string): Promise<boolean>;
101
+ touchSession(sessionId: string, atIso: string): Promise<void>;
102
+ setGroupJid(sessionId: string, groupJid: string): Promise<void>;
103
+ markStatus(sessionId: string, status: ChatSessionStatus, reason?: string): Promise<void>;
104
+ countRecentSessionsByIpHash(ipHash: string, windowMs: number): Promise<number>;
105
+ }
106
+ /** Publicação server-side de eventos para o canal de uma sessão (fire-and-forget). */
107
+ interface RealtimeTransport {
108
+ publish(realtimeToken: string, event: ChatEvent): Promise<void>;
109
+ }
110
+ /**
111
+ * Assinatura client-side do mesmo canal (usada pelo widget — Task 9).
112
+ * `subscribe` devolve o unsubscribe; `onStatus` reporta conexão do transporte.
113
+ */
114
+ interface RealtimeHandle {
115
+ subscribe(realtimeToken: string, onEvent: (e: ChatEvent) => void, onStatus?: (s: "open" | "closed") => void): () => void;
116
+ }
117
+ /** Injeção de relógio (testes determinísticos). */
118
+ type Clock = () => Date;
119
+ interface ChatLimiterResult {
120
+ success: boolean;
121
+ }
122
+ /** Rate limiter (chave → janela). Implementações: memória, Redis, … */
123
+ type ChatLimiter = (key: string, limit: number, windowMs: number) => ChatLimiterResult | Promise<ChatLimiterResult>;
124
+ /**
125
+ * Saída de ConversationRouter.decide — união discriminada por `action`: só o ramo
126
+ * "route" carrega sessão/direção/texto (obrigatórios), eliminando o risco de
127
+ * `decision.session` ser undefined em tempo de execução no consumidor (Task 6).
128
+ *
129
+ * Os `?: undefined` explícitos no 2º ramo preservam o typecheck de acessos sem
130
+ * narrowing (ex.: `d.session` → `ChatSession | undefined` em testes já escritos);
131
+ * após `if (d.action === "route")` os campos continuam obrigatórios.
132
+ */
133
+ type RouterDecision = {
134
+ action: "route";
135
+ session: ChatSession;
136
+ direction: ChatMessageDirection;
137
+ text: string;
138
+ } | {
139
+ action: "echo" | "unknown_session" | "not_text" | "ignore";
140
+ session?: undefined;
141
+ direction?: undefined;
142
+ text?: undefined;
143
+ };
144
+
145
+ declare class ConversationRouter {
146
+ private readonly store;
147
+ constructor(store: Pick<SessionStore, "getSessionByGroupJid" | "isEcho">);
148
+ /**
149
+ * `now` faz parte do contrato para que a bridge propague uma única leitura de relógio
150
+ * por mensagem (touch/close/limite de horário nas próximas tasks); o router puro ainda
151
+ * não a consome.
152
+ */
153
+ decide(msg: InboundMessage, now: Date): Promise<RouterDecision>;
154
+ }
155
+
156
+ /** Primeira mensagem da conversa no grupo: destaca código, nome e quebra de linha. */
157
+ declare function formatFirstMessage(code: string, name: string, text: string): string;
158
+ /** Demais mensagens vindas do site: prefixo curto na mesma linha. */
159
+ declare function formatFollowup(name: string, text: string): string;
160
+
161
+ interface SendTextResult {
162
+ waMessageId: string;
163
+ }
164
+ interface CreateGroupResult {
165
+ groupJid: string;
166
+ }
167
+ interface EvolutionClient {
168
+ sendText(instance: string, number: string, text: string): Promise<SendTextResult>;
169
+ createGroup(instance: string, subject: string, participants: string[], description?: string): Promise<CreateGroupResult>;
170
+ leaveGroup(instance: string, groupJid: string): Promise<void>;
171
+ getConnectionState(instance: string): Promise<"open" | "connecting" | "close">;
172
+ connectQR(instance: string): Promise<{
173
+ qrBase64: string | null;
174
+ pairingCode: string | null;
175
+ }>;
176
+ setWebhook(instance: string, url: string, events: string[]): Promise<void>;
177
+ }
178
+
179
+ interface StartChatInput {
180
+ name: string;
181
+ phone: string;
182
+ message: string;
183
+ contact?: string | null;
184
+ ipHash?: string | null;
185
+ userAgent?: string | null;
186
+ honeypot?: string | null;
187
+ }
188
+ interface ChatBridgeDeps {
189
+ client: EvolutionClient;
190
+ store: SessionStore;
191
+ transport: RealtimeTransport;
192
+ /** Relógio injetável (testes determinísticos). Default: `() => new Date()`. */
193
+ clock?: Clock;
194
+ }
195
+ declare class ChatBridge {
196
+ private readonly deps;
197
+ private readonly router;
198
+ private readonly clock;
199
+ private config;
200
+ constructor(deps: ChatBridgeDeps);
201
+ /** Config injetada pelas rotas a cada request (setConfig → uso → getConfig). */
202
+ getConfig(): ChatConfig;
203
+ setConfig(cfg: ChatConfig): void;
204
+ /**
205
+ * Abre uma conversa: cria o grupo na Evolution, envia a primeira mensagem e persiste a
206
+ * sessão. Não publica no canal realtime — quem abriu o chat já tem a própria mensagem.
207
+ */
208
+ startChat(input: StartChatInput): Promise<{
209
+ session: ChatSession;
210
+ messages: ChatMessage[];
211
+ }>;
212
+ /** Relay site → grupo: persiste pending, envia, promove para sent (ou failed). */
213
+ sendVisitorMessage(token: string, text: string): Promise<ChatMessage>;
214
+ /**
215
+ * Entrada do webhook da Evolution. NUNCA lança: um 500 aqui faz a Evolution reenviar
216
+ * a mesma mensagem para sempre. Qualquer problema é logado e devolvido como não tratado.
217
+ */
218
+ handleWebhook(payload: unknown): Promise<{
219
+ handled: boolean;
220
+ }>;
221
+ /** Replay para o widget: sessão por token + mensagens (opcionalmente após um ISO). */
222
+ history(token: string, afterIso?: string | null): Promise<{
223
+ session: ChatSession | null;
224
+ messages: ChatMessage[];
225
+ }>;
226
+ /**
227
+ * createGroup com uma única retratação: um participante inválido (ex.: o número do
228
+ * visitante, vindo de um formulário web) não pode abortar o atendimento — a segunda
229
+ * tentativa cria o grupo só com a plataforma, o número que controlamos e é válido.
230
+ *
231
+ * Atenção (F1): createGroup NÃO é idempotente. Se a 1ª chamada criar o grupo e o erro
232
+ * observado for um timeout, a retratação pode orfanar um segundo grupo; a compensação
233
+ * (limpeza/reconciliação de órfãos) é preocupação das Tasks 7/8, não deste fix.
234
+ */
235
+ private createGroupWithRetry;
236
+ }
237
+
238
+ type ChatErrorCode = "invalid_input" | "rate_limited" | "group_create_failed" | "send_failed" | "session_not_found" | "session_closed" | "disabled" | "unauthorized" | "store_error" | "webhook_invalid";
239
+ declare class ChatError extends Error {
240
+ readonly code: ChatErrorCode;
241
+ readonly cause?: unknown;
242
+ constructor(message: string, code: ChatErrorCode, cause?: unknown);
243
+ }
244
+
245
+ export { ChatBridge, type ChatBridgeDeps, ChatError, type ChatErrorCode, type ChatEvent, type ChatLimiter, type ChatLimiterResult, type ChatMessage, type ChatMessageDirection, type ChatMessageStatus, type ChatSession, type ChatSessionStatus, type Clock, ConversationRouter, type RealtimeHandle, type RealtimeTransport, type RouterDecision, type SessionStore, type StartChatInput, formatFirstMessage, formatFollowup };