Carry over pre-existing WABA outreach automation WIP

This work already existed uncommitted in the main working tree (not
authored by this branch) and is required as a runtime dependency for the
campaign outreach port (zpro-client, templates, outreach routes/UI). Copied
in as-is, untouched, so this feature branch has a working baseline; the
original uncommitted state in the main checkout is left exactly as it was.
This commit is contained in:
Gustavo
2026-08-01 10:12:25 -03:00
parent 0894ab68b3
commit 4f736562f3
33 changed files with 3537 additions and 342 deletions
+205
View File
@@ -0,0 +1,205 @@
import { getSetting, listMessageTemplates, updateMessageTemplate } from '../../db.mjs';
import { createZproClient, zproConfigured, zproDiagnostics } from '../../outreach/zpro-client.mjs';
import { listWabaTemplateLibrary, syncWabaTemplateLibrary } from '../../outreach/waba-template-library.mjs';
import { COMMERCIAL_VARIABLES, renderTemplate } from '../../outreach/templates.mjs';
import { validatePhone, onlyDigits } from '../../scoring.mjs';
function dailyLimitStatus(db) {
const limite = Number(getSetting(db, 'outreach.mensagensPorDia', '10')) || 0;
const enviadosHoje = db.prepare(
"SELECT COUNT(*) AS t FROM outreach_sends WHERE status = 'enviado' AND date(sent_at) = date('now')"
).get().t;
return { limite, enviadosHoje, restante: Math.max(0, limite - enviadosHoje) };
}
export function registerOutreachRoutes(router, { db }) {
router.add('GET', '/api/outreach/status', async (req, res, { json }) => {
const ativo = getSetting(db, 'outreach.ativo', 'false') === 'true';
const { limite, enviadosHoje, restante } = dailyLimitStatus(db);
const porProduto = db.prepare(
`SELECT produto, COUNT(*) AS total
FROM outreach_sends
WHERE status = 'enviado' AND date(sent_at) = date('now')
GROUP BY produto ORDER BY total DESC`
).all();
// LEFT JOIN (nao JOIN): envio manual nao tem lead_id, senao sumiria da lista.
const ultimos = db.prepare(
`SELECT outreach_sends.id, outreach_sends.produto, outreach_sends.status,
outreach_sends.sent_at, outreach_sends.canal, outreach_sends.origem,
outreach_sends.numero, outreach_sends.erro, leads.empresa, leads.cidade
FROM outreach_sends LEFT JOIN leads ON leads.id = outreach_sends.lead_id
ORDER BY outreach_sends.id DESC LIMIT 20`
).all();
json(200, {
ativo,
configurado: zproConfigured(db),
mensagensPorDia: limite,
enviadosHoje,
restante,
porProduto,
ultimos
});
});
// Testa as credenciais Z-PRO atuais (settings ou .env) com uma chamada real
// e barata (listTags), sem mandar nenhuma mensagem — pra validar a tela de
// Credenciais Z-PRO antes de ativar a automacao de verdade.
router.add('POST', '/api/outreach/testar-conexao', async (req, res, { json }) => {
if (!zproConfigured(db)) {
json(400, { ok: false, erro: 'preencha Base URL, API ID e Token antes de testar', diagnostico: zproDiagnostics(db) });
return;
}
try {
await createZproClient(db).listTags({ isActive: true });
json(200, { ok: true, diagnostico: zproDiagnostics(db) });
} catch (error) {
json(200, { ok: false, erro: String(error.message || error), diagnostico: zproDiagnostics(db) });
}
});
// Envio avulso pra um numero especifico, fora do lote automatico e sem
// depender de um lead cadastrado — util pra mandar uma mensagem pontual
// ou testar um template antes de ligar a automacao pra valer. Fica
// registrado em outreach_sends com origem='manual' (lead_id/produto
// ficam vazios), aparece nos "ultimos" igual aos envios automaticos.
router.add('POST', '/api/outreach/enviar-manual', async (req, res, { json, body }) => {
const numeroDigitado = String(body?.numero || '').trim();
if (!numeroDigitado) {
json(400, { erro: 'informe o numero (com DDI+DDD, ex.: 5511999999999)' });
return;
}
// O Z-PRO aceita (200 OK) numero mal formatado e so falha depois, de forma
// assincrona, sem avisar o app — por isso valida aqui ANTES de mandar,
// em vez de confiar na resposta da API pra saber se o numero e valido.
const { valid, motivo } = validatePhone(numeroDigitado);
if (!valid) {
json(400, { erro: `numero invalido (${motivo}): use DDI+DDD+numero, so digitos, ex. 5511999999999` });
return;
}
const numero = onlyDigits(numeroDigitado);
const canal = body?.canal === 'oficial' ? 'oficial' : 'nao_oficial';
const mensagem = String(body?.mensagem || '').trim();
const templateWabaNome = String(body?.templateWabaNome || '').trim();
const templateWabaIdioma = String(body?.templateWabaIdioma || 'pt_BR').trim() || 'pt_BR';
const templateRoute = String(body?.templateRota || 'template').trim() || 'template';
const rawTemplateVariables = Array.isArray(body?.templateVariaveis)
? body.templateVariaveis.map((value) => String(value || '').trim()).filter(Boolean)
: [String(body?.templateVariavel1 || '').trim()].filter(Boolean);
const leadContext = body?.lead && typeof body.lead === 'object' ? body.lead : {};
const templateVariables = rawTemplateVariables.map((value) => renderTemplate(value, {
empresa: 'Gustavo',
produto: 'Atendimento comercial',
cidade: 'Salvador',
segmento_label: 'Suporte',
email: 'cliente@exemplo.com.br',
...leadContext,
whatsapp: leadContext.whatsapp || onlyDigits(numeroDigitado)
}));
if (canal === 'nao_oficial' && !mensagem) {
json(400, { erro: 'escreva a mensagem pro canal nao-oficial' });
return;
}
if (canal === 'oficial' && !templateWabaNome) {
json(400, { erro: 'informe o nome do template aprovado pro canal oficial' });
return;
}
const { limite, enviadosHoje, restante } = dailyLimitStatus(db);
if (restante <= 0) {
json(429, {
ok: false,
erro: `limite diario atingido (${enviadosHoje}/${limite}). Aumente o limite por dia ou aguarde virar o dia.`,
limite,
enviadosHoje,
restante
});
return;
}
const insert = db.prepare(
`INSERT INTO outreach_sends (lead_id, produto, numero, mensagem_enviada, status, erro, zpro_ticket_id, canal, origem)
VALUES (NULL, '', ?, ?, ?, ?, ?, ?, 'manual')`
);
const mensagemEnviada = canal === 'oficial' ? `template:${templateWabaNome}/${templateWabaIdioma}` : mensagem;
try {
const zproClient = createZproClient(db);
const externalKey = `gti-manual-${Date.now()}`;
const resposta = canal === 'oficial'
? await zproClient.sendTemplateWaba({
number: numero,
templateName: templateWabaNome,
languageCode: templateWabaIdioma,
route: templateRoute,
variables: templateVariables
})
: await zproClient.sendText({ number: numero, message: mensagem, externalKey });
const ticketId = resposta?.ticketId || resposta?.data?.ticketId ? String(resposta.ticketId || resposta.data.ticketId) : null;
insert.run(numero, mensagemEnviada, 'enviado', null, ticketId, canal);
json(200, { ok: true, ticketId });
} catch (error) {
insert.run(numero, mensagemEnviada, 'erro', String(error.message || error), null, canal);
json(200, { ok: false, erro: String(error.message || error) });
}
});
router.add('GET', '/api/outreach/templates', async (req, res, { json }) => {
json(200, { templates: listMessageTemplates(db) });
});
router.add('GET', '/api/outreach/waba-templates', async (req, res, { json }) => {
const syncUrl = getSetting(db, 'gchat.templatesUrl', '').trim();
const lastSync = getSetting(db, 'gchat.templatesLastSync', '');
const lastSyncAgeMs = lastSync ? Date.now() - new Date(lastSync).getTime() : Infinity;
if (syncUrl && lastSyncAgeMs > 10 * 60 * 1000) {
try {
await syncWabaTemplateLibrary(db);
} catch {
// A biblioteca local continua funcionando mesmo se o GChat estiver fora.
}
}
json(200, {
templates: listWabaTemplateLibrary(db),
lastSync: getSetting(db, 'gchat.templatesLastSync', '')
});
});
router.add('GET', '/api/outreach/variables', async (req, res, { json }) => {
json(200, { variables: COMMERCIAL_VARIABLES });
});
router.add('POST', '/api/outreach/waba-templates/sync', async (req, res, { json }) => {
try {
json(200, await syncWabaTemplateLibrary(db));
} catch (error) {
json(200, { ok: false, erro: String(error.message || error) });
}
});
router.add('PUT', '/api/outreach/templates/:produto', async (req, res, { json, params, body }) => {
const produto = params.produto;
if (!body || typeof body !== 'object') {
json(400, { erro: 'corpo JSON esperado: {"mensagem": "...", "ativo": true}' });
return;
}
const atualizado = updateMessageTemplate(db, produto, {
mensagem: body.mensagem,
ativo: body.ativo,
canal: body.canal,
templateWabaNome: body.templateWabaNome,
templateWabaIdioma: body.templateWabaIdioma,
templateWabaVariaveis: body.templateWabaVariaveis
});
if (!atualizado) {
json(404, { erro: `template do produto "${produto}" nao encontrado` });
return;
}
json(200, atualizado);
});
}