@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.
@@ -0,0 +1,440 @@
1
+ """Persistência 100% local em arquivos JSON.
2
+
3
+ Toda a escrita é atômica (arquivo temporário + replace) e protegida por um lock
4
+ global, para permitir o uso concorrente pelas rotas da API e pelos workers em
5
+ background (campanhas de disparo).
6
+ """
7
+
8
+ import json
9
+ import os
10
+ import threading
11
+ from datetime import datetime
12
+ from pathlib import Path
13
+
14
+ LOCK = threading.RLock()
15
+
16
+ # Campos que nunca podem vazar para o frontend.
17
+ SECRET_KEYS = {"api_key", "token", "senha", "password"}
18
+
19
+ LEADS_FILE = "leads.json"
20
+ CAMPAIGNS_FILE = "campaigns.json"
21
+ PIPELINE_FILE = "pipeline.json"
22
+ CONFIG_FILE = "config.json"
23
+
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # Caminhos
27
+ # ---------------------------------------------------------------------------
28
+
29
+ def _repo_root() -> Path:
30
+ """Raiz do repositório (pasta que contém backend/). storage.py está em
31
+ backend/services/, então sobe três níveis."""
32
+ return Path(__file__).resolve().parent.parent.parent
33
+
34
+
35
+ def get_home() -> Path:
36
+ """Pasta de dados (home). Ordem: env LEADMASTER_HOME → raiz do repo."""
37
+ env = os.environ.get("LEADMASTER_HOME")
38
+ if env:
39
+ return Path(env).resolve()
40
+ return _repo_root()
41
+
42
+
43
+ def storage_dir() -> Path:
44
+ d = get_home() / "storage"
45
+ d.mkdir(parents=True, exist_ok=True)
46
+ return d
47
+
48
+
49
+ def now_iso() -> str:
50
+ return datetime.now().astimezone().isoformat(timespec="seconds")
51
+
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # Leitura/escrita atômica
55
+ # ---------------------------------------------------------------------------
56
+
57
+ def _read(name: str, default):
58
+ with LOCK:
59
+ path = storage_dir() / name
60
+ if not path.exists():
61
+ return default
62
+ try:
63
+ # utf-8-sig: tolera BOM (comum em arquivos editados no Windows
64
+ # por PowerShell/Notepad) e é idêntico a utf-8 quando não há BOM.
65
+ return json.loads(path.read_text(encoding="utf-8-sig"))
66
+ except json.JSONDecodeError:
67
+ # Arquivo corrompido: preserva uma cópia para inspeção manual
68
+ # e devolve o padrão para não travar a aplicação.
69
+ try:
70
+ path.rename(path.with_suffix(path.suffix + ".corrompido"))
71
+ except OSError:
72
+ pass
73
+ return default
74
+
75
+
76
+ def _write(name: str, data) -> None:
77
+ with LOCK:
78
+ path = storage_dir() / name
79
+ tmp = path.with_name(path.name + ".tmp")
80
+ tmp.write_text(
81
+ json.dumps(data, ensure_ascii=False, indent=2),
82
+ encoding="utf-8",
83
+ )
84
+ tmp.replace(path)
85
+
86
+
87
+ # ---------------------------------------------------------------------------
88
+ # Configuração
89
+ # ---------------------------------------------------------------------------
90
+
91
+ DEFAULT_CONFIG = {
92
+ "perfil": {
93
+ "nome": "Dan",
94
+ "licenca": {
95
+ "ativa": True,
96
+ "expira_em": "31/12/2099 23:59",
97
+ "restam_dias": None,
98
+ },
99
+ },
100
+ "llm": {
101
+ "provedor": "openai-compat",
102
+ "base_url": "https://api.openai.com/v1",
103
+ "api_key": "",
104
+ "modelo": "gpt-4o-mini",
105
+ },
106
+ "integracoes": {
107
+ "openoutreach": {"url": "", "enabled": False, "send_path": "/api/send"},
108
+ "pipecat": {"url": ""},
109
+ },
110
+ "extratores": {
111
+ "google_maps_cli": "",
112
+ "google_maps_args": (
113
+ '--kw "{palavra_chave}" --loc "{localizacao}" --lang pt --email --max {max_resultados} --outdir "{outdir}"'
114
+ ),
115
+ "igmapper_cli": "",
116
+ "igmapper_args": "--profile {url} --outdir {outdir}",
117
+ "scrapling_cli": "",
118
+ "cnpj_provedor_busca": "https://api.opencnpj.org",
119
+ },
120
+ "agentes": {
121
+ "disparo": {
122
+ "templates": {
123
+ "texto": (
124
+ "Olá {{primeiro_nome}}! Sou da {{minha_empresa}} e ajudo empresas como "
125
+ "{{empresa}} a crescerem com prospecção automatizada. "
126
+ "Posso te enviar uma proposta rápida?"
127
+ ),
128
+ "audio": "Olá {{primeiro_nome}}, tudo bem? Falo em nome da {{minha_empresa}}...",
129
+ },
130
+ "minha_empresa": "LeadMaster",
131
+ "delay_entre_envios_segundos": 3,
132
+ },
133
+ "atendimento": {"modo": "texto"},
134
+ },
135
+ "webhook": {
136
+ "url": "",
137
+ "eventos": ["lead.criado", "lead.etapa", "campanha.concluida"],
138
+ },
139
+ }
140
+
141
+ # Chaves de versões antigas (o pipeline de vídeos foi retirado do produto) —
142
+ # são removidas automaticamente da configuração local na primeira leitura.
143
+ _CAMINHOS_REMOVIDOS = [
144
+ ("integracoes", "moneyprinter"),
145
+ ("integracoes", "short_video_maker"),
146
+ ("integracoes", "openmontage"),
147
+ ("integracoes", "youtube_upload"),
148
+ ("uploads",),
149
+ ]
150
+
151
+
152
+ def _deep_merge(base: dict, override: dict) -> dict:
153
+ out = dict(base)
154
+ for key, value in override.items():
155
+ if isinstance(value, dict) and isinstance(out.get(key), dict):
156
+ out[key] = _deep_merge(out[key], value)
157
+ else:
158
+ out[key] = value
159
+ return out
160
+
161
+
162
+ def _deep_merge_preserve_secrets(base: dict, override: dict) -> dict:
163
+ """Deep merge que ignora valores vazios em campos de segredo (manter atual)."""
164
+ out = dict(base)
165
+ for key, value in override.items():
166
+ if key in SECRET_KEYS and (value == "" or value is None) and key in out:
167
+ continue
168
+ if isinstance(value, dict) and isinstance(out.get(key), dict):
169
+ out[key] = _deep_merge_preserve_secrets(out[key], value)
170
+ else:
171
+ out[key] = value
172
+ return out
173
+
174
+
175
+ def _limpar_deprecated(config: dict) -> dict:
176
+ """Migração: remove chaves de versões antigas (pipeline de vídeos)."""
177
+ mudou = False
178
+ for caminho in _CAMINHOS_REMOVIDOS:
179
+ secao = config
180
+ for chave in caminho[:-1]:
181
+ secao = secao.get(chave) if isinstance(secao, dict) else None
182
+ if not isinstance(secao, dict):
183
+ break
184
+ else:
185
+ ultimo = caminho[-1]
186
+ if isinstance(secao, dict) and ultimo in secao:
187
+ secao.pop(ultimo)
188
+ mudou = True
189
+ # Evento de webhook do recurso removido.
190
+ webhook = config.get("webhook")
191
+ if isinstance(webhook, dict) and isinstance(webhook.get("eventos"), list):
192
+ eventos = [e for e in webhook["eventos"] if e != "video.publicado"]
193
+ if eventos != webhook["eventos"]:
194
+ webhook["eventos"] = eventos
195
+ mudou = True
196
+ # Dados órfãos do pipeline de vídeos: o arquivo só existia para esse recurso.
197
+ pipeline_path = storage_dir() / PIPELINE_FILE
198
+ if pipeline_path.exists():
199
+ try:
200
+ pipeline_path.unlink()
201
+ except OSError:
202
+ pass
203
+ if mudou:
204
+ _write(CONFIG_FILE, config)
205
+ return config
206
+
207
+
208
+ def get_config() -> dict:
209
+ return _limpar_deprecated(_deep_merge(DEFAULT_CONFIG, _read(CONFIG_FILE, {})))
210
+
211
+
212
+ def update_config(patch: dict) -> dict:
213
+ with LOCK:
214
+ atual = get_config()
215
+ novo = _deep_merge_preserve_secrets(atual, patch)
216
+ _write(CONFIG_FILE, novo)
217
+ return novo
218
+
219
+
220
+ def redact_config(config: dict) -> dict:
221
+ """Copia a config substituindo segredos por vazio + flag `_configurado`."""
222
+ out = {}
223
+ for key, value in config.items():
224
+ if isinstance(value, dict):
225
+ out[key] = redact_config(value)
226
+ elif key in SECRET_KEYS:
227
+ out[key] = ""
228
+ out[f"{key}_configurado"] = bool(value)
229
+ else:
230
+ out[key] = value
231
+ return out
232
+
233
+
234
+ # ---------------------------------------------------------------------------
235
+ # Leads
236
+ # ---------------------------------------------------------------------------
237
+
238
+ def list_leads() -> list:
239
+ return _read(LEADS_FILE, [])
240
+
241
+
242
+ def save_leads(leads: list) -> None:
243
+ _write(LEADS_FILE, leads)
244
+
245
+
246
+ def add_lead(lead: dict) -> dict:
247
+ with LOCK:
248
+ leads = list_leads()
249
+ lead["criado_em"] = lead.get("criado_em") or now_iso()
250
+ lead["atualizado_em"] = now_iso()
251
+ leads.append(lead)
252
+ save_leads(leads)
253
+ return lead
254
+
255
+
256
+ def update_lead(lead_id: str, patch: dict) -> dict | None:
257
+ with LOCK:
258
+ leads = list_leads()
259
+ for lead in leads:
260
+ if lead.get("id") == lead_id:
261
+ lead.update(patch)
262
+ lead["atualizado_em"] = now_iso()
263
+ save_leads(leads)
264
+ return lead
265
+ return None
266
+
267
+
268
+ def delete_lead(lead_id: str) -> bool:
269
+ with LOCK:
270
+ leads = list_leads()
271
+ restantes = [l for l in leads if l.get("id") != lead_id]
272
+ if len(restantes) == len(leads):
273
+ return False
274
+ save_leads(restantes)
275
+ return True
276
+
277
+
278
+ def import_leads(novos: list, dedup: bool = True) -> dict:
279
+ """Importa leads em massa; retorna {importados, duplicados}."""
280
+ with LOCK:
281
+ leads = list_leads()
282
+ existentes = {
283
+ (l.get("telefone") or "").strip(),
284
+ (l.get("email") or "").strip().lower(),
285
+ }
286
+ importados, duplicados = [], []
287
+ for novo in novos:
288
+ tel = (novo.get("telefone") or "").strip()
289
+ email = (novo.get("email") or "").strip().lower()
290
+ if dedup and ((tel and tel in existentes) or (email and email in existentes)):
291
+ duplicados.append(novo)
292
+ continue
293
+ novo.setdefault("id", _novo_id())
294
+ novo["criado_em"] = now_iso()
295
+ novo["atualizado_em"] = now_iso()
296
+ leads.append(novo)
297
+ existentes.add(tel)
298
+ existentes.add(email)
299
+ importados.append(novo)
300
+ save_leads(leads)
301
+ return {"importados": importados, "duplicados": duplicados}
302
+
303
+
304
+ def _novo_id() -> str:
305
+ import uuid
306
+
307
+ return uuid.uuid4().hex[:12]
308
+
309
+
310
+ # ---------------------------------------------------------------------------
311
+ # Campanhas
312
+ # ---------------------------------------------------------------------------
313
+
314
+ def list_campaigns() -> list:
315
+ return _read(CAMPAIGNS_FILE, [])
316
+
317
+
318
+ def save_campaigns(campaigns: list) -> None:
319
+ _write(CAMPAIGNS_FILE, campaigns)
320
+
321
+
322
+ def upsert_campaign(campaign: dict) -> dict:
323
+ with LOCK:
324
+ campaigns = list_campaigns()
325
+ for i, c in enumerate(campaigns):
326
+ if c.get("id") == campaign["id"]:
327
+ campaigns[i] = campaign
328
+ break
329
+ else:
330
+ campaigns.append(campaign)
331
+ save_campaigns(campaigns)
332
+ return campaign
333
+
334
+
335
+ def get_campaign(campaign_id: str) -> dict | None:
336
+ for c in list_campaigns():
337
+ if c.get("id") == campaign_id:
338
+ return c
339
+ return None
340
+
341
+
342
+ def delete_campaign(campaign_id: str) -> bool:
343
+ with LOCK:
344
+ campaigns = list_campaigns()
345
+ restantes = [c for c in campaigns if c.get("id") != campaign_id]
346
+ if len(restantes) == len(campaigns):
347
+ return False
348
+ save_campaigns(restantes)
349
+ return True
350
+
351
+
352
+ # ---------------------------------------------------------------------------
353
+ # Seeds (primeira execução)
354
+ # ---------------------------------------------------------------------------
355
+
356
+ def _seed_leads() -> list:
357
+ """Leads de demonstração para o painel nascer com vida."""
358
+ modelos = [
359
+ ("Construtora Horizonte Ltda", "(11) 3344-5566", "contato@horizonte.com.br", "CNPJ", "leads", "Construtora Horizonte", "São Paulo/SP", 62),
360
+ ("Clínica Bem Viver", "(11) 98877-1122", "contato@bemviver.com.br", "Google Maps", "leads", "Clínica Bem Viver", "Campinas/SP", 48),
361
+ ("Padaria do Largo", "(21) 2233-4455", "", "Google Maps", "leads", "Padaria do Largo", "Rio de Janeiro/RJ", 25),
362
+ ("Academia Iron Fit", "(31) 99876-5544", "atendimento@ironfit.com.br", "Instagram", "leads", "Iron Fit", "Belo Horizonte/MG", 55),
363
+ ("Escritório Delta Contábil", "(41) 3322-7788", "delta@deltacontabil.com.br", "CNPJ", "em_contato", "Delta Contábil", "Curitiba/PR", 74),
364
+ ("Restaurante Sabor Caseiro", "(85) 98811-2233", "saborcaseiro@gmail.com", "Google Maps", "em_contato", "Sabor Caseiro", "Fortaleza/CE", 68),
365
+ ("Ótica Visão Clara", "(51) 3445-6677", "contato@visaoclara.com.br", "Instagram", "proposta", "Visão Clara", "Porto Alegre/RS", 88),
366
+ ("Mercado Central Atacado", "(62) 99555-1010", "compras@centralatacado.com.br", "CNPJ", "fechado", "Central Atacado", "Goiânia/GO", 95),
367
+ ]
368
+ leads = []
369
+ for nome, telefone, email, fonte, etapa, empresa, cidade, score in modelos:
370
+ leads.append(
371
+ {
372
+ "id": _novo_id(),
373
+ "nome": nome,
374
+ "telefone": telefone,
375
+ "email": email,
376
+ "fonte": fonte,
377
+ "etapa": etapa,
378
+ "empresa": empresa,
379
+ "cidade": cidade,
380
+ "score": score,
381
+ "tags": [],
382
+ "observacoes": "",
383
+ "criado_em": now_iso(),
384
+ "atualizado_em": now_iso(),
385
+ }
386
+ )
387
+ return leads
388
+
389
+
390
+ def _seed_campaigns() -> list:
391
+ return [
392
+ {
393
+ "id": _novo_id(),
394
+ "nome": "Reativação Q4 (demonstração)",
395
+ "canal": "texto",
396
+ "template": "texto",
397
+ "lead_ids": [],
398
+ "status": "concluida",
399
+ "total": 50,
400
+ "enviadas": 47,
401
+ "erros": 3,
402
+ "criado_em": now_iso(),
403
+ "iniciada_em": now_iso(),
404
+ "concluida_em": now_iso(),
405
+ "eventos": [],
406
+ },
407
+ {
408
+ "id": _novo_id(),
409
+ "nome": "Boas-vindas Novos Leads (demonstração)",
410
+ "canal": "texto",
411
+ "template": "texto",
412
+ "lead_ids": [],
413
+ "status": "rascunho",
414
+ "total": 0,
415
+ "enviadas": 0,
416
+ "erros": 0,
417
+ "criado_em": now_iso(),
418
+ "eventos": [],
419
+ },
420
+ ]
421
+
422
+
423
+ def ensure_seeds() -> None:
424
+ """Cria os arquivos de dados com seeds na primeira execução."""
425
+ with LOCK:
426
+ d = storage_dir()
427
+ if not (d / LEADS_FILE).exists():
428
+ _write(LEADS_FILE, _seed_leads())
429
+ if not (d / CAMPAIGNS_FILE).exists():
430
+ _write(CAMPAIGNS_FILE, _seed_campaigns())
431
+ if not (d / CONFIG_FILE).exists():
432
+ _write(CONFIG_FILE, DEFAULT_CONFIG)
433
+
434
+
435
+ def limpar_seeds_demo() -> None:
436
+ """Remove os dados de demonstração (usado quando o usuário pede)."""
437
+ with LOCK:
438
+ save_leads([])
439
+ save_campaigns([])
440
+ save_jobs([])