@erlancarreira/evolution-chat 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +202 -0
- package/api/index.cjs +278 -0
- package/api/index.cjs.map +1 -0
- package/api/index.d.cts +65 -0
- package/api/index.d.ts +65 -0
- package/api/index.js +244 -0
- package/api/index.js.map +1 -0
- package/bridge/index.cjs +412 -0
- package/bridge/index.cjs.map +1 -0
- package/bridge/index.d.cts +245 -0
- package/bridge/index.d.ts +245 -0
- package/bridge/index.js +381 -0
- package/bridge/index.js.map +1 -0
- package/next/index.cjs +218 -0
- package/next/index.cjs.map +1 -0
- package/next/index.d.cts +205 -0
- package/next/index.d.ts +205 -0
- package/next/index.js +190 -0
- package/next/index.js.map +1 -0
- package/package.json +44 -0
- package/transports/supabase/index.cjs +74 -0
- package/transports/supabase/index.cjs.map +1 -0
- package/transports/supabase/index.d.cts +45 -0
- package/transports/supabase/index.d.ts +45 -0
- package/transports/supabase/index.js +48 -0
- package/transports/supabase/index.js.map +1 -0
- package/widget/index.cjs +1045 -0
- package/widget/index.cjs.map +1 -0
- package/widget/index.d.cts +122 -0
- package/widget/index.d.ts +122 -0
- package/widget/index.js +1009 -0
- package/widget/index.js.map +1 -0
- package/widget/styles.css +355 -0
- package/widget/styles.css.map +1 -0
- package/widget/styles.d.cts +2 -0
- package/widget/styles.d.ts +2 -0
- package/widget-embed/evolution-chat.iife.js +28706 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/next/index.ts","../src/errors.ts","../src/next/chat-routes.ts"],"sourcesContent":["// src/next/index.ts — superfície pública do subpath \"@erlancarreira/evolution-chat/next\".\n// Factories de rotas (Request/Response puros, compatíveis com o App Router do Next sem\n// importar \"next\"). Tipos auxiliares reexportados para o consumidor não descer a caminhos\n// internos (src/types, src/bridge/types).\n\nexport * from \"./chat-routes\";\nexport type { ChatConfig } from \"../types\";\nexport type { ChatLimiter, ChatLimiterResult } from \"../bridge/types\";\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/next/chat-routes.ts — factories de rotas para o App Router do Next, SEM importar \"next\".\n//\n// O plano proíbe dependência de \"next\"/\"next/server\": o App Router aceita os handlers\n// padrão Web `Request → Promise<Response>`, então as fábricas devolvem funções puras e o\n// arquivo de rota do consumidor apenas encaminha. Padrão de uso no LMS\n// (app/api/chat/route.ts):\n//\n// import { createChatRoutes } from \"@erlancarreira/evolution-chat/next\";\n// const handlers = createChatRoutes(deps);\n// export const GET = (req: Request) => handlers.GET(req);\n// export const POST = (req: Request) => handlers.POST(req);\n//\n// e em app/api/chat/webhook/route.ts:\n//\n// import { createWebhookRoute } from \"@erlancarreira/evolution-chat/next\";\n// const webhook = createWebhookRoute({ bridge, getConfig });\n// export const POST = (req: Request) => webhook.POST(req);\n//\n// Mapeamento ChatError.code → HTTP (contrato da Task 8):\n// invalid_input → 422 {error, field?} rate_limited → 429 {error}\n// session_not_found → 404 session_closed → 409\n// group_create_failed / send_failed → 502 {error}\n// store_error → 502 inesperado → 500 (log server-side)\n//\n// O mapeamento lê o `code` por DUCK-TYPING, nunca por `instanceof ChatError`. Motivo:\n// o tsup emite uma cópia própria de `ChatError` em CADA bundle de entry (sem shared\n// chunks), então a classe que vive no bundle /bridge NÃO é a mesma do bundle /next —\n// um ChatError lançado pela bridge falha o `instanceof` na rota e degradava para 500.\n// Qualquer objeto `{ code, message }` (esta classe, outra cópia dela, ou um erro\n// serializado) produz o status correto. Ver statusForError().\n//\n// Segurança: as respostas NUNCA devolvem a sessão completa — só os campos que o widget\n// precisa (code/status/visitorName/realtimeToken). Telefone do visitante e groupJid não\n// saem do servidor. Webhook sempre responde 200: um 5xx faz a Evolution reenviar para\n// sempre; token errado é ignorado com `{ignored:true}` (registra log, não vaza motivo).\n\nimport { ChatError } from \"../errors\";\nimport type { ChatBridge } from \"../bridge\";\nimport type { ChatLimiter } from \"../bridge/types\";\nimport type { ChatConfig } from \"../types\";\n\nexport interface ChatRoutesDeps {\n bridge: ChatBridge;\n /** DI; default: permite tudo (o limite de sessões/IP de domínio vive no ChatBridge). */\n limiter?: ChatLimiter;\n /** DI (LMS: sha256(ip+salt)). Default: `() => null` → limiter nunca é acionado. */\n getIpHash?: (req: Request) => string | null;\n /** Resolvida (await) no início de cada request; o handler faz bridge.setConfig. */\n getConfig: () => Promise<ChatConfig> | ChatConfig;\n}\n\nexport interface ChatRoutes {\n GET: (req: Request) => Promise<Response>;\n POST: (req: Request) => Promise<Response>;\n}\n\nexport interface WebhookRouteDeps {\n bridge: ChatBridge;\n getConfig: ChatRoutesDeps[\"getConfig\"];\n}\n\nexport interface WebhookRoute {\n POST: (req: Request) => Promise<Response>;\n}\n\n// Guarda de transporte por IP (a de domínio — 5 sessões/10min — é do ChatBridge).\nconst POST_LIMIT = 30;\nconst POST_WINDOW_MS = 60 * 1000;\n\nfunction json(status: number, body: Record<string, unknown>): Response {\n return new Response(JSON.stringify(body), {\n status,\n headers: { \"content-type\": \"application/json; charset=utf-8\" },\n });\n}\n\nfunction asText(value: unknown): string {\n return typeof value === \"string\" ? value : \"\";\n}\n\nfunction isFilled(value: unknown): boolean {\n return typeof value === \"string\" && value.trim().length > 0;\n}\n\nasync function readJsonBody(req: Request): Promise<Record<string, unknown>> {\n let parsed: unknown;\n try {\n parsed = await req.json();\n } catch {\n throw new ChatError(\"JSON inválido\", \"invalid_input\");\n }\n if (parsed === null || typeof parsed !== \"object\" || Array.isArray(parsed)) {\n throw new ChatError(\"O corpo da requisição deve ser um objeto JSON\", \"invalid_input\");\n }\n return parsed as Record<string, unknown>;\n}\n\n// O bridge nomeia o campo na mensagem (\"Nome deve ter…\", \"Telefone inválido\"); a rota\n// traduz para o nome do campo do corpo (name/phone/message) para o widget destacar.\nconst FIELD_BY_LABEL: Record<string, string> = { Nome: \"name\", Mensagem: \"message\" };\n\nfunction fieldFromMessage(message: string): string | null {\n if (message.startsWith(\"Telefone\")) return \"phone\";\n const label = /^(\\w+) deve ter\\b/.exec(message)?.[1];\n return label === undefined ? null : FIELD_BY_LABEL[label] ?? null;\n}\n\ninterface StatusForError {\n status: number;\n error: string;\n field?: string;\n}\n\n// DUCK-TYPE (ver cabeçalho do arquivo): o `code` é lido por propriedade, nunca por\n// `instanceof ChatError` — a classe é duplicada nos bundles do tsup e a comparação de\n// identidade falharia entre /bridge e /next. Erro sem `code` reconhecível → 500.\nfunction statusForError(err: unknown): StatusForError {\n const code =\n typeof err === \"object\" && err !== null && \"code\" in err\n ? (err as { code?: unknown }).code\n : undefined;\n const message =\n typeof err === \"object\" && err !== null && typeof (err as { message?: unknown }).message === \"string\"\n ? (err as { message: string }).message\n : \"\";\n // Objeto duck-typed sem `message` (erro serializado, plain object) ainda responde com o\n // próprio code no corpo — nunca uma string vazia que o widget não conseguiria exibir.\n const text = message !== \"\" || typeof code !== \"string\" ? message : code;\n\n switch (code) {\n case \"invalid_input\": {\n const field = fieldFromMessage(text);\n return field === null\n ? { status: 422, error: text }\n : { status: 422, error: text, field };\n }\n case \"rate_limited\":\n return { status: 429, error: text };\n case \"session_not_found\":\n return { status: 404, error: text };\n case \"session_closed\":\n return { status: 409, error: text };\n case \"group_create_failed\":\n case \"send_failed\":\n return { status: 502, error: text };\n case \"disabled\":\n return { status: 404, error: \"not_found\" };\n case \"unauthorized\":\n return { status: 401, error: text };\n case \"store_error\":\n return { status: 502, error: text };\n default:\n return { status: 500, error: \"erro interno\" };\n }\n}\n\nfunction errorResponse(error: unknown): Response {\n const { status, error: message, field } = statusForError(error);\n // 500 só sai do ramo default (nenhum code mapeado responde 500) → é sempre inesperado,\n // e o detalhe fica apenas no log do servidor: o corpo nunca vaza a mensagem original.\n if (status === 500) {\n console.error(\"[evolution-chat] erro inesperado na rota:\", error);\n return json(500, { error: message });\n }\n const body: Record<string, unknown> = { error: message };\n if (field !== undefined) body[\"field\"] = field;\n return json(status, body);\n}\n\nexport function createChatRoutes(deps: ChatRoutesDeps): ChatRoutes {\n const { bridge } = deps;\n const getIpHash = deps.getIpHash ?? (() => null);\n\n // GET /api/chat?token=…&after=… — replay de histórico para o widget.\n async function GET(req: Request): Promise<Response> {\n try {\n const url = new URL(req.url);\n const token = url.searchParams.get(\"token\");\n if (token === null || token === \"\") {\n return json(400, { error: \"token é obrigatório\" });\n }\n bridge.setConfig(await deps.getConfig());\n const after = url.searchParams.get(\"after\");\n const { session, messages } = await bridge.history(token, after);\n if (session === null) return json(404, { error: \"session_not_found\" });\n return json(200, {\n session: { code: session.code, status: session.status, visitorName: session.visitorName },\n messages,\n });\n } catch (error) {\n return errorResponse(error);\n }\n }\n\n // POST /api/chat — sem token abre chat; com token envia mensagem na sessão.\n async function POST(req: Request): Promise<Response> {\n let body: Record<string, unknown>;\n try {\n body = await readJsonBody(req);\n } catch (error) {\n return errorResponse(error);\n }\n try {\n const cfg = await deps.getConfig();\n bridge.setConfig(cfg);\n\n const token = asText(body[\"token\"]);\n const ipHash = getIpHash(req);\n\n if (token === \"\") {\n // Feature oculta: 404 genérico, nada confirma que o chat existe desligado.\n if (!cfg.enabled) return json(404, { error: \"not_found\" });\n // Anti-bot silencioso: finge sucesso sem tocar em store/Evolution/limiter.\n if (isFilled(body[\"honeypot\"])) {\n return json(200, { session: { code: \"XXXX\", status: \"closed\" }, messages: [] });\n }\n }\n\n if (deps.limiter !== undefined && ipHash !== null && ipHash !== \"\") {\n const result = await deps.limiter(ipHash, POST_LIMIT, POST_WINDOW_MS);\n if (!result.success) {\n return json(429, { error: \"Muitas requisições. Tente novamente em alguns minutos.\" });\n }\n }\n\n if (token !== \"\") {\n const message = await bridge.sendVisitorMessage(token, asText(body[\"message\"]));\n return json(200, { message });\n }\n\n const { session, messages } = await bridge.startChat({\n name: asText(body[\"name\"]),\n phone: asText(body[\"phone\"]),\n message: asText(body[\"message\"]),\n contact: typeof body[\"contact\"] === \"string\" ? body[\"contact\"] : null,\n ipHash,\n userAgent: req.headers.get(\"user-agent\") ?? null,\n honeypot: typeof body[\"honeypot\"] === \"string\" ? body[\"honeypot\"] : null,\n });\n return json(200, {\n session: {\n code: session.code,\n status: session.status,\n realtimeToken: session.realtimeToken,\n visitorName: session.visitorName,\n },\n messages,\n });\n } catch (error) {\n return errorResponse(error);\n }\n }\n\n return { GET, POST };\n}\n\nexport function createWebhookRoute(deps: WebhookRouteDeps): WebhookRoute {\n const { bridge } = deps;\n\n // POST /api/chat/webhook — entrada da Evolution. SEMPRE 200 (ver cabeçalho do arquivo).\n async function POST(req: Request): Promise<Response> {\n try {\n const cfg = await deps.getConfig();\n bridge.setConfig(cfg);\n\n const url = new URL(req.url);\n const token = url.searchParams.get(\"token\") ?? req.headers.get(\"x-webhook-token\") ?? \"\";\n if (token === \"\" || token !== cfg.webhookToken) {\n // Log server-side para auditoria; a resposta é neutra e não vaza o motivo.\n console.warn(\"[evolution-chat] webhook rejeitado: token inválido ou ausente\");\n return json(200, { ignored: true });\n }\n\n let payload: unknown;\n try {\n payload = await req.json();\n } catch {\n console.warn(\"[evolution-chat] webhook rejeitado: corpo não é JSON válido\");\n return json(200, { ignored: true });\n }\n\n const { handled } = await bridge.handleWebhook(payload);\n return json(200, { handled });\n } catch (error) {\n // getConfig pode falhar (store fora): ainda assim 200, senão a Evolution reenvia.\n console.error(\"[evolution-chat] webhook falhou (respondendo 200):\", error);\n return json(200, { ignored: true });\n }\n }\n\n return { POST };\n}\n"],"mappings":"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","names":[]}
|
package/next/index.d.cts
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
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
|
+
/** Persistência de sessões/mensagens vista pela bridge. */
|
|
53
|
+
interface SessionStore {
|
|
54
|
+
createSession(input: {
|
|
55
|
+
code: string;
|
|
56
|
+
realtimeToken: string;
|
|
57
|
+
visitorName: string;
|
|
58
|
+
visitorPhone: string;
|
|
59
|
+
visitorContact?: string | null;
|
|
60
|
+
groupJid: string | null;
|
|
61
|
+
/** Hash não-reversível do IP (LGPD): usado por countRecentSessionsByIpHash. */
|
|
62
|
+
ipHash?: string | null;
|
|
63
|
+
/** User-Agent bruto da requisição (auditoria/forense; nunca exibido). */
|
|
64
|
+
userAgent?: string | null;
|
|
65
|
+
}): Promise<ChatSession>;
|
|
66
|
+
getSessionByToken(token: string): Promise<ChatSession | null>;
|
|
67
|
+
getSessionByGroupJid(jid: string): Promise<ChatSession | null>;
|
|
68
|
+
appendMessage(input: {
|
|
69
|
+
sessionId: string;
|
|
70
|
+
direction: ChatMessageDirection;
|
|
71
|
+
body: string;
|
|
72
|
+
waMessageId?: string | null;
|
|
73
|
+
status?: ChatMessageStatus;
|
|
74
|
+
}): Promise<ChatMessage>;
|
|
75
|
+
/** Atualiza o status de uma mensagem já persistida (pending → sent/failed). */
|
|
76
|
+
updateMessageStatus(id: string, status: ChatMessageStatus): Promise<void>;
|
|
77
|
+
listMessages(sessionId: string, afterIso?: string | null): Promise<ChatMessage[]>;
|
|
78
|
+
/**
|
|
79
|
+
* Busca uma mensagem já persistida pelo `waMessageId` dentro da sessão (`null` se não
|
|
80
|
+
* existir). Suporta a idempotência de reentrega do webhook: a Evolution reenvia a
|
|
81
|
+
* mesma mensagem quando devolvemos `handled:false`, e o bridge precisa reconhecer o
|
|
82
|
+
* duplicado em vez de anexar/publicar de novo.
|
|
83
|
+
*/
|
|
84
|
+
findMessageByWaId(sessionId: string, waMessageId: string): Promise<ChatMessage | null>;
|
|
85
|
+
/** Registra o waMessageId de uma mensagem enviada por ESTA instância (dedupe de eco). */
|
|
86
|
+
registerSentMessageId(sessionId: string, waMessageId: string): Promise<void>;
|
|
87
|
+
isEcho(sessionId: string, waMessageId: string): Promise<boolean>;
|
|
88
|
+
touchSession(sessionId: string, atIso: string): Promise<void>;
|
|
89
|
+
setGroupJid(sessionId: string, groupJid: string): Promise<void>;
|
|
90
|
+
markStatus(sessionId: string, status: ChatSessionStatus, reason?: string): Promise<void>;
|
|
91
|
+
countRecentSessionsByIpHash(ipHash: string, windowMs: number): Promise<number>;
|
|
92
|
+
}
|
|
93
|
+
/** Publicação server-side de eventos para o canal de uma sessão (fire-and-forget). */
|
|
94
|
+
interface RealtimeTransport {
|
|
95
|
+
publish(realtimeToken: string, event: ChatEvent): Promise<void>;
|
|
96
|
+
}
|
|
97
|
+
/** Injeção de relógio (testes determinísticos). */
|
|
98
|
+
type Clock = () => Date;
|
|
99
|
+
interface ChatLimiterResult {
|
|
100
|
+
success: boolean;
|
|
101
|
+
}
|
|
102
|
+
/** Rate limiter (chave → janela). Implementações: memória, Redis, … */
|
|
103
|
+
type ChatLimiter = (key: string, limit: number, windowMs: number) => ChatLimiterResult | Promise<ChatLimiterResult>;
|
|
104
|
+
|
|
105
|
+
interface SendTextResult {
|
|
106
|
+
waMessageId: string;
|
|
107
|
+
}
|
|
108
|
+
interface CreateGroupResult {
|
|
109
|
+
groupJid: string;
|
|
110
|
+
}
|
|
111
|
+
interface EvolutionClient {
|
|
112
|
+
sendText(instance: string, number: string, text: string): Promise<SendTextResult>;
|
|
113
|
+
createGroup(instance: string, subject: string, participants: string[], description?: string): Promise<CreateGroupResult>;
|
|
114
|
+
leaveGroup(instance: string, groupJid: string): Promise<void>;
|
|
115
|
+
getConnectionState(instance: string): Promise<"open" | "connecting" | "close">;
|
|
116
|
+
connectQR(instance: string): Promise<{
|
|
117
|
+
qrBase64: string | null;
|
|
118
|
+
pairingCode: string | null;
|
|
119
|
+
}>;
|
|
120
|
+
setWebhook(instance: string, url: string, events: string[]): Promise<void>;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
interface StartChatInput {
|
|
124
|
+
name: string;
|
|
125
|
+
phone: string;
|
|
126
|
+
message: string;
|
|
127
|
+
contact?: string | null;
|
|
128
|
+
ipHash?: string | null;
|
|
129
|
+
userAgent?: string | null;
|
|
130
|
+
honeypot?: string | null;
|
|
131
|
+
}
|
|
132
|
+
interface ChatBridgeDeps {
|
|
133
|
+
client: EvolutionClient;
|
|
134
|
+
store: SessionStore;
|
|
135
|
+
transport: RealtimeTransport;
|
|
136
|
+
/** Relógio injetável (testes determinísticos). Default: `() => new Date()`. */
|
|
137
|
+
clock?: Clock;
|
|
138
|
+
}
|
|
139
|
+
declare class ChatBridge {
|
|
140
|
+
private readonly deps;
|
|
141
|
+
private readonly router;
|
|
142
|
+
private readonly clock;
|
|
143
|
+
private config;
|
|
144
|
+
constructor(deps: ChatBridgeDeps);
|
|
145
|
+
/** Config injetada pelas rotas a cada request (setConfig → uso → getConfig). */
|
|
146
|
+
getConfig(): ChatConfig;
|
|
147
|
+
setConfig(cfg: ChatConfig): void;
|
|
148
|
+
/**
|
|
149
|
+
* Abre uma conversa: cria o grupo na Evolution, envia a primeira mensagem e persiste a
|
|
150
|
+
* sessão. Não publica no canal realtime — quem abriu o chat já tem a própria mensagem.
|
|
151
|
+
*/
|
|
152
|
+
startChat(input: StartChatInput): Promise<{
|
|
153
|
+
session: ChatSession;
|
|
154
|
+
messages: ChatMessage[];
|
|
155
|
+
}>;
|
|
156
|
+
/** Relay site → grupo: persiste pending, envia, promove para sent (ou failed). */
|
|
157
|
+
sendVisitorMessage(token: string, text: string): Promise<ChatMessage>;
|
|
158
|
+
/**
|
|
159
|
+
* Entrada do webhook da Evolution. NUNCA lança: um 500 aqui faz a Evolution reenviar
|
|
160
|
+
* a mesma mensagem para sempre. Qualquer problema é logado e devolvido como não tratado.
|
|
161
|
+
*/
|
|
162
|
+
handleWebhook(payload: unknown): Promise<{
|
|
163
|
+
handled: boolean;
|
|
164
|
+
}>;
|
|
165
|
+
/** Replay para o widget: sessão por token + mensagens (opcionalmente após um ISO). */
|
|
166
|
+
history(token: string, afterIso?: string | null): Promise<{
|
|
167
|
+
session: ChatSession | null;
|
|
168
|
+
messages: ChatMessage[];
|
|
169
|
+
}>;
|
|
170
|
+
/**
|
|
171
|
+
* createGroup com uma única retratação: um participante inválido (ex.: o número do
|
|
172
|
+
* visitante, vindo de um formulário web) não pode abortar o atendimento — a segunda
|
|
173
|
+
* tentativa cria o grupo só com a plataforma, o número que controlamos e é válido.
|
|
174
|
+
*
|
|
175
|
+
* Atenção (F1): createGroup NÃO é idempotente. Se a 1ª chamada criar o grupo e o erro
|
|
176
|
+
* observado for um timeout, a retratação pode orfanar um segundo grupo; a compensação
|
|
177
|
+
* (limpeza/reconciliação de órfãos) é preocupação das Tasks 7/8, não deste fix.
|
|
178
|
+
*/
|
|
179
|
+
private createGroupWithRetry;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
interface ChatRoutesDeps {
|
|
183
|
+
bridge: ChatBridge;
|
|
184
|
+
/** DI; default: permite tudo (o limite de sessões/IP de domínio vive no ChatBridge). */
|
|
185
|
+
limiter?: ChatLimiter;
|
|
186
|
+
/** DI (LMS: sha256(ip+salt)). Default: `() => null` → limiter nunca é acionado. */
|
|
187
|
+
getIpHash?: (req: Request) => string | null;
|
|
188
|
+
/** Resolvida (await) no início de cada request; o handler faz bridge.setConfig. */
|
|
189
|
+
getConfig: () => Promise<ChatConfig> | ChatConfig;
|
|
190
|
+
}
|
|
191
|
+
interface ChatRoutes {
|
|
192
|
+
GET: (req: Request) => Promise<Response>;
|
|
193
|
+
POST: (req: Request) => Promise<Response>;
|
|
194
|
+
}
|
|
195
|
+
interface WebhookRouteDeps {
|
|
196
|
+
bridge: ChatBridge;
|
|
197
|
+
getConfig: ChatRoutesDeps["getConfig"];
|
|
198
|
+
}
|
|
199
|
+
interface WebhookRoute {
|
|
200
|
+
POST: (req: Request) => Promise<Response>;
|
|
201
|
+
}
|
|
202
|
+
declare function createChatRoutes(deps: ChatRoutesDeps): ChatRoutes;
|
|
203
|
+
declare function createWebhookRoute(deps: WebhookRouteDeps): WebhookRoute;
|
|
204
|
+
|
|
205
|
+
export { type ChatConfig, type ChatLimiter, type ChatLimiterResult, type ChatRoutes, type ChatRoutesDeps, type WebhookRoute, type WebhookRouteDeps, createChatRoutes, createWebhookRoute };
|
package/next/index.d.ts
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
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
|
+
/** Persistência de sessões/mensagens vista pela bridge. */
|
|
53
|
+
interface SessionStore {
|
|
54
|
+
createSession(input: {
|
|
55
|
+
code: string;
|
|
56
|
+
realtimeToken: string;
|
|
57
|
+
visitorName: string;
|
|
58
|
+
visitorPhone: string;
|
|
59
|
+
visitorContact?: string | null;
|
|
60
|
+
groupJid: string | null;
|
|
61
|
+
/** Hash não-reversível do IP (LGPD): usado por countRecentSessionsByIpHash. */
|
|
62
|
+
ipHash?: string | null;
|
|
63
|
+
/** User-Agent bruto da requisição (auditoria/forense; nunca exibido). */
|
|
64
|
+
userAgent?: string | null;
|
|
65
|
+
}): Promise<ChatSession>;
|
|
66
|
+
getSessionByToken(token: string): Promise<ChatSession | null>;
|
|
67
|
+
getSessionByGroupJid(jid: string): Promise<ChatSession | null>;
|
|
68
|
+
appendMessage(input: {
|
|
69
|
+
sessionId: string;
|
|
70
|
+
direction: ChatMessageDirection;
|
|
71
|
+
body: string;
|
|
72
|
+
waMessageId?: string | null;
|
|
73
|
+
status?: ChatMessageStatus;
|
|
74
|
+
}): Promise<ChatMessage>;
|
|
75
|
+
/** Atualiza o status de uma mensagem já persistida (pending → sent/failed). */
|
|
76
|
+
updateMessageStatus(id: string, status: ChatMessageStatus): Promise<void>;
|
|
77
|
+
listMessages(sessionId: string, afterIso?: string | null): Promise<ChatMessage[]>;
|
|
78
|
+
/**
|
|
79
|
+
* Busca uma mensagem já persistida pelo `waMessageId` dentro da sessão (`null` se não
|
|
80
|
+
* existir). Suporta a idempotência de reentrega do webhook: a Evolution reenvia a
|
|
81
|
+
* mesma mensagem quando devolvemos `handled:false`, e o bridge precisa reconhecer o
|
|
82
|
+
* duplicado em vez de anexar/publicar de novo.
|
|
83
|
+
*/
|
|
84
|
+
findMessageByWaId(sessionId: string, waMessageId: string): Promise<ChatMessage | null>;
|
|
85
|
+
/** Registra o waMessageId de uma mensagem enviada por ESTA instância (dedupe de eco). */
|
|
86
|
+
registerSentMessageId(sessionId: string, waMessageId: string): Promise<void>;
|
|
87
|
+
isEcho(sessionId: string, waMessageId: string): Promise<boolean>;
|
|
88
|
+
touchSession(sessionId: string, atIso: string): Promise<void>;
|
|
89
|
+
setGroupJid(sessionId: string, groupJid: string): Promise<void>;
|
|
90
|
+
markStatus(sessionId: string, status: ChatSessionStatus, reason?: string): Promise<void>;
|
|
91
|
+
countRecentSessionsByIpHash(ipHash: string, windowMs: number): Promise<number>;
|
|
92
|
+
}
|
|
93
|
+
/** Publicação server-side de eventos para o canal de uma sessão (fire-and-forget). */
|
|
94
|
+
interface RealtimeTransport {
|
|
95
|
+
publish(realtimeToken: string, event: ChatEvent): Promise<void>;
|
|
96
|
+
}
|
|
97
|
+
/** Injeção de relógio (testes determinísticos). */
|
|
98
|
+
type Clock = () => Date;
|
|
99
|
+
interface ChatLimiterResult {
|
|
100
|
+
success: boolean;
|
|
101
|
+
}
|
|
102
|
+
/** Rate limiter (chave → janela). Implementações: memória, Redis, … */
|
|
103
|
+
type ChatLimiter = (key: string, limit: number, windowMs: number) => ChatLimiterResult | Promise<ChatLimiterResult>;
|
|
104
|
+
|
|
105
|
+
interface SendTextResult {
|
|
106
|
+
waMessageId: string;
|
|
107
|
+
}
|
|
108
|
+
interface CreateGroupResult {
|
|
109
|
+
groupJid: string;
|
|
110
|
+
}
|
|
111
|
+
interface EvolutionClient {
|
|
112
|
+
sendText(instance: string, number: string, text: string): Promise<SendTextResult>;
|
|
113
|
+
createGroup(instance: string, subject: string, participants: string[], description?: string): Promise<CreateGroupResult>;
|
|
114
|
+
leaveGroup(instance: string, groupJid: string): Promise<void>;
|
|
115
|
+
getConnectionState(instance: string): Promise<"open" | "connecting" | "close">;
|
|
116
|
+
connectQR(instance: string): Promise<{
|
|
117
|
+
qrBase64: string | null;
|
|
118
|
+
pairingCode: string | null;
|
|
119
|
+
}>;
|
|
120
|
+
setWebhook(instance: string, url: string, events: string[]): Promise<void>;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
interface StartChatInput {
|
|
124
|
+
name: string;
|
|
125
|
+
phone: string;
|
|
126
|
+
message: string;
|
|
127
|
+
contact?: string | null;
|
|
128
|
+
ipHash?: string | null;
|
|
129
|
+
userAgent?: string | null;
|
|
130
|
+
honeypot?: string | null;
|
|
131
|
+
}
|
|
132
|
+
interface ChatBridgeDeps {
|
|
133
|
+
client: EvolutionClient;
|
|
134
|
+
store: SessionStore;
|
|
135
|
+
transport: RealtimeTransport;
|
|
136
|
+
/** Relógio injetável (testes determinísticos). Default: `() => new Date()`. */
|
|
137
|
+
clock?: Clock;
|
|
138
|
+
}
|
|
139
|
+
declare class ChatBridge {
|
|
140
|
+
private readonly deps;
|
|
141
|
+
private readonly router;
|
|
142
|
+
private readonly clock;
|
|
143
|
+
private config;
|
|
144
|
+
constructor(deps: ChatBridgeDeps);
|
|
145
|
+
/** Config injetada pelas rotas a cada request (setConfig → uso → getConfig). */
|
|
146
|
+
getConfig(): ChatConfig;
|
|
147
|
+
setConfig(cfg: ChatConfig): void;
|
|
148
|
+
/**
|
|
149
|
+
* Abre uma conversa: cria o grupo na Evolution, envia a primeira mensagem e persiste a
|
|
150
|
+
* sessão. Não publica no canal realtime — quem abriu o chat já tem a própria mensagem.
|
|
151
|
+
*/
|
|
152
|
+
startChat(input: StartChatInput): Promise<{
|
|
153
|
+
session: ChatSession;
|
|
154
|
+
messages: ChatMessage[];
|
|
155
|
+
}>;
|
|
156
|
+
/** Relay site → grupo: persiste pending, envia, promove para sent (ou failed). */
|
|
157
|
+
sendVisitorMessage(token: string, text: string): Promise<ChatMessage>;
|
|
158
|
+
/**
|
|
159
|
+
* Entrada do webhook da Evolution. NUNCA lança: um 500 aqui faz a Evolution reenviar
|
|
160
|
+
* a mesma mensagem para sempre. Qualquer problema é logado e devolvido como não tratado.
|
|
161
|
+
*/
|
|
162
|
+
handleWebhook(payload: unknown): Promise<{
|
|
163
|
+
handled: boolean;
|
|
164
|
+
}>;
|
|
165
|
+
/** Replay para o widget: sessão por token + mensagens (opcionalmente após um ISO). */
|
|
166
|
+
history(token: string, afterIso?: string | null): Promise<{
|
|
167
|
+
session: ChatSession | null;
|
|
168
|
+
messages: ChatMessage[];
|
|
169
|
+
}>;
|
|
170
|
+
/**
|
|
171
|
+
* createGroup com uma única retratação: um participante inválido (ex.: o número do
|
|
172
|
+
* visitante, vindo de um formulário web) não pode abortar o atendimento — a segunda
|
|
173
|
+
* tentativa cria o grupo só com a plataforma, o número que controlamos e é válido.
|
|
174
|
+
*
|
|
175
|
+
* Atenção (F1): createGroup NÃO é idempotente. Se a 1ª chamada criar o grupo e o erro
|
|
176
|
+
* observado for um timeout, a retratação pode orfanar um segundo grupo; a compensação
|
|
177
|
+
* (limpeza/reconciliação de órfãos) é preocupação das Tasks 7/8, não deste fix.
|
|
178
|
+
*/
|
|
179
|
+
private createGroupWithRetry;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
interface ChatRoutesDeps {
|
|
183
|
+
bridge: ChatBridge;
|
|
184
|
+
/** DI; default: permite tudo (o limite de sessões/IP de domínio vive no ChatBridge). */
|
|
185
|
+
limiter?: ChatLimiter;
|
|
186
|
+
/** DI (LMS: sha256(ip+salt)). Default: `() => null` → limiter nunca é acionado. */
|
|
187
|
+
getIpHash?: (req: Request) => string | null;
|
|
188
|
+
/** Resolvida (await) no início de cada request; o handler faz bridge.setConfig. */
|
|
189
|
+
getConfig: () => Promise<ChatConfig> | ChatConfig;
|
|
190
|
+
}
|
|
191
|
+
interface ChatRoutes {
|
|
192
|
+
GET: (req: Request) => Promise<Response>;
|
|
193
|
+
POST: (req: Request) => Promise<Response>;
|
|
194
|
+
}
|
|
195
|
+
interface WebhookRouteDeps {
|
|
196
|
+
bridge: ChatBridge;
|
|
197
|
+
getConfig: ChatRoutesDeps["getConfig"];
|
|
198
|
+
}
|
|
199
|
+
interface WebhookRoute {
|
|
200
|
+
POST: (req: Request) => Promise<Response>;
|
|
201
|
+
}
|
|
202
|
+
declare function createChatRoutes(deps: ChatRoutesDeps): ChatRoutes;
|
|
203
|
+
declare function createWebhookRoute(deps: WebhookRouteDeps): WebhookRoute;
|
|
204
|
+
|
|
205
|
+
export { type ChatConfig, type ChatLimiter, type ChatLimiterResult, type ChatRoutes, type ChatRoutesDeps, type WebhookRoute, type WebhookRouteDeps, createChatRoutes, createWebhookRoute };
|
package/next/index.js
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// src/errors.ts
|
|
2
|
+
var ChatError = class extends Error {
|
|
3
|
+
code;
|
|
4
|
+
cause;
|
|
5
|
+
constructor(message, code, cause) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = "ChatError";
|
|
8
|
+
this.code = code;
|
|
9
|
+
this.cause = cause;
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
// src/next/chat-routes.ts
|
|
14
|
+
var POST_LIMIT = 30;
|
|
15
|
+
var POST_WINDOW_MS = 60 * 1e3;
|
|
16
|
+
function json(status, body) {
|
|
17
|
+
return new Response(JSON.stringify(body), {
|
|
18
|
+
status,
|
|
19
|
+
headers: { "content-type": "application/json; charset=utf-8" }
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
function asText(value) {
|
|
23
|
+
return typeof value === "string" ? value : "";
|
|
24
|
+
}
|
|
25
|
+
function isFilled(value) {
|
|
26
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
27
|
+
}
|
|
28
|
+
async function readJsonBody(req) {
|
|
29
|
+
let parsed;
|
|
30
|
+
try {
|
|
31
|
+
parsed = await req.json();
|
|
32
|
+
} catch {
|
|
33
|
+
throw new ChatError("JSON inv\xE1lido", "invalid_input");
|
|
34
|
+
}
|
|
35
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
36
|
+
throw new ChatError("O corpo da requisi\xE7\xE3o deve ser um objeto JSON", "invalid_input");
|
|
37
|
+
}
|
|
38
|
+
return parsed;
|
|
39
|
+
}
|
|
40
|
+
var FIELD_BY_LABEL = { Nome: "name", Mensagem: "message" };
|
|
41
|
+
function fieldFromMessage(message) {
|
|
42
|
+
if (message.startsWith("Telefone")) return "phone";
|
|
43
|
+
const label = /^(\w+) deve ter\b/.exec(message)?.[1];
|
|
44
|
+
return label === void 0 ? null : FIELD_BY_LABEL[label] ?? null;
|
|
45
|
+
}
|
|
46
|
+
function statusForError(err) {
|
|
47
|
+
const code = typeof err === "object" && err !== null && "code" in err ? err.code : void 0;
|
|
48
|
+
const message = typeof err === "object" && err !== null && typeof err.message === "string" ? err.message : "";
|
|
49
|
+
const text = message !== "" || typeof code !== "string" ? message : code;
|
|
50
|
+
switch (code) {
|
|
51
|
+
case "invalid_input": {
|
|
52
|
+
const field = fieldFromMessage(text);
|
|
53
|
+
return field === null ? { status: 422, error: text } : { status: 422, error: text, field };
|
|
54
|
+
}
|
|
55
|
+
case "rate_limited":
|
|
56
|
+
return { status: 429, error: text };
|
|
57
|
+
case "session_not_found":
|
|
58
|
+
return { status: 404, error: text };
|
|
59
|
+
case "session_closed":
|
|
60
|
+
return { status: 409, error: text };
|
|
61
|
+
case "group_create_failed":
|
|
62
|
+
case "send_failed":
|
|
63
|
+
return { status: 502, error: text };
|
|
64
|
+
case "disabled":
|
|
65
|
+
return { status: 404, error: "not_found" };
|
|
66
|
+
case "unauthorized":
|
|
67
|
+
return { status: 401, error: text };
|
|
68
|
+
case "store_error":
|
|
69
|
+
return { status: 502, error: text };
|
|
70
|
+
default:
|
|
71
|
+
return { status: 500, error: "erro interno" };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function errorResponse(error) {
|
|
75
|
+
const { status, error: message, field } = statusForError(error);
|
|
76
|
+
if (status === 500) {
|
|
77
|
+
console.error("[evolution-chat] erro inesperado na rota:", error);
|
|
78
|
+
return json(500, { error: message });
|
|
79
|
+
}
|
|
80
|
+
const body = { error: message };
|
|
81
|
+
if (field !== void 0) body["field"] = field;
|
|
82
|
+
return json(status, body);
|
|
83
|
+
}
|
|
84
|
+
function createChatRoutes(deps) {
|
|
85
|
+
const { bridge } = deps;
|
|
86
|
+
const getIpHash = deps.getIpHash ?? (() => null);
|
|
87
|
+
async function GET(req) {
|
|
88
|
+
try {
|
|
89
|
+
const url = new URL(req.url);
|
|
90
|
+
const token = url.searchParams.get("token");
|
|
91
|
+
if (token === null || token === "") {
|
|
92
|
+
return json(400, { error: "token \xE9 obrigat\xF3rio" });
|
|
93
|
+
}
|
|
94
|
+
bridge.setConfig(await deps.getConfig());
|
|
95
|
+
const after = url.searchParams.get("after");
|
|
96
|
+
const { session, messages } = await bridge.history(token, after);
|
|
97
|
+
if (session === null) return json(404, { error: "session_not_found" });
|
|
98
|
+
return json(200, {
|
|
99
|
+
session: { code: session.code, status: session.status, visitorName: session.visitorName },
|
|
100
|
+
messages
|
|
101
|
+
});
|
|
102
|
+
} catch (error) {
|
|
103
|
+
return errorResponse(error);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
async function POST(req) {
|
|
107
|
+
let body;
|
|
108
|
+
try {
|
|
109
|
+
body = await readJsonBody(req);
|
|
110
|
+
} catch (error) {
|
|
111
|
+
return errorResponse(error);
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
const cfg = await deps.getConfig();
|
|
115
|
+
bridge.setConfig(cfg);
|
|
116
|
+
const token = asText(body["token"]);
|
|
117
|
+
const ipHash = getIpHash(req);
|
|
118
|
+
if (token === "") {
|
|
119
|
+
if (!cfg.enabled) return json(404, { error: "not_found" });
|
|
120
|
+
if (isFilled(body["honeypot"])) {
|
|
121
|
+
return json(200, { session: { code: "XXXX", status: "closed" }, messages: [] });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (deps.limiter !== void 0 && ipHash !== null && ipHash !== "") {
|
|
125
|
+
const result = await deps.limiter(ipHash, POST_LIMIT, POST_WINDOW_MS);
|
|
126
|
+
if (!result.success) {
|
|
127
|
+
return json(429, { error: "Muitas requisi\xE7\xF5es. Tente novamente em alguns minutos." });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (token !== "") {
|
|
131
|
+
const message = await bridge.sendVisitorMessage(token, asText(body["message"]));
|
|
132
|
+
return json(200, { message });
|
|
133
|
+
}
|
|
134
|
+
const { session, messages } = await bridge.startChat({
|
|
135
|
+
name: asText(body["name"]),
|
|
136
|
+
phone: asText(body["phone"]),
|
|
137
|
+
message: asText(body["message"]),
|
|
138
|
+
contact: typeof body["contact"] === "string" ? body["contact"] : null,
|
|
139
|
+
ipHash,
|
|
140
|
+
userAgent: req.headers.get("user-agent") ?? null,
|
|
141
|
+
honeypot: typeof body["honeypot"] === "string" ? body["honeypot"] : null
|
|
142
|
+
});
|
|
143
|
+
return json(200, {
|
|
144
|
+
session: {
|
|
145
|
+
code: session.code,
|
|
146
|
+
status: session.status,
|
|
147
|
+
realtimeToken: session.realtimeToken,
|
|
148
|
+
visitorName: session.visitorName
|
|
149
|
+
},
|
|
150
|
+
messages
|
|
151
|
+
});
|
|
152
|
+
} catch (error) {
|
|
153
|
+
return errorResponse(error);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return { GET, POST };
|
|
157
|
+
}
|
|
158
|
+
function createWebhookRoute(deps) {
|
|
159
|
+
const { bridge } = deps;
|
|
160
|
+
async function POST(req) {
|
|
161
|
+
try {
|
|
162
|
+
const cfg = await deps.getConfig();
|
|
163
|
+
bridge.setConfig(cfg);
|
|
164
|
+
const url = new URL(req.url);
|
|
165
|
+
const token = url.searchParams.get("token") ?? req.headers.get("x-webhook-token") ?? "";
|
|
166
|
+
if (token === "" || token !== cfg.webhookToken) {
|
|
167
|
+
console.warn("[evolution-chat] webhook rejeitado: token inv\xE1lido ou ausente");
|
|
168
|
+
return json(200, { ignored: true });
|
|
169
|
+
}
|
|
170
|
+
let payload;
|
|
171
|
+
try {
|
|
172
|
+
payload = await req.json();
|
|
173
|
+
} catch {
|
|
174
|
+
console.warn("[evolution-chat] webhook rejeitado: corpo n\xE3o \xE9 JSON v\xE1lido");
|
|
175
|
+
return json(200, { ignored: true });
|
|
176
|
+
}
|
|
177
|
+
const { handled } = await bridge.handleWebhook(payload);
|
|
178
|
+
return json(200, { handled });
|
|
179
|
+
} catch (error) {
|
|
180
|
+
console.error("[evolution-chat] webhook falhou (respondendo 200):", error);
|
|
181
|
+
return json(200, { ignored: true });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return { POST };
|
|
185
|
+
}
|
|
186
|
+
export {
|
|
187
|
+
createChatRoutes,
|
|
188
|
+
createWebhookRoute
|
|
189
|
+
};
|
|
190
|
+
//# sourceMappingURL=index.js.map
|