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:
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user