@danhachuel/leadmaster 0.1.14 → 0.1.16
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/CHANGELOG.md +68 -0
- package/backend/__pycache__/main.cpython-311.pyc +0 -0
- package/backend/main.py +24 -0
- package/backend/services/__pycache__/__init__.cpython-311.pyc +0 -0
- package/backend/services/__pycache__/agents.cpython-311.pyc +0 -0
- package/backend/services/__pycache__/crm_agent.cpython-311.pyc +0 -0
- package/backend/services/__pycache__/extractors.cpython-311.pyc +0 -0
- package/backend/services/__pycache__/flags.cpython-311.pyc +0 -0
- package/backend/services/__pycache__/llm.cpython-311.pyc +0 -0
- package/backend/services/__pycache__/storage.cpython-311.pyc +0 -0
- package/backend/services/agents.py +4 -1
- package/backend/tests/__pycache__/conftest.cpython-311-pytest-8.4.2.pyc +0 -0
- package/backend/tests/__pycache__/test_campaigns_routes.cpython-311-pytest-8.4.2.pyc +0 -0
- package/backend/tests/__pycache__/test_cnpj_extractor.cpython-311-pytest-8.4.2.pyc +0 -0
- package/backend/tests/__pycache__/test_crm_routes.cpython-311-pytest-8.4.2.pyc +0 -0
- package/backend/tests/__pycache__/test_instagram_extractor.cpython-311-pytest-8.4.2.pyc +0 -0
- package/backend/tests/__pycache__/test_launcher_protections.cpython-311-pytest-8.4.2.pyc +0 -0
- package/backend/tests/__pycache__/test_maps_extractor.cpython-311-pytest-8.4.2.pyc +0 -0
- package/backend/tests/__pycache__/test_process_flags.cpython-311-pytest-8.4.2.pyc +0 -0
- package/backend/tests/test_campaigns_routes.py +71 -0
- package/backend/tests/test_launcher_protections.py +28 -3
- package/frontend/dist/assets/{index-Bf28IekR.css → index-BKo_OphM.css} +1 -1
- package/frontend/dist/assets/index-BTNs0Fhp.js +90 -0
- package/frontend/dist/index.html +2 -2
- package/package.json +1 -1
- package/scripts/cli.mjs +139 -24
- package/frontend/dist/assets/index-DgsUzQ92.js +0 -90
package/CHANGELOG.md
CHANGED
|
@@ -37,6 +37,74 @@ normaliza para a forma canônica (ex.: `0.1.0`).
|
|
|
37
37
|
falha se `process.stdin.resume();` desaparecer, se `.unref()` aparecer, se o
|
|
38
38
|
ciclo de vida vinculado for removido ou se os handlers globais sumirem.
|
|
39
39
|
|
|
40
|
+
## [0.1.16] — 2026-11-09
|
|
41
|
+
|
|
42
|
+
### Adicionado
|
|
43
|
+
|
|
44
|
+
- **Módulo de Disparos (Campanhas) revisado conforme especificação:**
|
|
45
|
+
- Rota `PUT /api/campaigns/{id}/status` — altera status
|
|
46
|
+
(`rascunho`/`ativa`/`pausada`/`concluida`; `ativa` normalize para
|
|
47
|
+
`em_andamento`; status inválido → 400, campanha inexistente → 404).
|
|
48
|
+
- `POST /api/campaigns` agora aceita **`filtro_etapa`**: sem `lead_ids`
|
|
49
|
+
explícitos, seleciona automaticamente os leads da etapa do CRM escolhida
|
|
50
|
+
(ex.: "Novos Leads"), e **`mensagem`** (texto do template) — usada pelo
|
|
51
|
+
agente de disparo quando presente.
|
|
52
|
+
- Página `frontend/src/pages/Disparos.tsx` reformulada em **tabela**
|
|
53
|
+
(Nome · Status · Total de Leads · Data de Criação · Ações), botão
|
|
54
|
+
**"Nova Campanha"** grande no topo (Fitts + von Restorff) e modal com nome,
|
|
55
|
+
**template da mensagem** (textarea), canal e **dropdown de filtro por etapa
|
|
56
|
+
do CRM**. Ações na tabela: **Iniciar** (gradiente), **Pausar** e **Parar**.
|
|
57
|
+
- UX: badges de status vibrantes (verde "Ativa", amarelo "Pausada", cinza
|
|
58
|
+
"Rascunho" — von Restorff) e **barra de progresso + spinner** para
|
|
59
|
+
campanhas ativas (Zeigarnik, com polling react-query a cada 4s).
|
|
60
|
+
- **5 novos testes pytest** (`backend/tests/test_campaigns_routes.py`):
|
|
61
|
+
listagem, criação com filtro de etapa + persistência, payload inválido,
|
|
62
|
+
alteração de status (ativa/pausada) e erros (status inválido 400 /
|
|
63
|
+
inexistente 404).
|
|
64
|
+
|
|
65
|
+
### Corrigido
|
|
66
|
+
|
|
67
|
+
- **Launcher — kill switch (6ª regra inviolável):** o launcher NUNCA mais
|
|
68
|
+
ignora uma porta ocupada por instância anterior do LeadMaster — antes de
|
|
69
|
+
subir, encerra a instância antiga confirmada (PID dono da porta via
|
|
70
|
+
`netstat -ano` no Windows / `lsof -ti:porta -sTCP:LISTEN` no Unix, com
|
|
71
|
+
fallback `ps`), `taskkill /F /T /PID` ou `kill -9`, aguardando até 5s a
|
|
72
|
+
liberação. A mensagem "já está rodando" foi **removida** (impedia
|
|
73
|
+
atualizações e causava `EBUSY` no npx). Processos de outros projetos nunca
|
|
74
|
+
são tocados (avança para a próxima porta). Roda no `install` (libera
|
|
75
|
+
arquivos) e no `start`. Gate de regressão reforçado com as asserções do
|
|
76
|
+
kill switch.
|
|
77
|
+
|
|
78
|
+
## [0.1.15] — 2026-11-08
|
|
79
|
+
|
|
80
|
+
### Corrigido
|
|
81
|
+
|
|
82
|
+
- **Launcher — kill switch: a versão nova SEMPRE assume o controle** — o
|
|
83
|
+
bloqueio de porta foi eliminado. Antes, ao detectar a porta ocupada, o
|
|
84
|
+
`cli.mjs` apenas imprimia "LeadMaster já está rodando" e encerrava: a versão
|
|
85
|
+
antiga continuava no ar, o usuário não conseguia testar a nova e o `npx`
|
|
86
|
+
falhava com `EBUSY` (arquivos travados no Windows). Agora:
|
|
87
|
+
- **`encerrarInstanciaAnterior()`** roda no início do `install` (libera os
|
|
88
|
+
arquivos antes de instalar — fim do `EBUSY`) e do `start`;
|
|
89
|
+
- descoberta do PID dono da porta via `netstat -ano` (Windows) /
|
|
90
|
+
`lsof -ti:porta` (Linux/macOS, com fallback `ps`), seguida de
|
|
91
|
+
`taskkill /F /T /PID` ou `kill -9`;
|
|
92
|
+
- espera de até 5s pela liberação da porta antes de subir o novo servidor;
|
|
93
|
+
- **higiene preservada:** só mata processos **confirmados** como LeadMaster
|
|
94
|
+
(`/api/health` com payload próprio + PID dono da porta). Portas ocupadas
|
|
95
|
+
por outros projetos são respeitadas — o launcher avança para a próxima
|
|
96
|
+
porta livre, como sempre;
|
|
97
|
+
- ciclo de vida vinculado mantido: com o Python antigo morto, o Node antigo
|
|
98
|
+
encerra sozinho (`child.on('exit')`/`close`).
|
|
99
|
+
- **AGENTS.md:** 6ª regra inviolável do launcher — *Kill switch*: nunca ignorar
|
|
100
|
+
porta ocupada por instância do LeadMaster; mensagem de reuso **proibida**.
|
|
101
|
+
- **Gate de regressão:** `test_launcher_protections.py` falha se o kill switch
|
|
102
|
+
desaparecer (`encerrarInstanciaAnterior`/`taskkill`/`netstat`/`lsof`) ou se a
|
|
103
|
+
mensagem proibida voltar. Suíte completa: **32 passed**.
|
|
104
|
+
- **Testado ao vivo:** instância anterior na mesma porta detectada, encerrada
|
|
105
|
+
(PID confirmado) e nova instância assumida com PID novo; node antigo caiu
|
|
106
|
+
pelo ciclo de vida; instância de outros projetos preservada.
|
|
107
|
+
|
|
40
108
|
## [0.1.14] — 2026-11-08
|
|
41
109
|
|
|
42
110
|
### Adicionado
|
|
Binary file
|
package/backend/main.py
CHANGED
|
@@ -104,7 +104,9 @@ class CampaignIn(BaseModel):
|
|
|
104
104
|
nome: str = Field(min_length=1)
|
|
105
105
|
canal: str = "texto"
|
|
106
106
|
template: str = "texto"
|
|
107
|
+
mensagem: str = ""
|
|
107
108
|
lead_ids: list[str] = []
|
|
109
|
+
filtro_etapa: str | None = None
|
|
108
110
|
|
|
109
111
|
|
|
110
112
|
class InstagramIn(BaseModel):
|
|
@@ -354,12 +356,34 @@ def listar_campanhas():
|
|
|
354
356
|
@app.post("/api/campaigns", status_code=201)
|
|
355
357
|
def criar_campanha(campaign_in: CampaignIn):
|
|
356
358
|
campaign = campaign_in.model_dump()
|
|
359
|
+
# Filtro por etapa do CRM: sem lead_ids explícitos, seleciona os leads da
|
|
360
|
+
# etapa escolhida (ex.: "Novos Leads").
|
|
361
|
+
if not campaign["lead_ids"] and campaign.get("filtro_etapa"):
|
|
362
|
+
_validar_etapa(campaign["filtro_etapa"])
|
|
363
|
+
campaign["lead_ids"] = [l["id"] for l in storage.list_leads() if l.get("etapa") == campaign["filtro_etapa"]]
|
|
357
364
|
campaign["id"] = storage._novo_id()
|
|
358
365
|
campaign.update({"status": "rascunho", "total": len(campaign["lead_ids"]), "enviadas": 0, "erros": 0, "eventos": [], "criado_em": storage.now_iso()})
|
|
359
366
|
storage.upsert_campaign(campaign)
|
|
360
367
|
return campaign
|
|
361
368
|
|
|
362
369
|
|
|
370
|
+
@app.put("/api/campaigns/{campaign_id}/status")
|
|
371
|
+
def alterar_status_campanha(campaign_id: str, dados: dict):
|
|
372
|
+
"""Altera o status de uma campanha (rascunho/ativa/pausada/concluida)."""
|
|
373
|
+
status = (dados.get("status") or "").strip().lower()
|
|
374
|
+
# Aliases aceitos: "ativa" equivale a "em_andamento" no ciclo interno.
|
|
375
|
+
aliases = {"ativa": "em_andamento", "em_andamento": "em_andamento", "pausada": "pausada", "pausado": "pausada", "rascunho": "rascunho", "concluida": "concluida"}
|
|
376
|
+
interno = aliases.get(status)
|
|
377
|
+
if not interno:
|
|
378
|
+
raise HTTPException(400, 'Status inválido. Use: rascunho, ativa, pausada ou concluida.')
|
|
379
|
+
campaign = storage.get_campaign(campaign_id)
|
|
380
|
+
if not campaign:
|
|
381
|
+
raise HTTPException(404, "Campanha não encontrada.")
|
|
382
|
+
campaign["status"] = interno
|
|
383
|
+
storage.upsert_campaign(campaign)
|
|
384
|
+
return campaign
|
|
385
|
+
|
|
386
|
+
|
|
363
387
|
@app.post("/api/campaigns/{campaign_id}/start")
|
|
364
388
|
def iniciar_campanha(campaign_id: str):
|
|
365
389
|
resultado = agents.iniciar_campanha(campaign_id)
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -152,7 +152,10 @@ def _executar_campanha(campaign_id: str) -> None:
|
|
|
152
152
|
storage.upsert_campaign(campaign)
|
|
153
153
|
continue
|
|
154
154
|
|
|
155
|
-
mensagem = render_template(
|
|
155
|
+
mensagem = render_template(
|
|
156
|
+
campaign.get("mensagem") or templates.get(campaign.get("canal", "texto"), templates.get("texto", "")),
|
|
157
|
+
lead,
|
|
158
|
+
)
|
|
156
159
|
try:
|
|
157
160
|
r = httpx.post(
|
|
158
161
|
f"{base}/{send_path}",
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Testes do módulo de Campanhas/Disparos (backend/main.py).
|
|
2
|
+
|
|
3
|
+
Cobrem listagem, criação (com filtro por etapa do CRM) e alteração de status
|
|
4
|
+
via PUT /api/campaigns/{id}/status. LEADMASTER_HOME em diretório temporário.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from fastapi.testclient import TestClient
|
|
9
|
+
|
|
10
|
+
import main
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.fixture()
|
|
14
|
+
def client(tmp_path, monkeypatch):
|
|
15
|
+
monkeypatch.setenv("LEADMASTER_HOME", str(tmp_path))
|
|
16
|
+
with TestClient(main.app) as c:
|
|
17
|
+
yield c
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_listar_campanhas(client):
|
|
21
|
+
r = client.get("/api/campaigns")
|
|
22
|
+
assert r.status_code == 200
|
|
23
|
+
assert isinstance(r.json(), list)
|
|
24
|
+
assert len(r.json()) >= 1 # seeds de demonstração
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_criar_campanha_com_filtro_etapa(client):
|
|
28
|
+
# Total de leads na etapa "leads" (seeds).
|
|
29
|
+
leads_leads = [l for l in client.get("/api/crm/leads").json() if l["etapa"] == "leads"]
|
|
30
|
+
r = client.post(
|
|
31
|
+
"/api/campaigns",
|
|
32
|
+
json={
|
|
33
|
+
"nome": "Campanha Filtro",
|
|
34
|
+
"canal": "texto",
|
|
35
|
+
"template": "texto",
|
|
36
|
+
"mensagem": "Olá {{primeiro_nome}}, tudo bem?",
|
|
37
|
+
"filtro_etapa": "leads",
|
|
38
|
+
},
|
|
39
|
+
)
|
|
40
|
+
assert r.status_code == 201
|
|
41
|
+
campanha = r.json()
|
|
42
|
+
assert campanha["nome"] == "Campanha Filtro"
|
|
43
|
+
assert campanha["status"] == "rascunho"
|
|
44
|
+
assert campanha["total"] == len(leads_leads)
|
|
45
|
+
assert len(campanha["lead_ids"]) == len(leads_leads)
|
|
46
|
+
# Persistida
|
|
47
|
+
lista = client.get("/api/campaigns").json()
|
|
48
|
+
assert any(c["id"] == campanha["id"] for c in lista)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_criar_campanha_payload_invalido(client):
|
|
52
|
+
r = client.post("/api/campaigns", json={"canal": "texto"})
|
|
53
|
+
assert r.status_code == 422 # nome obrigatório
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_alterar_status_ativa_e_pausada(client):
|
|
57
|
+
criada = client.post("/api/campaigns", json={"nome": "Status Teste", "lead_ids": []}).json()
|
|
58
|
+
r = client.put(f"/api/campaigns/{criada['id']}/status", json={"status": "ativa"})
|
|
59
|
+
assert r.status_code == 200
|
|
60
|
+
assert r.json()["status"] == "em_andamento"
|
|
61
|
+
r2 = client.put(f"/api/campaigns/{criada['id']}/status", json={"status": "pausada"})
|
|
62
|
+
assert r2.status_code == 200
|
|
63
|
+
assert r2.json()["status"] == "pausada"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_alterar_status_invalido_e_inexistente(client):
|
|
67
|
+
criada = client.post("/api/campaigns", json={"nome": "Status X", "lead_ids": []}).json()
|
|
68
|
+
r = client.put(f"/api/campaigns/{criada['id']}/status", json={"status": "nao_existe"})
|
|
69
|
+
assert r.status_code == 400
|
|
70
|
+
r2 = client.put("/api/campaigns/nao-existe/status", json={"status": "ativa"})
|
|
71
|
+
assert r2.status_code == 404
|
|
@@ -6,9 +6,11 @@ Windows (MobaXterm) for removida:
|
|
|
6
6
|
1. Keep-alive com setInterval (event loop ativo sem I/O de console);
|
|
7
7
|
2. Terminal preso: `process.stdin.resume();` (PTY do MobaXterm nunca fecha);
|
|
8
8
|
sem `process.exit(0)` no caminho de sucesso e sem `.unref()` no worker;
|
|
9
|
-
3.
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
3. Kill switch: instância anterior do LeadMaster encerrada antes de subir a
|
|
10
|
+
nova versão (taskkill/lsof pelo PID da porta) — nunca reuso de instância;
|
|
11
|
+
4. Absorção de SIGINT/SIGHUP no Windows (apenas log, sem encerrar);
|
|
12
|
+
5. Função guardConsoleStream (anti-EPIPE com espelho em arquivo de log);
|
|
13
|
+
6. Isolamento do worker Python (detached: true / CREATE_NEW_PROCESS_GROUP).
|
|
12
14
|
"""
|
|
13
15
|
|
|
14
16
|
from pathlib import Path
|
|
@@ -31,6 +33,17 @@ class TestLauncherProtections:
|
|
|
31
33
|
def test_worker_nunca_unref(self):
|
|
32
34
|
assert ".unref()" not in _conteudo()
|
|
33
35
|
|
|
36
|
+
def test_kill_switch_mata_instancia_anterior(self):
|
|
37
|
+
# Regra inviolável: nunca ignorar uma porta ocupada por outra instância
|
|
38
|
+
# do LeadMaster — matar o processo antigo antes de subir o novo. Sem
|
|
39
|
+
# isso a versão antiga trava arquivos (EBUSY no npx) e a nova não sobe.
|
|
40
|
+
c = _conteudo()
|
|
41
|
+
assert "encerrarInstanciaAnterior" in c
|
|
42
|
+
assert "taskkill" in c # Windows: mata a árvore do PID da porta
|
|
43
|
+
assert "netstat" in c or "lsof" in c # descoberta do PID pela porta
|
|
44
|
+
# Avisar que já existe instância no ar é proibido (impede atualizações).
|
|
45
|
+
assert "já está rodando" not in c
|
|
46
|
+
|
|
34
47
|
def test_ciclo_de_vida_vinculado(self):
|
|
35
48
|
c = _conteudo()
|
|
36
49
|
assert "child.on('exit'" in c or "child.on(\"exit\"" in c
|
|
@@ -53,6 +66,18 @@ class TestLauncherProtections:
|
|
|
53
66
|
assert "detached: true" in c
|
|
54
67
|
assert "windowsHide: true" in c
|
|
55
68
|
|
|
69
|
+
def test_kill_switch_presente(self):
|
|
70
|
+
c = _conteudo()
|
|
71
|
+
assert "encerrarInstanciaAnterior" in c
|
|
72
|
+
assert "netstat" in c # descoberta do PID no Windows
|
|
73
|
+
assert "lsof" in c # descoberta do PID no Linux/macOS
|
|
74
|
+
assert "taskkill" in c and "/F" in c # kill switch Windows
|
|
75
|
+
|
|
76
|
+
def test_nunca_avisa_ja_esta_rodando(self):
|
|
77
|
+
# Mensagem proibida: impede atualizações e deixa a versão antiga no ar.
|
|
78
|
+
assert "já está rodando" not in _conteudo().lower()
|
|
79
|
+
assert "ja esta rodando" not in _conteudo().lower()
|
|
80
|
+
|
|
56
81
|
def test_sigterm_nao_absorvido(self):
|
|
57
82
|
# SIGTERM/SIGBREAK são paradas legítimas: sempre encerram.
|
|
58
83
|
c = _conteudo()
|