@danhachuel/leadmaster 0.0.0-stage → 0.1.1

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 ADDED
@@ -0,0 +1,87 @@
1
+ # Changelog
2
+
3
+ Todas as mudanças notáveis deste projeto serão documentadas neste ficheiro.
4
+ O formato é baseado no [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/).
5
+
6
+ A etiqueta visual de versão usa patch de dois dígitos (ex.: `0.1.00`); o registry NPM
7
+ normaliza para a forma canônica (ex.: `0.1.0`).
8
+
9
+ ## [0.1.01] — 2026-10-23
10
+
11
+ ### Removido
12
+
13
+ - **Pipeline de vídeos faceless** — o LeadMaster agora é 100% focado em prospecção
14
+ (CRM, extratores de leads e agentes de IA). Removidos:
15
+ - Aba lateral "Pipeline de Vídeos", página e rota `/pipeline`.
16
+ - Integrações com MoneyPrinterTurbo, Short-Video-Maker, OpenMontage e upload para
17
+ YouTube: serviços backend, endpoints `/api/pipeline`, cards e testes em
18
+ Ferramentas, menções no Painel, tutorial do header, FAQ do Suporte e
19
+ diagnóstico.
20
+ - Motor do pipeline, agendador de publicação e contadores diários de upload.
21
+ - `pipeline.json` do armazenamento local — removido automaticamente das
22
+ instalações existentes, junto com as chaves de vídeo do `config.json`
23
+ (`integracoes.moneyprinter`, `short_video_maker`, `openmontage`,
24
+ `youtube_upload`, `uploads`) e o evento de webhook `video.publicado`.
25
+
26
+ ### Adicionado
27
+
28
+ - Migração automática de configuração: chaves de vídeo de versões antigas são
29
+ limpas na primeira execução da nova versão.
30
+ - Card "Status da Conexão" do Painel passa a mostrar LLM e OpenOutreach.
31
+
32
+ ### Alterado
33
+
34
+ - Backend de desenvolvimento (uvicorn) agora usa a porta **8010** — a 8000 costuma
35
+ ser usada por outros serviços locais (ex.: PandaNews), o que causava conflito.
36
+
37
+ ## [0.1.00] — 2026-10-23
38
+
39
+ ### Adicionado
40
+
41
+ - **Versão inicial do LeadMaster** — SaaS local de prospecção e automação de conteúdo,
42
+ instalável via `npx @danhachuel/leadmaster` e 100% offline (sem nuvem).
43
+ - **Launcher NPM** (`scripts/cli.mjs`): comandos `install`, `start`, `doctor`, `version`,
44
+ `help`; **comando único auto-suficiente** (estilo Thunderbolt) — `npx
45
+ @danhachuel/leadmaster` sem argumentos verifica e prepara tudo sozinho antes de subir;
46
+ **instalação idempotente** (dependências Python e Node só são instaladas/atualizadas
47
+ quando realmente faltam; interface só é reconstruída quando o build não existe ou
48
+ falha por deps desatualizadas); resolução da pasta de dados (`--home` /
49
+ `LEADMASTER_HOME`, padrão `Documents/REPOSITORIO/LeadMaster`); instalação automática
50
+ das dependências Python; porta padrão da UI/API **5055** com auto-incremento (5056,
51
+ 5057, ...) quando ocupada; deteção de instância já ativa; abertura automática do
52
+ navegador; encerramento limpo.
53
+ - **Workflow de publicação** (`.github/workflows/publish-npm.yml`): build do frontend,
54
+ validação de metadados, smoke-test do launcher, publicação via `dist-tag next`,
55
+ verificação de tarball e promoção a `latest` por tag `v*`.
56
+ - **Backend FastAPI** (`backend/`):
57
+ - Persistência local em JSON (`storage/leads.json`, `campaigns.json`, `pipeline.json`,
58
+ `config.json`) com escrita atômica e seeds automáticas.
59
+ - CRUD de leads com importação em massa, agente de CRM (sugestão de movimentação e
60
+ enriquecimento via BrasilAPI/LLM).
61
+ - Campanhas de disparo com execução em background, contadores e log por lead.
62
+ - Motor do pipeline de vídeos faceless com 5 etapas (roteiro, thumbnail/título,
63
+ produção wide 16:9, produção shorts 9:16, upload), controle
64
+ iniciar/pausar/parar/repetir, logs por etapa e agendamento de publicação
65
+ (não listado → 1h → público).
66
+ - Integrações: MoneyPrinterTurbo (localhost:8080), Short-Video-Maker (localhost:3123),
67
+ OpenMontage (CLI), upload YouTube com limite de 50/dia e fallback
68
+ `youtube-uploader-mcp`, LLM OpenAI-compatível, OpenOutreach, pipecat.
69
+ - Extratores de leads: Google Maps (`gosom/google-maps-scraper`), Instagram
70
+ (`lucasoal/igmapper`), CNPJ (BrasilAPI + provedor de busca por CNAE/UF +
71
+ `D4Vinci/Scrapling`).
72
+ - Endpoints de diagnóstico (`/api/health`, `/api/doctor`), métricas do painel e
73
+ serving estático da UI construída (modo `npx`).
74
+ - **Frontend React 18 + Vite + TypeScript + Tailwind** (`frontend/`):
75
+ - UI fiel ao protótipo `LeadMaster v1.0` (sidebar `#1e1b4b`, cards brancos, gradientes
76
+ indigo/purple, Font Awesome) com as 7 Leis de UX aplicadas; **favicon de urso** nas
77
+ guias dos navegadores (`frontend/public/favicon.svg`).
78
+ - Páginas: Painel (métricas + gráficos Chart.js), Leads (CRUD + progresso de
79
+ preenchimento), CRM Kanban (`@dnd-kit` — Leads/Em contato/Proposta/Fechado),
80
+ Disparos (campanhas), Extratores (Google Maps/Instagram/CNPJ), Pipeline de vídeos,
81
+ Agentes (disparo/atendimento), Ferramentas (configuração de chaves e caminhos),
82
+ Webhook e Suporte (diagnóstico).
83
+ - Componentes: Sidebar, Header, MetricCard, ProgressBar, Accordion, Modal, Toast,
84
+ StatusBadge, gráficos (barras horizontais e doughnut) e Kanban com drag-and-drop.
85
+ - **Documentação**: `README.md` (instalação, arquitetura, integrações), `AGENTS.md`
86
+ (regras de projeto, versionamento `MAJOR.MINOR.PATCH` com patch de dois dígitos,
87
+ release via GitHub Actions, push obrigatório, CHANGELOG obrigatório) e `LICENSE` (MIT).
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dan Pariente Hachuel
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,119 @@
1
- # Temporary Holding Version
1
+ # LeadMaster
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **SaaS local de prospecção.** CRM, extratores de leads e agentes de IA — instalável via
4
+ `npx`, 100% local, sem nuvem e sem banco de dados externo.
5
+
6
+ ![Stack](https://img.shields.io/badge/React%2018-Vite%20%2B%20TS-61dafb) ![Backend](https://img.shields.io/badge/FastAPI-Python%203.11%2B-009688) ![Storage](https://img.shields.io/badge/storage-JSON%20local-orange)
7
+
8
+ ---
9
+
10
+ ## Instalação e uso
11
+
12
+ Requisitos: **Node 18+** e **Python 3.11+** no PATH.
13
+
14
+ ```cmd
15
+ :: CMD / MobaXterm (Windows)
16
+ npx.cmd --yes --prefer-online @danhachuel/leadmaster@0.1.0 install
17
+ npx.cmd --yes --prefer-online @danhachuel/leadmaster@0.1.0
18
+ ```
19
+
20
+ ```bash
21
+ # Linux / macOS / PowerShell
22
+ npx --yes @danhachuel/leadmaster@0.1.0 install
23
+ npx --yes @danhachuel/leadmaster@0.1.0
24
+ ```
25
+
26
+ - `install` prepara a pasta de dados e instala as dependências Python.
27
+ - Sem argumentos, o launcher sobe o servidor local (FastAPI na porta **5055**) e abre o
28
+ navegador automaticamente. Se a porta 5055 estiver ocupada, o LeadMaster sobe
29
+ automaticamente na próxima livre (5056, 5057, ...). Pressione `Ctrl+C` para encerrar.
30
+ - Flags úteis: `--home=<caminho>` (pasta de dados), `--port=<numero>`,
31
+ `doctor` (diagnóstico), `help`.
32
+
33
+ A pasta de dados padrão é `Documents\REPOSITORIO\LeadMaster` (sobrescreva com
34
+ `--home` ou `LEADMASTER_HOME`). Todos os dados — leads, campanhas e
35
+ configuração — ficam em `<home>\storage` como arquivos JSON.
36
+
37
+ ### Modo desenvolvimento (a partir do repositório)
38
+
39
+ ```bash
40
+ npm install # dependências da raiz (concurrently)
41
+ npm run dev # sobe backend (uvicorn :8010, reload) + frontend (Vite :5055)
42
+ ```
43
+
44
+ O Vite dev server sobe a UI de desenvolvimento em `http://localhost:5055` (com
45
+ auto-incremento para 5056, 5057... se a porta estiver ocupada) e faz proxy de `/api`
46
+ para `http://127.0.0.1:8010` (backend de desenvolvimento).
47
+
48
+ ---
49
+
50
+ ## Módulos
51
+
52
+ | Módulo | O que faz |
53
+ |---|---|
54
+ | **Painel** | Status da conexão, taxa de entrega, taxa de erro, total de leads; gráfico de barras (leads por etapa do CRM) e doughnut (campanhas: enviadas × erros). |
55
+ | **Leads** | CRUD completo, importação em massa a partir dos extratores, barra de progresso de preenchimento, enriquecimento por IA. |
56
+ | **CRM Kanban** | Drag-and-drop (@dnd-kit) nas colunas *Leads → Em contato → Proposta → Fechado*; o agente de IA sugere movimentações e enriquece os cards. |
57
+ | **Extratores** | **Google Maps** (palavra-chave + localização, via `gosom/google-maps-scraper`), **Instagram** (URL do perfil, via `lucasoal/igmapper`) e **CNPJ** (BrasilAPI + busca por CNAE/UF + `D4Vinci/Scrapling`). Toda extração gera pré-visualização antes de importar. |
58
+ | **Disparos** | Campanhas de disparo (texto/áudio) com templates configuráveis e execução via `eracle/OpenOutreach`. |
59
+ | **Agentes** | Agente de disparo (prompts/templates) e agente de atendimento (texto via LLM ou voz via `pipecat-ai/pipecat`). |
60
+ | **Ferramentas** | Chaves de API, URLs das integrações locais, testes de conexão — tudo salvo em `config.json` local. |
61
+
62
+ ---
63
+
64
+ ## Arquitetura
65
+
66
+ ```
67
+ npx @danhachuel/leadmaster
68
+ └─ scripts/cli.mjs # instala deps Python, sobe uvicorn, abre navegador
69
+ └─ backend/main.py # FastAPI: API + serving da UI construída (frontend/dist)
70
+ ├─ services/storage.py # JSON local (leads, campanhas, config)
71
+ ├─ services/extractors.py # Maps / Instagram / CNPJ
72
+ ├─ services/crm_agent.py # sugestões + enriquecimento
73
+ ├─ services/agents.py # disparo (OpenOutreach) + atendimento
74
+ └─ services/llm.py # LLM OpenAI-compatível
75
+ ```
76
+
77
+ - **Portas**: LeadMaster UI/API `:5055` (auto-incrementa: 5056, 5057, ... se ocupada) ·
78
+ backend de desenvolvimento `:8000`.
79
+ - **Degradación graciosa**: se um serviço externo não estiver configurado/rodando, a ação
80
+ falha com mensagem acionável — nada de falsos sucessos.
81
+
82
+ ### Serviços externos (opcionais, todos locais)
83
+
84
+ | Serviço | Como usar no LeadMaster |
85
+ |---|---|
86
+ | [gosom/google-maps-scraper](https://github.com/gosom/google-maps-scraper) | Informe o caminho do binário em *Ferramentas → Extratores*. |
87
+ | [lucasoal/igmapper](https://github.com/lucasoal/igmapper) | Informe o caminho em *Ferramentas → Extratores*. |
88
+ | BrasilAPI / OpenCNPJ / Scrapling | Consulta por CNPJ e busca por CNAE/UF (configuráveis). |
89
+ | [eracle/OpenOutreach](https://github.com/eracle/OpenOutreach) | Agente de disparo. |
90
+ | [pipecat-ai/pipecat](https://github.com/pipecat-ai/pipecat) | Atendimento por voz. |
91
+
92
+ ---
93
+
94
+ ## UX
95
+
96
+ A interface replica o protótipo `LeadMaster v1.0` (sidebar escura `#1e1b4b`, cards
97
+ brancos, gradientes indigo/purple) e aplica as **7 Leis de Usabilidade**:
98
+
99
+ 1. **Fitts** — CTAs grandes ("Nova Campanha", "Salvar").
100
+ 2. **Hick** — configurações avançadas em accordions.
101
+ 3. **Zeigarnik** — barras de progresso (preenchimento de leads e campanhas).
102
+ 4. **Jakob** — consistência com o modelo mental do protótipo (menu lateral fixo).
103
+ 5. **Gradiente de Meta** — "Faltam 2 campos para o lead ficar completo".
104
+ 6. **von Restorff** — botão principal com gradiente sobre fundo neutro.
105
+ 7. **Miller** — métricas agrupadas em cards (chunking).
106
+
107
+ ---
108
+
109
+ ## Versionamento e release
110
+
111
+ - Etiqueta visual: `MAJOR.MINOR.PATCH` com **patch de dois dígitos** (`0.1.00` → `0.1.99`
112
+ → `0.2.00`; `0.9.99` → `1.0.00`). O NPM normaliza para a forma canônica (`0.1.0`).
113
+ - Publicação **sempre** via GitHub Actions (`.github/workflows/publish-npm.yml`):
114
+ dispatch manual com `dist-tag next` → push da tag `vX.Y.ZZ` → promoção a `latest`.
115
+ - Toda versão precisa de entrada no `CHANGELOG.md`. Regras completas em `AGENTS.md`.
116
+
117
+ ## Licença
118
+
119
+ [MIT](LICENSE) © Dan Hachuel
@@ -0,0 +1,466 @@
1
+ """LeadMaster — API FastAPI (100% local).
2
+
3
+ Rotas da aplicação + serving estático da UI construída (frontend/dist) para o
4
+ modo `npx`, onde um único servidor entrega painel e API na mesma porta.
5
+ """
6
+
7
+ import json
8
+ import os
9
+ import platform
10
+ import sys
11
+ import threading
12
+ from contextlib import asynccontextmanager
13
+ from pathlib import Path
14
+
15
+ from fastapi import FastAPI, HTTPException
16
+ from fastapi.middleware.cors import CORSMiddleware
17
+ from fastapi.responses import FileResponse
18
+ from fastapi.staticfiles import StaticFiles
19
+ from pydantic import BaseModel, Field
20
+
21
+ from services import agents, crm_agent, extractors, llm, storage
22
+
23
+ REPO_ROOT = Path(__file__).resolve().parent.parent
24
+ FRONTEND_DIST = REPO_ROOT / "frontend" / "dist"
25
+
26
+
27
+ def _versao() -> str:
28
+ env = os.environ.get("LEADMASTER_VERSION")
29
+ if env:
30
+ return env
31
+ try:
32
+ return json.loads((REPO_ROOT / "package.json").read_text(encoding="utf-8"))["version"]
33
+ except Exception:
34
+ return "dev"
35
+
36
+
37
+ VERSION = _versao()
38
+
39
+
40
+ @asynccontextmanager
41
+ async def lifespan(_app: FastAPI):
42
+ storage.ensure_seeds()
43
+ yield
44
+
45
+
46
+ app = FastAPI(title="LeadMaster API", version=VERSION, lifespan=lifespan)
47
+
48
+ app.add_middleware(
49
+ CORSMiddleware,
50
+ # A UI local (npx) roda same-origin; em modo dev o Vite sobe em 5055 e pode
51
+ # auto-incrementar (5056, 5057...) quando a porta está ocupada — por isso
52
+ # aceitamos qualquer porta em localhost/127.0.0.1.
53
+ allow_origin_regex=r"https?://(localhost|127\.0\.0\.1)(:\d+)?",
54
+ allow_methods=["*"],
55
+ allow_headers=["*"],
56
+ )
57
+
58
+
59
+ # ---------------------------------------------------------------------------
60
+ # Models
61
+ # ---------------------------------------------------------------------------
62
+
63
+ class LeadIn(BaseModel):
64
+ nome: str = Field(min_length=1)
65
+ telefone: str = ""
66
+ email: str = ""
67
+ fonte: str = "Manual"
68
+ etapa: str = "leads"
69
+ cnpj: str = ""
70
+ empresa: str = ""
71
+ endereco: str = ""
72
+ cidade: str = ""
73
+ observacoes: str = ""
74
+ score: int | None = None
75
+ tags: list[str] = []
76
+
77
+
78
+ class LeadsImportIn(BaseModel):
79
+ leads: list[dict]
80
+
81
+
82
+ class CampaignIn(BaseModel):
83
+ nome: str = Field(min_length=1)
84
+ canal: str = "texto"
85
+ template: str = "texto"
86
+ lead_ids: list[str] = []
87
+
88
+
89
+ class GoogleMapsIn(BaseModel):
90
+ palavra_chave: str = Field(min_length=2)
91
+ localizacao: str = Field(min_length=2)
92
+ max_resultados: int = 100
93
+
94
+
95
+ class InstagramIn(BaseModel):
96
+ profile_url: str = Field(min_length=6)
97
+
98
+
99
+ class CnpjIn(BaseModel):
100
+ cnpj: str = Field(min_length=8)
101
+
102
+
103
+ class CnpjSearchIn(BaseModel):
104
+ cnae: str = Field(min_length=2)
105
+ uf: str = Field(min_length=2)
106
+ quantidade: int = 20
107
+
108
+
109
+ class ChatIn(BaseModel):
110
+ mensagens: list[dict]
111
+
112
+
113
+ class TemplateTestIn(BaseModel):
114
+ template: str
115
+ lead_id: str | None = None
116
+
117
+
118
+ class WebhookTestIn(BaseModel):
119
+ url: str = Field(min_length=8)
120
+
121
+
122
+ class IntegracaoTestIn(BaseModel):
123
+ url: str
124
+
125
+
126
+ # ---------------------------------------------------------------------------
127
+ # Sistema
128
+ # ---------------------------------------------------------------------------
129
+
130
+ @app.get("/api/health")
131
+ def health():
132
+ return {
133
+ "status": "ok",
134
+ "versao": VERSION,
135
+ "home": str(storage.get_home()),
136
+ "storage": str(storage.storage_dir()),
137
+ }
138
+
139
+
140
+ @app.get("/api/doctor")
141
+ def doctor():
142
+ integracoes = {
143
+ "llm_configurado": llm.configurado(),
144
+ "openoutreach": agents.disparo_configurado(),
145
+ "pipecat": bool(storage.get_config()["integracoes"]["pipecat"].get("url")),
146
+ }
147
+ return {
148
+ "ok": True,
149
+ "versao": VERSION,
150
+ "python": platform.python_version(),
151
+ "sistema": platform.platform(),
152
+ "dist_frontend": FRONTEND_DIST.exists(),
153
+ "storage": str(storage.storage_dir()),
154
+ "integracoes": integracoes,
155
+ }
156
+
157
+
158
+ @app.post("/api/shutdown")
159
+ def shutdown():
160
+ threading.Timer(0.6, os._exit, [0]).start()
161
+ return {"ok": True, "mensagem": "Servidor local encerrando em instantes."}
162
+
163
+
164
+ # ---------------------------------------------------------------------------
165
+ # Métricas (Painel)
166
+ # ---------------------------------------------------------------------------
167
+
168
+ @app.get("/api/metrics")
169
+ def metrics():
170
+ leads = storage.list_leads()
171
+ campaigns = storage.list_campaigns()
172
+ enviadas = sum(int(c.get("enviadas", 0)) for c in campaigns)
173
+ erros = sum(int(c.get("erros", 0)) for c in campaigns)
174
+ total_envios = enviadas + erros
175
+
176
+ return {
177
+ "conexao": {
178
+ "geral": True, # o backend local responde => conectado
179
+ "llm": llm.configurado(),
180
+ "openoutreach": agents.disparo_configurado(),
181
+ },
182
+ "taxa_entrega": round(enviadas / total_envios * 100, 1) if total_envios else 100.0,
183
+ "taxa_erro": round(erros / total_envios * 100, 1) if total_envios else 0.0,
184
+ "total_enviadas": enviadas,
185
+ "total_erros": erros,
186
+ "leads_total": len(leads),
187
+ "leads_por_etapa": {
188
+ etapa: sum(1 for l in leads if l.get("etapa") == etapa)
189
+ for etapa in ("leads", "em_contato", "proposta", "fechado")
190
+ },
191
+ "campanhas": {"enviadas": enviadas, "erros": erros, "total": len(campaigns)},
192
+ "perfil": storage.get_config().get("perfil", {}),
193
+ }
194
+
195
+
196
+ # ---------------------------------------------------------------------------
197
+ # Leads
198
+ # ---------------------------------------------------------------------------
199
+
200
+ def _validar_etapa(etapa: str) -> str:
201
+ if etapa not in ("leads", "em_contato", "proposta", "fechado"):
202
+ raise HTTPException(400, "Etapa inválida. Use: leads, em_contato, proposta ou fechado.")
203
+ return etapa
204
+
205
+
206
+ @app.get("/api/leads")
207
+ def listar_leads():
208
+ return storage.list_leads()
209
+
210
+
211
+ @app.post("/api/leads", status_code=201)
212
+ def criar_lead(lead_in: LeadIn):
213
+ lead = lead_in.model_dump()
214
+ lead["id"] = storage._novo_id()
215
+ lead["etapa"] = _validar_etapa(lead.get("etapa", "leads"))
216
+ lead["score"] = lead.get("score") if lead.get("score") is not None else crm_agent.calcular_completude(lead)
217
+ storage.add_lead(lead)
218
+ agents.disparar_webhook("lead.criado", {"lead_id": lead["id"], "nome": lead["nome"]})
219
+ return lead
220
+
221
+
222
+ @app.patch("/api/leads/{lead_id}")
223
+ def atualizar_lead(lead_id: str, patch: dict):
224
+ if patch.get("etapa"):
225
+ _validar_etapa(patch["etapa"])
226
+ atualizado = storage.update_lead(lead_id, patch)
227
+ if not atualizado:
228
+ raise HTTPException(404, "Lead não encontrado.")
229
+ if patch.get("etapa"):
230
+ agents.disparar_webhook("lead.etapa", {"lead_id": lead_id, "etapa": patch["etapa"]})
231
+ return atualizado
232
+
233
+
234
+ @app.delete("/api/leads/{lead_id}")
235
+ def remover_lead(lead_id: str):
236
+ if not storage.delete_lead(lead_id):
237
+ raise HTTPException(404, "Lead não encontrado.")
238
+ return {"ok": True}
239
+
240
+
241
+ @app.post("/api/leads/import")
242
+ def importar_leads(dados: LeadsImportIn):
243
+ for lead in dados.leads:
244
+ lead["etapa"] = _validar_etapa(lead.get("etapa", "leads"))
245
+ return storage.import_leads(dados.leads)
246
+
247
+
248
+ @app.post("/api/leads/{lead_id}/enrich")
249
+ def enriquecer_lead(lead_id: str):
250
+ resultado = crm_agent.enriquecer_lead(lead_id)
251
+ if not resultado.get("ok"):
252
+ raise HTTPException(404, resultado.get("mensagem", "Lead não encontrado."))
253
+ return resultado
254
+
255
+
256
+ # ---------------------------------------------------------------------------
257
+ # CRM (agente)
258
+ # ---------------------------------------------------------------------------
259
+
260
+ @app.get("/api/crm/sugestoes")
261
+ def sugestoes_crm():
262
+ return crm_agent.sugerir_movimentacoes()
263
+
264
+
265
+ @app.post("/api/crm/sugestoes/aplicar")
266
+ def aplicar_sugestao(dados: dict):
267
+ lead_id, para = dados.get("lead_id"), dados.get("para")
268
+ if not lead_id or not para:
269
+ raise HTTPException(400, "Informe lead_id e para.")
270
+ atualizado = storage.update_lead(lead_id, {"etapa": _validar_etapa(para)})
271
+ if not atualizado:
272
+ raise HTTPException(404, "Lead não encontrado.")
273
+ agents.disparar_webhook("lead.etapa", {"lead_id": lead_id, "etapa": para, "via": "agente_crm"})
274
+ return {"ok": True, "lead": atualizado}
275
+
276
+
277
+ # ---------------------------------------------------------------------------
278
+ # Campanhas / Disparos
279
+ # ---------------------------------------------------------------------------
280
+
281
+ @app.get("/api/campaigns")
282
+ def listar_campanhas():
283
+ return storage.list_campaigns()
284
+
285
+
286
+ @app.post("/api/campaigns", status_code=201)
287
+ def criar_campanha(campaign_in: CampaignIn):
288
+ campaign = campaign_in.model_dump()
289
+ campaign["id"] = storage._novo_id()
290
+ campaign.update({"status": "rascunho", "total": len(campaign["lead_ids"]), "enviadas": 0, "erros": 0, "eventos": [], "criado_em": storage.now_iso()})
291
+ storage.upsert_campaign(campaign)
292
+ return campaign
293
+
294
+
295
+ @app.post("/api/campaigns/{campaign_id}/start")
296
+ def iniciar_campanha(campaign_id: str):
297
+ resultado = agents.iniciar_campanha(campaign_id)
298
+ if not resultado.get("ok"):
299
+ raise HTTPException(400, resultado["mensagem"])
300
+ return resultado
301
+
302
+
303
+ @app.post("/api/campaigns/{campaign_id}/pause")
304
+ def pausar_campanha(campaign_id: str):
305
+ return agents.pausar_campanha(campaign_id)
306
+
307
+
308
+ @app.post("/api/campaigns/{campaign_id}/stop")
309
+ def parar_campanha(campaign_id: str):
310
+ return agents.parar_campanha(campaign_id)
311
+
312
+
313
+ @app.delete("/api/campaigns/{campaign_id}")
314
+ def remover_campanha(campaign_id: str):
315
+ if not storage.delete_campaign(campaign_id):
316
+ raise HTTPException(404, "Campanha não encontrada.")
317
+ return {"ok": True}
318
+
319
+
320
+ # ---------------------------------------------------------------------------
321
+ # Extratores
322
+ # ---------------------------------------------------------------------------
323
+
324
+ @app.post("/api/extract/google-maps")
325
+ def extrair_google_maps(dados: GoogleMapsIn):
326
+ try:
327
+ return extractors.extrair_google_maps(dados.palavra_chave, dados.localizacao, dados.max_resultados)
328
+ except extractors.ExtratorNaoConfigurado as e:
329
+ raise HTTPException(400, str(e))
330
+ except extractors.ExtratorErro as e:
331
+ raise HTTPException(502, str(e))
332
+
333
+
334
+ @app.post("/api/extract/instagram")
335
+ def extrair_instagram(dados: InstagramIn):
336
+ try:
337
+ return extractors.extrair_instagram(dados.profile_url)
338
+ except extractors.ExtratorNaoConfigurado as e:
339
+ raise HTTPException(400, str(e))
340
+ except extractors.ExtratorErro as e:
341
+ raise HTTPException(502, str(e))
342
+
343
+
344
+ @app.post("/api/extract/cnpj")
345
+ def consultar_cnpj(dados: CnpjIn):
346
+ try:
347
+ return extractors.consulta_cnpj(dados.cnpj)
348
+ except extractors.ExtratorErro as e:
349
+ raise HTTPException(502, str(e))
350
+
351
+
352
+ @app.post("/api/extract/cnpj/search")
353
+ def buscar_cnpj(dados: CnpjSearchIn):
354
+ try:
355
+ return extractors.buscar_cnpj_cnae(dados.cnae, dados.uf, dados.quantidade)
356
+ except extractors.ExtratorErro as e:
357
+ raise HTTPException(502, str(e))
358
+
359
+
360
+ # ---------------------------------------------------------------------------
361
+ # Configuração e integrações
362
+ # ---------------------------------------------------------------------------
363
+
364
+ @app.get("/api/config")
365
+ def obter_config():
366
+ return storage.redact_config(storage.get_config())
367
+
368
+
369
+ @app.put("/api/config")
370
+ def salvar_config(patch: dict):
371
+ storage.update_config(patch)
372
+ return storage.redact_config(storage.get_config())
373
+
374
+
375
+ @app.post("/api/config/test/llm")
376
+ def testar_llm():
377
+ return llm.testar_conexao()
378
+
379
+
380
+ @app.post("/api/config/test/integracao")
381
+ def testar_integracao(dados: IntegracaoTestIn):
382
+ import httpx
383
+
384
+ try:
385
+ r = httpx.get(dados.url, timeout=4)
386
+ return {"ok": True, "status_http": r.status_code}
387
+ except httpx.HTTPError as e:
388
+ return {"ok": False, "mensagem": f"Sem resposta de {dados.url}: {e}"}
389
+
390
+
391
+ @app.get("/api/integrations/status")
392
+ def status_integracoes():
393
+ return {
394
+ "llm": llm.configurado(),
395
+ "openoutreach": agents.disparo_configurado(),
396
+ "pipecat": bool(storage.get_config()["integracoes"]["pipecat"].get("url")),
397
+ }
398
+
399
+
400
+ # ---------------------------------------------------------------------------
401
+ # Agentes
402
+ # ---------------------------------------------------------------------------
403
+
404
+ @app.get("/api/agents/templates")
405
+ def obter_templates():
406
+ return agents.obter_templates()
407
+
408
+
409
+ @app.put("/api/agents/templates")
410
+ def salvar_templates(patch: dict):
411
+ return agents.salvar_templates(patch)
412
+
413
+
414
+ @app.post("/api/agents/templates/test")
415
+ def testar_template(dados: TemplateTestIn):
416
+ return agents.testar_template(dados.template, dados.lead_id)
417
+
418
+
419
+ @app.post("/api/agents/attendance/chat")
420
+ def atendimento_chat(dados: ChatIn):
421
+ return agents.atendimento_chat(dados.mensagens)
422
+
423
+
424
+ # ---------------------------------------------------------------------------
425
+ # Webhook
426
+ # ---------------------------------------------------------------------------
427
+
428
+ @app.get("/api/webhook")
429
+ def obter_webhook():
430
+ return storage.get_config().get("webhook", {})
431
+
432
+
433
+ @app.put("/api/webhook")
434
+ def salvar_webhook(patch: dict):
435
+ config = storage.get_config()
436
+ webhook = config.get("webhook", {})
437
+ webhook.update({k: v for k, v in patch.items() if k in ("url", "eventos")})
438
+ storage.update_config({"webhook": webhook})
439
+ return webhook
440
+
441
+
442
+ @app.post("/api/webhook/test")
443
+ def testar_webhook(dados: WebhookTestIn):
444
+ return agents.testar_webhook(dados.url)
445
+
446
+
447
+ # ---------------------------------------------------------------------------
448
+ # UI estática (modo npx / produção)
449
+ # ---------------------------------------------------------------------------
450
+
451
+ if FRONTEND_DIST.exists():
452
+ app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"), name="assets")
453
+
454
+ @app.get("/", include_in_schema=False)
455
+ def index():
456
+ return FileResponse(FRONTEND_DIST / "index.html")
457
+
458
+ @app.get("/{full_path:path}", include_in_schema=False)
459
+ def spa(full_path: str):
460
+ # Rotas de API inexistentes devolvem 404 JSON (não a página do SPA).
461
+ if full_path.startswith("api/") or full_path == "api":
462
+ raise HTTPException(404, f"Rota /{full_path} não existe.")
463
+ alvo = FRONTEND_DIST / full_path
464
+ if full_path and alvo.is_file():
465
+ return FileResponse(alvo)
466
+ return FileResponse(FRONTEND_DIST / "index.html")