@danhachuel/leadmaster 0.1.15 → 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 +38 -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 +12 -0
- 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/frontend/dist/assets/index-CAvEEMWg.js +0 -90
package/CHANGELOG.md
CHANGED
|
@@ -37,6 +37,44 @@ 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
|
+
|
|
40
78
|
## [0.1.15] — 2026-11-08
|
|
41
79
|
|
|
42
80
|
### Corrigido
|
|
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
|
|
@@ -66,6 +66,18 @@ class TestLauncherProtections:
|
|
|
66
66
|
assert "detached: true" in c
|
|
67
67
|
assert "windowsHide: true" in c
|
|
68
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
|
+
|
|
69
81
|
def test_sigterm_nao_absorvido(self):
|
|
70
82
|
# SIGTERM/SIGBREAK são paradas legítimas: sempre encerram.
|
|
71
83
|
c = _conteudo()
|