|
|
|
@@ -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){}
|
|
|
|
|
}
|