@danhachuel/leadmaster 0.0.0-stage → 0.1.0

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.
@@ -0,0 +1,523 @@
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, moneyprinter, pipeline, short_video_maker, storage, youtube_upload
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
+ engine = pipeline.PipelineEngine()
39
+
40
+
41
+ @asynccontextmanager
42
+ async def lifespan(_app: FastAPI):
43
+ storage.ensure_seeds()
44
+ engine.recuperar()
45
+ engine.iniciar_agendador()
46
+ yield
47
+
48
+
49
+ app = FastAPI(title="LeadMaster API", version=VERSION, lifespan=lifespan)
50
+
51
+ app.add_middleware(
52
+ CORSMiddleware,
53
+ # A UI local (npx) roda same-origin; em modo dev o Vite sobe em 5055 e pode
54
+ # auto-incrementar (5056, 5057...) quando a porta está ocupada — por isso
55
+ # aceitamos qualquer porta em localhost/127.0.0.1.
56
+ allow_origin_regex=r"https?://(localhost|127\.0\.0\.1)(:\d+)?",
57
+ allow_methods=["*"],
58
+ allow_headers=["*"],
59
+ )
60
+
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # Models
64
+ # ---------------------------------------------------------------------------
65
+
66
+ class LeadIn(BaseModel):
67
+ nome: str = Field(min_length=1)
68
+ telefone: str = ""
69
+ email: str = ""
70
+ fonte: str = "Manual"
71
+ etapa: str = "leads"
72
+ cnpj: str = ""
73
+ empresa: str = ""
74
+ endereco: str = ""
75
+ cidade: str = ""
76
+ observacoes: str = ""
77
+ score: int | None = None
78
+ tags: list[str] = []
79
+
80
+
81
+ class LeadsImportIn(BaseModel):
82
+ leads: list[dict]
83
+
84
+
85
+ class CampaignIn(BaseModel):
86
+ nome: str = Field(min_length=1)
87
+ canal: str = "texto"
88
+ template: str = "texto"
89
+ lead_ids: list[str] = []
90
+
91
+
92
+ class JobIn(BaseModel):
93
+ tema: str = Field(min_length=1)
94
+ titulo: str = ""
95
+ tom: str = "informativo"
96
+ duracao_min: int = 8
97
+ keyword_seo: str = ""
98
+ formatos: list[str] = ["wide", "shorts"]
99
+
100
+
101
+ class AcaoIn(BaseModel):
102
+ acao: str
103
+
104
+
105
+ class GoogleMapsIn(BaseModel):
106
+ palavra_chave: str = Field(min_length=2)
107
+ localizacao: str = Field(min_length=2)
108
+ max_resultados: int = 100
109
+
110
+
111
+ class InstagramIn(BaseModel):
112
+ profile_url: str = Field(min_length=6)
113
+
114
+
115
+ class CnpjIn(BaseModel):
116
+ cnpj: str = Field(min_length=8)
117
+
118
+
119
+ class CnpjSearchIn(BaseModel):
120
+ cnae: str = Field(min_length=2)
121
+ uf: str = Field(min_length=2)
122
+ quantidade: int = 20
123
+
124
+
125
+ class ChatIn(BaseModel):
126
+ mensagens: list[dict]
127
+
128
+
129
+ class TemplateTestIn(BaseModel):
130
+ template: str
131
+ lead_id: str | None = None
132
+
133
+
134
+ class WebhookTestIn(BaseModel):
135
+ url: str = Field(min_length=8)
136
+
137
+
138
+ class IntegracaoTestIn(BaseModel):
139
+ url: str
140
+
141
+
142
+ # ---------------------------------------------------------------------------
143
+ # Sistema
144
+ # ---------------------------------------------------------------------------
145
+
146
+ @app.get("/api/health")
147
+ def health():
148
+ return {
149
+ "status": "ok",
150
+ "versao": VERSION,
151
+ "home": str(storage.get_home()),
152
+ "storage": str(storage.storage_dir()),
153
+ }
154
+
155
+
156
+ @app.get("/api/doctor")
157
+ def doctor():
158
+ integracoes = {
159
+ "moneyprinter": moneyprinter.verificar(),
160
+ "short_video_maker": short_video_maker.verificar(),
161
+ "uploader_youtube": youtube_upload.verificar(),
162
+ "llm_configurado": llm.configurado(),
163
+ "openmontage": bool(storage.get_config()["integracoes"]["openmontage"].get("cli_path")),
164
+ "openoutreach": agents.disparo_configurado(),
165
+ }
166
+ return {
167
+ "ok": True,
168
+ "versao": VERSION,
169
+ "python": platform.python_version(),
170
+ "sistema": platform.platform(),
171
+ "dist_frontend": FRONTEND_DIST.exists(),
172
+ "storage": str(storage.storage_dir()),
173
+ "uploads_hoje": youtube_upload.contagem_hoje(),
174
+ "integracoes": integracoes,
175
+ }
176
+
177
+
178
+ @app.post("/api/shutdown")
179
+ def shutdown():
180
+ threading.Timer(0.6, os._exit, [0]).start()
181
+ return {"ok": True, "mensagem": "Servidor local encerrando em instantes."}
182
+
183
+
184
+ # ---------------------------------------------------------------------------
185
+ # Métricas (Painel)
186
+ # ---------------------------------------------------------------------------
187
+
188
+ @app.get("/api/metrics")
189
+ def metrics():
190
+ leads = storage.list_leads()
191
+ campaigns = storage.list_campaigns()
192
+ enviadas = sum(int(c.get("enviadas", 0)) for c in campaigns)
193
+ erros = sum(int(c.get("erros", 0)) for c in campaigns)
194
+ total_envios = enviadas + erros
195
+ jobs = storage.list_jobs()
196
+
197
+ return {
198
+ "conexao": {
199
+ "geral": True, # o backend local responde => conectado
200
+ "moneyprinter": moneyprinter.verificar(),
201
+ "short_video_maker": short_video_maker.verificar(),
202
+ "uploader_youtube": youtube_upload.verificar(),
203
+ "llm": llm.configurado(),
204
+ "openoutreach": agents.disparo_configurado(),
205
+ },
206
+ "taxa_entrega": round(enviadas / total_envios * 100, 1) if total_envios else 100.0,
207
+ "taxa_erro": round(erros / total_envios * 100, 1) if total_envios else 0.0,
208
+ "total_enviadas": enviadas,
209
+ "total_erros": erros,
210
+ "leads_total": len(leads),
211
+ "leads_por_etapa": {
212
+ etapa: sum(1 for l in leads if l.get("etapa") == etapa)
213
+ for etapa in ("leads", "em_contato", "proposta", "fechado")
214
+ },
215
+ "campanhas": {"enviadas": enviadas, "erros": erros, "total": len(campaigns)},
216
+ "pipeline": {
217
+ "ativos": sum(1 for j in jobs if j.get("status") in ("em_andamento", "pausado")),
218
+ "concluidos": sum(1 for j in jobs if j.get("status") == "concluido"),
219
+ },
220
+ "perfil": storage.get_config().get("perfil", {}),
221
+ }
222
+
223
+
224
+ # ---------------------------------------------------------------------------
225
+ # Leads
226
+ # ---------------------------------------------------------------------------
227
+
228
+ def _validar_etapa(etapa: str) -> str:
229
+ if etapa not in ("leads", "em_contato", "proposta", "fechado"):
230
+ raise HTTPException(400, "Etapa inválida. Use: leads, em_contato, proposta ou fechado.")
231
+ return etapa
232
+
233
+
234
+ @app.get("/api/leads")
235
+ def listar_leads():
236
+ return storage.list_leads()
237
+
238
+
239
+ @app.post("/api/leads", status_code=201)
240
+ def criar_lead(lead_in: LeadIn):
241
+ lead = lead_in.model_dump()
242
+ lead["id"] = storage._novo_id()
243
+ lead["etapa"] = _validar_etapa(lead.get("etapa", "leads"))
244
+ lead["score"] = lead.get("score") if lead.get("score") is not None else crm_agent.calcular_completude(lead)
245
+ storage.add_lead(lead)
246
+ agents.disparar_webhook("lead.criado", {"lead_id": lead["id"], "nome": lead["nome"]})
247
+ return lead
248
+
249
+
250
+ @app.patch("/api/leads/{lead_id}")
251
+ def atualizar_lead(lead_id: str, patch: dict):
252
+ if patch.get("etapa"):
253
+ _validar_etapa(patch["etapa"])
254
+ atualizado = storage.update_lead(lead_id, patch)
255
+ if not atualizado:
256
+ raise HTTPException(404, "Lead não encontrado.")
257
+ if patch.get("etapa"):
258
+ agents.disparar_webhook("lead.etapa", {"lead_id": lead_id, "etapa": patch["etapa"]})
259
+ return atualizado
260
+
261
+
262
+ @app.delete("/api/leads/{lead_id}")
263
+ def remover_lead(lead_id: str):
264
+ if not storage.delete_lead(lead_id):
265
+ raise HTTPException(404, "Lead não encontrado.")
266
+ return {"ok": True}
267
+
268
+
269
+ @app.post("/api/leads/import")
270
+ def importar_leads(dados: LeadsImportIn):
271
+ for lead in dados.leads:
272
+ lead["etapa"] = _validar_etapa(lead.get("etapa", "leads"))
273
+ return storage.import_leads(dados.leads)
274
+
275
+
276
+ @app.post("/api/leads/{lead_id}/enrich")
277
+ def enriquecer_lead(lead_id: str):
278
+ resultado = crm_agent.enriquecer_lead(lead_id)
279
+ if not resultado.get("ok"):
280
+ raise HTTPException(404, resultado.get("mensagem", "Lead não encontrado."))
281
+ return resultado
282
+
283
+
284
+ # ---------------------------------------------------------------------------
285
+ # CRM (agente)
286
+ # ---------------------------------------------------------------------------
287
+
288
+ @app.get("/api/crm/sugestoes")
289
+ def sugestoes_crm():
290
+ return crm_agent.sugerir_movimentacoes()
291
+
292
+
293
+ @app.post("/api/crm/sugestoes/aplicar")
294
+ def aplicar_sugestao(dados: dict):
295
+ lead_id, para = dados.get("lead_id"), dados.get("para")
296
+ if not lead_id or not para:
297
+ raise HTTPException(400, "Informe lead_id e para.")
298
+ atualizado = storage.update_lead(lead_id, {"etapa": _validar_etapa(para)})
299
+ if not atualizado:
300
+ raise HTTPException(404, "Lead não encontrado.")
301
+ agents.disparar_webhook("lead.etapa", {"lead_id": lead_id, "etapa": para, "via": "agente_crm"})
302
+ return {"ok": True, "lead": atualizado}
303
+
304
+
305
+ # ---------------------------------------------------------------------------
306
+ # Campanhas / Disparos
307
+ # ---------------------------------------------------------------------------
308
+
309
+ @app.get("/api/campaigns")
310
+ def listar_campanhas():
311
+ return storage.list_campaigns()
312
+
313
+
314
+ @app.post("/api/campaigns", status_code=201)
315
+ def criar_campanha(campaign_in: CampaignIn):
316
+ campaign = campaign_in.model_dump()
317
+ campaign["id"] = storage._novo_id()
318
+ campaign.update({"status": "rascunho", "total": len(campaign["lead_ids"]), "enviadas": 0, "erros": 0, "eventos": [], "criado_em": storage.now_iso()})
319
+ storage.upsert_campaign(campaign)
320
+ return campaign
321
+
322
+
323
+ @app.post("/api/campaigns/{campaign_id}/start")
324
+ def iniciar_campanha(campaign_id: str):
325
+ resultado = agents.iniciar_campanha(campaign_id)
326
+ if not resultado.get("ok"):
327
+ raise HTTPException(400, resultado["mensagem"])
328
+ return resultado
329
+
330
+
331
+ @app.post("/api/campaigns/{campaign_id}/pause")
332
+ def pausar_campanha(campaign_id: str):
333
+ return agents.pausar_campanha(campaign_id)
334
+
335
+
336
+ @app.post("/api/campaigns/{campaign_id}/stop")
337
+ def parar_campanha(campaign_id: str):
338
+ return agents.parar_campanha(campaign_id)
339
+
340
+
341
+ @app.delete("/api/campaigns/{campaign_id}")
342
+ def remover_campanha(campaign_id: str):
343
+ if not storage.delete_campaign(campaign_id):
344
+ raise HTTPException(404, "Campanha não encontrada.")
345
+ return {"ok": True}
346
+
347
+
348
+ # ---------------------------------------------------------------------------
349
+ # Pipeline de vídeos
350
+ # ---------------------------------------------------------------------------
351
+
352
+ @app.get("/api/pipeline")
353
+ def listar_pipeline():
354
+ return engine.listar()
355
+
356
+
357
+ @app.post("/api/pipeline", status_code=201)
358
+ def criar_job(job_in: JobIn):
359
+ try:
360
+ return engine.criar_job(job_in.model_dump())
361
+ except ValueError as e:
362
+ raise HTTPException(400, str(e))
363
+
364
+
365
+ @app.post("/api/pipeline/{job_id}/actions")
366
+ def agir_pipeline(job_id: str, acao_in: AcaoIn):
367
+ try:
368
+ resultado = engine.agir(job_id, acao_in.acao)
369
+ except ValueError as e:
370
+ raise HTTPException(400, str(e))
371
+ if not resultado.get("ok"):
372
+ raise HTTPException(400, resultado.get("mensagem", "Ação não permitida."))
373
+ return resultado
374
+
375
+
376
+ # ---------------------------------------------------------------------------
377
+ # Extratores
378
+ # ---------------------------------------------------------------------------
379
+
380
+ @app.post("/api/extract/google-maps")
381
+ def extrair_google_maps(dados: GoogleMapsIn):
382
+ try:
383
+ return extractors.extrair_google_maps(dados.palavra_chave, dados.localizacao, dados.max_resultados)
384
+ except extractors.ExtratorNaoConfigurado as e:
385
+ raise HTTPException(400, str(e))
386
+ except extractors.ExtratorErro as e:
387
+ raise HTTPException(502, str(e))
388
+
389
+
390
+ @app.post("/api/extract/instagram")
391
+ def extrair_instagram(dados: InstagramIn):
392
+ try:
393
+ return extractors.extrair_instagram(dados.profile_url)
394
+ except extractors.ExtratorNaoConfigurado as e:
395
+ raise HTTPException(400, str(e))
396
+ except extractors.ExtratorErro as e:
397
+ raise HTTPException(502, str(e))
398
+
399
+
400
+ @app.post("/api/extract/cnpj")
401
+ def consultar_cnpj(dados: CnpjIn):
402
+ try:
403
+ return extractors.consulta_cnpj(dados.cnpj)
404
+ except extractors.ExtratorErro as e:
405
+ raise HTTPException(502, str(e))
406
+
407
+
408
+ @app.post("/api/extract/cnpj/search")
409
+ def buscar_cnpj(dados: CnpjSearchIn):
410
+ try:
411
+ return extractors.buscar_cnpj_cnae(dados.cnae, dados.uf, dados.quantidade)
412
+ except extractors.ExtratorErro as e:
413
+ raise HTTPException(502, str(e))
414
+
415
+
416
+ # ---------------------------------------------------------------------------
417
+ # Configuração e integrações
418
+ # ---------------------------------------------------------------------------
419
+
420
+ @app.get("/api/config")
421
+ def obter_config():
422
+ return storage.redact_config(storage.get_config())
423
+
424
+
425
+ @app.put("/api/config")
426
+ def salvar_config(patch: dict):
427
+ storage.update_config(patch)
428
+ return storage.redact_config(storage.get_config())
429
+
430
+
431
+ @app.post("/api/config/test/llm")
432
+ def testar_llm():
433
+ return llm.testar_conexao()
434
+
435
+
436
+ @app.post("/api/config/test/integracao")
437
+ def testar_integracao(dados: IntegracaoTestIn):
438
+ import httpx
439
+
440
+ try:
441
+ r = httpx.get(dados.url, timeout=4)
442
+ return {"ok": True, "status_http": r.status_code}
443
+ except httpx.HTTPError as e:
444
+ return {"ok": False, "mensagem": f"Sem resposta de {dados.url}: {e}"}
445
+
446
+
447
+ @app.get("/api/integrations/status")
448
+ def status_integracoes():
449
+ return {
450
+ "moneyprinter": moneyprinter.verificar(),
451
+ "short_video_maker": short_video_maker.verificar(),
452
+ "uploader_youtube": youtube_upload.verificar(),
453
+ "llm": llm.configurado(),
454
+ "openmontage": bool(storage.get_config()["integracoes"]["openmontage"].get("cli_path")),
455
+ "openoutreach": agents.disparo_configurado(),
456
+ "pipecat": bool(storage.get_config()["integracoes"]["pipecat"].get("url")),
457
+ }
458
+
459
+
460
+ # ---------------------------------------------------------------------------
461
+ # Agentes
462
+ # ---------------------------------------------------------------------------
463
+
464
+ @app.get("/api/agents/templates")
465
+ def obter_templates():
466
+ return agents.obter_templates()
467
+
468
+
469
+ @app.put("/api/agents/templates")
470
+ def salvar_templates(patch: dict):
471
+ return agents.salvar_templates(patch)
472
+
473
+
474
+ @app.post("/api/agents/templates/test")
475
+ def testar_template(dados: TemplateTestIn):
476
+ return agents.testar_template(dados.template, dados.lead_id)
477
+
478
+
479
+ @app.post("/api/agents/attendance/chat")
480
+ def atendimento_chat(dados: ChatIn):
481
+ return agents.atendimento_chat(dados.mensagens)
482
+
483
+
484
+ # ---------------------------------------------------------------------------
485
+ # Webhook
486
+ # ---------------------------------------------------------------------------
487
+
488
+ @app.get("/api/webhook")
489
+ def obter_webhook():
490
+ return storage.get_config().get("webhook", {})
491
+
492
+
493
+ @app.put("/api/webhook")
494
+ def salvar_webhook(patch: dict):
495
+ config = storage.get_config()
496
+ webhook = config.get("webhook", {})
497
+ webhook.update({k: v for k, v in patch.items() if k in ("url", "eventos")})
498
+ storage.update_config({"webhook": webhook})
499
+ return webhook
500
+
501
+
502
+ @app.post("/api/webhook/test")
503
+ def testar_webhook(dados: WebhookTestIn):
504
+ return agents.testar_webhook(dados.url)
505
+
506
+
507
+ # ---------------------------------------------------------------------------
508
+ # UI estática (modo npx / produção)
509
+ # ---------------------------------------------------------------------------
510
+
511
+ if FRONTEND_DIST.exists():
512
+ app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"), name="assets")
513
+
514
+ @app.get("/", include_in_schema=False)
515
+ def index():
516
+ return FileResponse(FRONTEND_DIST / "index.html")
517
+
518
+ @app.get("/{full_path:path}", include_in_schema=False)
519
+ def spa(full_path: str):
520
+ alvo = FRONTEND_DIST / full_path
521
+ if full_path and alvo.is_file():
522
+ return FileResponse(alvo)
523
+ return FileResponse(FRONTEND_DIST / "index.html")
@@ -0,0 +1,4 @@
1
+ fastapi>=0.115,<1.0
2
+ uvicorn[standard]>=0.30,<1.0
3
+ httpx>=0.27,<1.0
4
+ pydantic>=2.7,<3.0
@@ -0,0 +1 @@
1
+ # Serviços e regras de negócio do LeadMaster.