Add external messaging bridge

This commit is contained in:
Tiago
2026-09-29 14:25:37 -03:00
parent 0f011a091a
commit 911986ea8b
7 changed files with 274 additions and 0 deletions
+1
View File
@@ -11,6 +11,7 @@ ABACATEPAY_WEBHOOK_SECRET=troque-por-um-segredo-do-webhook
# Chave global da Evolution API. Use valor forte e igual ao configurado no app.
EVOLUTION_GLOBAL_API_KEY=troque-por-uma-chave-forte
EVOLUTION_WEBHOOK_SECRET=troque-por-outro-segredo-forte
EVOLUTION_API_URL=http://evolution-api:8080
EVOLUTION_PUBLIC_URL=https://condominio.seconectai.com.br/evolution
+1
View File
@@ -58,6 +58,7 @@ Ele gera automaticamente:
- `DB_PASSWORD`
- `JWT_SECRET`
- `EVOLUTION_GLOBAL_API_KEY`
- `EVOLUTION_WEBHOOK_SECRET` para proteger o webhook de respostas da Evolution
- `ABACATEPAY_WEBHOOK_SECRET`
- `EVOLUTION_DATABASE_CONNECTION_URI` com senha URL-encoded
- `MASTER_PASSWORD_HASH` a partir da senha Master inicial
+2
View File
@@ -82,6 +82,7 @@ services:
ABACATEPAY_API_KEY: ${ABACATEPAY_API_KEY:-}
EVOLUTION_GLOBAL_API_KEY: ${EVOLUTION_GLOBAL_API_KEY:-}
EVOLUTION_API_URL: ${EVOLUTION_API_URL:-http://evolution-api:8080}
EVOLUTION_WEBHOOK_SECRET: ${EVOLUTION_WEBHOOK_SECRET:-}
depends_on:
postgres:
condition: service_healthy
@@ -108,6 +109,7 @@ services:
ABACATEPAY_API_KEY: ${ABACATEPAY_API_KEY:-}
EVOLUTION_GLOBAL_API_KEY: ${EVOLUTION_GLOBAL_API_KEY:-}
EVOLUTION_API_URL: ${EVOLUTION_API_URL:-http://evolution-api:8080}
EVOLUTION_WEBHOOK_SECRET: ${EVOLUTION_WEBHOOK_SECRET:-}
depends_on:
postgres:
condition: service_healthy
+3
View File
@@ -42,6 +42,7 @@ hash_master() {
DB_PASSWORD="$(secret)"
JWT_SECRET="$(secret)"
EVOLUTION_GLOBAL_API_KEY="$(secret)"
EVOLUTION_WEBHOOK_SECRET="$(secret)"
ABACATEPAY_WEBHOOK_SECRET="$(secret)"
MASTER_PASSWORD_HASH="$(hash_master "$MASTER_PASSWORD")"
DB_PASSWORD_URL="$(urlencode "$DB_PASSWORD")"
@@ -57,6 +58,7 @@ ABACATEPAY_API_KEY=
ABACATEPAY_WEBHOOK_SECRET=$ABACATEPAY_WEBHOOK_SECRET
EVOLUTION_GLOBAL_API_KEY=$EVOLUTION_GLOBAL_API_KEY
EVOLUTION_WEBHOOK_SECRET=$EVOLUTION_WEBHOOK_SECRET
EVOLUTION_API_URL=http://evolution-api:8080
EVOLUTION_PUBLIC_URL=https://$DOMAIN/evolution
EVOLUTION_DATABASE_CONNECTION_URI=postgresql://app:$DB_PASSWORD_URL@postgres:5432/evolution
@@ -71,5 +73,6 @@ echo "Instalacao concluida."
echo "URL: https://$DOMAIN"
echo "Health: https://$DOMAIN/health"
echo "Webhook AbacatePay: https://$DOMAIN/api/master/webhooks/abacatepay?webhookSecret=$ABACATEPAY_WEBHOOK_SECRET"
echo "Webhook Evolution respostas: https://$DOMAIN/api/webhooks/evolution/respostas com header X-Webhook-Secret: $EVOLUTION_WEBHOOK_SECRET"
echo
echo "Guarde a senha Master informada. Os demais segredos foram gerados em $ENV_FILE."
@@ -16,6 +16,7 @@ import com.seconectai.condocontab.http.Router;
import com.seconectai.condocontab.http.StaticFileHandler;
import com.seconectai.condocontab.integracao.EmailClient;
import com.seconectai.condocontab.integracao.EvolutionApiClient;
import com.seconectai.condocontab.integracao.ExternalBridgeHandler;
import com.seconectai.condocontab.master.http.MasterHandlers;
import com.seconectai.condocontab.tenant.TenantDataSourceProvider;
import com.seconectai.condocontab.tenant.http.ClienteHandlers;
@@ -57,6 +58,7 @@ public final class Main {
ClienteHandlers clienteHandlers = new ClienteHandlers(tenantDataSourceProvider, dataSourceProvider, Path.of("/app/uploads"), evolutionApiClient, emailClient);
FinanceiroHandlers financeiroHandlers = new FinanceiroHandlers(dataSourceProvider, abacatePayClient);
AbacatePayWebhookHandler abacatePayWebhookHandler = new AbacatePayWebhookHandler(dataSourceProvider, abacatePayClient);
ExternalBridgeHandler externalBridgeHandler = new ExternalBridgeHandler(tenantDataSourceProvider, dataSourceProvider, evolutionApiClient);
if ("worker".equalsIgnoreCase(System.getenv().getOrDefault("APP_MODE", "web"))) {
Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(() -> {
@@ -77,6 +79,8 @@ public final class Main {
router.post("/api/master/login", authHandlers.masterLogin());
router.post("/api/auth/login", authHandlers.funcionarioLogin());
router.post("/api/master/webhooks/abacatepay", abacatePayWebhookHandler);
router.post("/api/webhooks/evolution/respostas", externalBridgeHandler);
router.post("/api/integracao/mensagens", authMiddleware.exigirApiKey(externalBridgeHandler));
router.prefix("/api/master/", authMiddleware.exigirMaster(masterHandlers));
router.prefix("/api/financeiro/", authMiddleware.exigirFuncionario(financeiroHandlers));
router.prefix("/api/", authMiddleware.exigirFuncionario(clienteHandlers));
@@ -0,0 +1,231 @@
package com.seconectai.condocontab.integracao;
import com.seconectai.condocontab.auth.ApiKeyService;
import com.seconectai.condocontab.auth.http.AuthMiddleware;
import com.seconectai.condocontab.config.DataSourceProvider;
import com.seconectai.condocontab.http.HttpResponses;
import com.seconectai.condocontab.tenant.TenantDataSourceProvider;
import com.seconectai.condocontab.util.Json;
import com.seconectai.condocontab.util.SimpleJson;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import javax.crypto.Mac;
import java.security.MessageDigest;
import javax.crypto.spec.SecretKeySpec;
import java.io.IOException;
import java.net.InetAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.sql.*;
import java.time.Duration;
import java.util.*;
import java.util.HexFormat;
public final class ExternalBridgeHandler implements HttpHandler {
private final TenantDataSourceProvider tenantDs;
private final DataSourceProvider masterDs;
private final EvolutionApiClient evolutionApiClient;
private final String webhookSecret;
private final HttpClient httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();
public ExternalBridgeHandler(TenantDataSourceProvider tenantDs, DataSourceProvider masterDs, EvolutionApiClient evolutionApiClient) {
this.tenantDs = tenantDs;
this.masterDs = masterDs;
this.evolutionApiClient = evolutionApiClient;
this.webhookSecret = firstNonBlank(System.getenv("EVOLUTION_WEBHOOK_SECRET"), System.getenv("EVOLUTION_GLOBAL_API_KEY"));
}
@Override
public void handle(HttpExchange ex) throws IOException {
try {
route(ex);
} catch (IllegalArgumentException e) {
HttpResponses.json(ex, 400, Map.of("erro", "requisicao_invalida", "mensagem", e.getMessage()));
} catch (Exception e) {
e.printStackTrace(System.err);
HttpResponses.json(ex, 500, Map.of("erro", "erro_integracao", "mensagem", String.valueOf(e.getMessage())));
}
}
private void route(HttpExchange ex) throws Exception {
String path = ex.getRequestURI().getPath();
String method = ex.getRequestMethod();
if ("/api/integracao/mensagens".equals(path) && "POST".equals(method)) {
enviarMensagem(ex, (long) ex.getAttribute(AuthMiddleware.ATTR_EMPRESA_ID));
return;
}
if ("/api/webhooks/evolution/respostas".equals(path) && "POST".equals(method)) {
receberRespostaEvolution(ex);
return;
}
HttpResponses.json(ex, 404, Map.of("erro", "endpoint_nao_encontrado"));
}
private void enviarMensagem(HttpExchange ex, long empresaId) throws Exception {
Map<String,String> b = SimpleJson.parseObject(rawBody(ex));
String mensagem = required(b, "mensagem");
String destinatariosRaw = required(b, "destinatarios");
ApiKeyConfig cfg = apiKeyConfig(ex.getRequestHeaders().getFirst("X-API-Key"), empresaId);
Map<String,Object> inst = evolutionAprovada(empresaId);
if (inst == null) {
HttpResponses.json(ex, 400, Map.of("erro", "whatsapp_indisponivel", "mensagem", "WhatsApp nao aprovado para esta empresa."));
return;
}
List<Destinatario> destinatarios = resolverDestinatarios(empresaId, destinatariosRaw);
if (destinatarios.isEmpty()) throw new IllegalArgumentException("Nenhum destinatario encontrado.");
String[] parts = String.valueOf(inst.get("instance_token_enc")).split(":", 2);
List<Map<String,Object>> resultados = new ArrayList<>();
try (Connection m = masterDs.getMasterConnection()) {
for (Destinatario d : destinatarios) {
String telefone = telefoneWhatsapp(d.telefone());
Map<String,Object> row = new LinkedHashMap<>();
row.put("tipo", d.tipo()); row.put("id", d.id()); row.put("nome", d.nome()); row.put("telefone", telefone);
try {
if (telefone.length() < 12) throw new IllegalArgumentException("telefone_invalido");
String resp = evolutionApiClient.enviarTexto(str(inst.get("instance_url")), parts[0], parts.length > 1 ? parts[1] : "", telefone, mensagem);
long msgId = registrarMensagem(m, empresaId, cfg, b.get("referencia_externa"), d, telefone, mensagem, "enviado", resp);
row.put("status", "enviado"); row.put("mensagem_id", msgId);
} catch (Exception err) {
long msgId = registrarMensagem(m, empresaId, cfg, b.get("referencia_externa"), d, telefone, mensagem, "falhou", err.getMessage());
row.put("status", "falhou"); row.put("erro", err.getMessage()); row.put("mensagem_id", msgId);
}
resultados.add(row);
}
}
HttpResponses.json(ex, 200, Map.of("ok", true, "resultados", resultados));
}
private void receberRespostaEvolution(HttpExchange ex) throws Exception {
validarWebhookEvolution(ex);
String raw = rawBody(ex);
String numero = telefoneWhatsapp(firstNonBlank(extract(raw, "remoteJid"), extract(raw, "from"), extract(raw, "number"), extract(raw, "sender")));
String texto = firstNonBlank(extract(raw, "conversation"), extract(raw, "text"), extract(raw, "body"), extract(raw, "message"));
if (numero.isBlank()) {
HttpResponses.json(ex, 400, Map.of("erro", "numero_nao_identificado"));
return;
}
try (Connection c = masterDs.getMasterConnection()) {
Map<String,Object> envio = one(c, "SELECT * FROM integracao_mensagens WHERE telefone=? AND callback_url IS NOT NULL AND status='enviado' ORDER BY criado_em DESC,id DESC LIMIT 1", numero);
if (envio == null) {
HttpResponses.json(ex, 200, Map.of("ok", true, "encaminhado", false, "motivo", "envio_nao_encontrado"));
return;
}
long respostaId = registrarResposta(c, ((Number)envio.get("id")).longValue(), raw, texto);
boolean encaminhado = encaminharCallback(envio, respostaId, numero, texto);
HttpResponses.json(ex, 200, Map.of("ok", true, "encaminhado", encaminhado, "mensagem_id", envio.get("id"), "resposta_id", respostaId));
}
}
private List<Destinatario> resolverDestinatarios(long empresaId, String raw) throws Exception {
List<Destinatario> out = new ArrayList<>();
try (Connection t = tenantDs.getConnection(empresaId); Connection m = masterDs.getMasterConnection()) {
for (String token : raw.split(",")) {
String item = token.trim();
if (item.isBlank()) continue;
String[] p = item.split(":", 2);
String tipo = p[0].trim().toLowerCase(Locale.ROOT);
Long id = p.length > 1 && !p[1].isBlank() ? Long.parseLong(p[1].trim()) : null;
if ("escritorio".equals(tipo) || "funcionarios".equals(tipo) || "funcionario".equals(tipo)) {
if (id == null) out.addAll(funcionarios(m, empresaId)); else addIfPresent(out, funcionario(m, empresaId, id));
} else if ("extra".equals(tipo) || "extras".equals(tipo)) {
if (id == null) out.addAll(extras(t)); else addIfPresent(out, extra(t, id));
} else if (Set.of("sindico", "subsindico", "conselheiro").contains(tipo)) {
out.addAll(contatosPorTipo(t, tipo));
} else {
throw new IllegalArgumentException("Destinatario invalido: " + item);
}
}
}
Map<String,Destinatario> unicos = new LinkedHashMap<>();
for (Destinatario d : out) unicos.put(d.tipo()+":"+d.id()+":"+telefoneWhatsapp(d.telefone()), d);
return new ArrayList<>(unicos.values());
}
private ApiKeyConfig apiKeyConfig(String chave, long empresaId) throws SQLException {
if (chave == null || chave.isBlank()) return new ApiKeyConfig("externo", null, null);
try (Connection c = masterDs.getMasterConnection(); PreparedStatement ps = c.prepareStatement("SELECT descricao, callback_url, callback_secret FROM api_keys WHERE key_hash=? AND empresa_id=? AND ativa=true")) {
ps.setString(1, ApiKeyService.hash(chave));
ps.setLong(2, empresaId);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) return new ApiKeyConfig("externo", null, null);
String sistema = rs.getString("descricao");
return new ApiKeyConfig(sistema == null || sistema.isBlank() ? "externo" : sistema, rs.getString("callback_url"), rs.getString("callback_secret"));
}
}
}
private boolean encaminharCallback(Map<String,Object> envio,long respostaId,String numero,String texto){
String url=str(envio.get("callback_url"));
if(url.isBlank())return false;
try{
validarCallbackUrl(url);
Map<String,Object> payload=new LinkedHashMap<>();
payload.put("mensagem_id",envio.get("id"));
payload.put("resposta_id",respostaId);
payload.put("empresa_id",envio.get("empresa_id"));
payload.put("sistema_externo",envio.get("sistema_externo"));
payload.put("referencia_externa",envio.get("referencia_externa"));
payload.put("telefone",numero);
payload.put("resposta",texto);
String body=Json.write(payload);
HttpRequest.Builder req=HttpRequest.newBuilder(URI.create(url)).timeout(Duration.ofSeconds(15)).header("Content-Type","application/json");
String secret=str(envio.get("callback_secret"));
if(!secret.isBlank())req.header("X-Seconectai-Signature", hmac(secret, body));
HttpResponse<String> resp=httpClient.send(req.POST(HttpRequest.BodyPublishers.ofString(body)).build(),HttpResponse.BodyHandlers.ofString());
return resp.statusCode()>=200&&resp.statusCode()<300;
}catch(Exception e){System.err.println("Falha callback integracao: "+e.getMessage());return false;}
}
private void validarWebhookEvolution(HttpExchange ex) {
String recebido = ex.getRequestHeaders().getFirst("X-Webhook-Secret");
if (webhookSecret == null || webhookSecret.isBlank() || recebido == null || !MessageDigest.isEqual(webhookSecret.getBytes(StandardCharsets.UTF_8), recebido.getBytes(StandardCharsets.UTF_8))) {
throw new IllegalArgumentException("webhook_nao_autorizado");
}
}
private void validarCallbackUrl(String value) throws Exception {
URI uri = URI.create(value);
if (!"https".equalsIgnoreCase(uri.getScheme())) throw new IllegalArgumentException("callback_url precisa usar https");
String host = uri.getHost();
if (host == null || host.isBlank()) throw new IllegalArgumentException("callback_url sem host");
String lower = host.toLowerCase(Locale.ROOT);
if (Set.of("localhost", "127.0.0.1", "0.0.0.0", "::1").contains(lower) || lower.endsWith(".local")) throw new IllegalArgumentException("callback_url local nao permitido");
for (InetAddress addr : InetAddress.getAllByName(host)) {
if (addr.isAnyLocalAddress() || addr.isLoopbackAddress() || addr.isLinkLocalAddress() || addr.isSiteLocalAddress() || addr.isMulticastAddress()) throw new IllegalArgumentException("callback_url em rede privada nao permitido");
}
}
private String hmac(String secret, String body) throws Exception {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
return "sha256=" + HexFormat.of().formatHex(mac.doFinal(body.getBytes(StandardCharsets.UTF_8)));
}
private List<Destinatario> funcionarios(Connection c, long emp) throws SQLException {return dests(c, "SELECT id,nome,telefone FROM funcionarios WHERE empresa_id=? AND status='ativo' ORDER BY nome", "funcionario", emp);}
private Destinatario funcionario(Connection c, long emp, long id) throws SQLException {return dest(c, "SELECT id,nome,telefone FROM funcionarios WHERE empresa_id=? AND id=? AND status='ativo'", "funcionario", emp, id);}
private List<Destinatario> extras(Connection c) throws SQLException {return dests(c, "SELECT id,nome,telefone FROM destinatarios_extra WHERE status='ativo' ORDER BY nome", "extra");}
private Destinatario extra(Connection c, long id) throws SQLException {return dest(c, "SELECT id,nome,telefone FROM destinatarios_extra WHERE id=? AND status='ativo'", "extra", id);}
private List<Destinatario> contatosPorTipo(Connection c, String tipo) throws SQLException {return dests(c, "SELECT c.id,c.nome,c.telefone FROM contatos c JOIN contato_condominio cc ON cc.contato_id=c.id WHERE cc.tipo=? AND cc.status='ativo' ORDER BY c.nome", tipo, tipo);}
private List<Destinatario> dests(Connection c, String sql, String tipo, Object... params) throws SQLException {List<Destinatario> out=new ArrayList<>();try(PreparedStatement ps=c.prepareStatement(sql)){bind(ps,params);try(ResultSet rs=ps.executeQuery()){while(rs.next())out.add(new Destinatario(tipo,rs.getLong("id"),rs.getString("nome"),rs.getString("telefone")));}}return out;}
private Destinatario dest(Connection c, String sql, String tipo, Object... params) throws SQLException {try(PreparedStatement ps=c.prepareStatement(sql)){bind(ps,params);try(ResultSet rs=ps.executeQuery()){return rs.next()?new Destinatario(tipo,rs.getLong("id"),rs.getString("nome"),rs.getString("telefone")):null;}}}
private void addIfPresent(List<Destinatario> out, Destinatario d){if(d!=null)out.add(d);}
private long registrarMensagem(Connection c,long emp,ApiKeyConfig cfg,String ref,Destinatario d,String tel,String msg,String status,String detalhe)throws SQLException{try(PreparedStatement ps=c.prepareStatement("INSERT INTO integracao_mensagens (empresa_id,sistema_externo,referencia_externa,callback_url,callback_secret,tipo_destinatario,destinatario_id,destinatario_nome,telefone,mensagem,status,evolution_response) VALUES (?,?,?,?,?,?,?,?,?,?,?,?) RETURNING id")){ps.setLong(1,emp);ps.setString(2,cfg.sistemaExterno());ps.setString(3,ref);ps.setString(4,emptyToNull(cfg.callbackUrl()));ps.setString(5,emptyToNull(cfg.callbackSecret()));ps.setString(6,d.tipo());ps.setLong(7,d.id());ps.setString(8,d.nome());ps.setString(9,tel);ps.setString(10,msg);ps.setString(11,status);ps.setString(12,detalhe);try(ResultSet rs=ps.executeQuery()){rs.next();return rs.getLong(1);}}}
private long registrarResposta(Connection c,long msgId,String raw,String texto)throws SQLException{try(PreparedStatement ps=c.prepareStatement("INSERT INTO integracao_respostas (integracao_mensagem_id,resposta_texto,payload) VALUES (?,?,?) RETURNING id")){ps.setLong(1,msgId);ps.setString(2,texto);ps.setString(3,raw);try(ResultSet rs=ps.executeQuery()){rs.next();return rs.getLong(1);}}}
private Map<String,Object> evolutionAprovada(long emp)throws SQLException{try(Connection m=masterDs.getMasterConnection();PreparedStatement ps=m.prepareStatement("SELECT * FROM evolution_instancias WHERE empresa_id=? AND status='aprovada' ORDER BY aprovado_em DESC,id DESC LIMIT 1")){ps.setLong(1,emp);try(ResultSet rs=ps.executeQuery()){return rs.next()?map(rs):null;}}}
private Map<String,Object> one(Connection c,String sql,Object...params)throws SQLException{try(PreparedStatement ps=c.prepareStatement(sql)){bind(ps,params);try(ResultSet rs=ps.executeQuery()){return rs.next()?map(rs):null;}}}
private Map<String,Object> map(ResultSet rs)throws SQLException{Map<String,Object>m=new LinkedHashMap<>();ResultSetMetaData md=rs.getMetaData();for(int i=1;i<=md.getColumnCount();i++)m.put(md.getColumnLabel(i),rs.getObject(i));return m;}
private void bind(PreparedStatement ps,Object...params)throws SQLException{for(int i=0;i<params.length;i++)ps.setObject(i+1,params[i]);}
private String rawBody(HttpExchange ex)throws IOException{return new String(ex.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);}
private String required(Map<String,String> m,String k){String v=m.get(k);if(v==null||v.isBlank())throw new IllegalArgumentException("Campo obrigatorio: "+k);return v;}
private String telefoneWhatsapp(String raw){String d=EvolutionApiClient.onlyDigits(raw);if((d.length()==10||d.length()==11)&&!d.startsWith("55"))return "55"+d;return d;}
private String str(Object v){return v==null?"":String.valueOf(v);}
private String emptyToNull(String v){return v==null||v.isBlank()?null:v;}
private String firstNonBlank(String... values){for(String v:values)if(v!=null&&!v.isBlank())return v;return "";}
private String extract(String raw,String key){String marker="\""+key+"\"";int p=raw.indexOf(marker);if(p<0)return null;int colon=raw.indexOf(':',p);if(colon<0)return null;int q=raw.indexOf('"',colon+1);int e=q<0?-1:raw.indexOf('"',q+1);if(q<0||e<0)return null;return raw.substring(q+1,e).split("@")[0];}
private record Destinatario(String tipo,long id,String nome,String telefone){}
private record ApiKeyConfig(String sistemaExterno,String callbackUrl,String callbackSecret){}
}
@@ -0,0 +1,32 @@
ALTER TABLE api_keys ADD COLUMN IF NOT EXISTS callback_url TEXT;
ALTER TABLE api_keys ADD COLUMN IF NOT EXISTS callback_secret TEXT;
CREATE TABLE IF NOT EXISTS integracao_mensagens (
id BIGSERIAL PRIMARY KEY,
empresa_id BIGINT NOT NULL REFERENCES empresas(id),
sistema_externo VARCHAR(120) NOT NULL DEFAULT 'externo',
referencia_externa VARCHAR(160),
callback_url TEXT,
callback_secret TEXT,
tipo_destinatario VARCHAR(30) NOT NULL,
destinatario_id BIGINT NOT NULL,
destinatario_nome VARCHAR(160),
telefone VARCHAR(30) NOT NULL,
mensagem TEXT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pendente',
evolution_response TEXT,
criado_em TIMESTAMP NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_integracao_mensagens_empresa ON integracao_mensagens(empresa_id, criado_em DESC);
CREATE INDEX IF NOT EXISTS idx_integracao_mensagens_telefone ON integracao_mensagens(telefone, criado_em DESC);
CREATE TABLE IF NOT EXISTS integracao_respostas (
id BIGSERIAL PRIMARY KEY,
integracao_mensagem_id BIGINT NOT NULL REFERENCES integracao_mensagens(id),
resposta_texto TEXT,
payload TEXT NOT NULL,
recebido_em TIMESTAMP NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_integracao_respostas_mensagem ON integracao_respostas(integracao_mensagem_id, recebido_em DESC);