ViniciusKhan commited on
Commit
cf487cf
·
1 Parent(s): 9b19962

API robusta + Transformers + front + fix Turso

Browse files
.env.example CHANGED
@@ -74,12 +74,26 @@ ENABLE_AI_INTERPRETATION=true
74
  REPORT_TIMEZONE=America/Sao_Paulo
75
  REPORT_RECIPIENT_EMAIL=
76
 
77
- # ---------------------- SMTP (opcional) --------------------
 
 
78
  SMTP_HOST=
79
  SMTP_PORT=
80
  SMTP_USER=
81
  SMTP_PASSWORD=
82
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  # --------------------- ClickUp (opcional) ------------------
84
  CLICKUP_API_TOKEN=
85
  CLICKUP_LIST_ID=
 
74
  REPORT_TIMEZONE=America/Sao_Paulo
75
  REPORT_RECIPIENT_EMAIL=
76
 
77
+ # ---------------------- E-mail: SMTP (uso local) -----------
78
+ # Funciona localmente. Em Hugging Face Spaces o SMTP é BLOQUEADO — use a
79
+ # Gmail API (abaixo), que envia por HTTPS.
80
  SMTP_HOST=
81
  SMTP_PORT=
82
  SMTP_USER=
83
  SMTP_PASSWORD=
84
 
85
+ # ------------- E-mail: Gmail API (HTTP/OAuth) --------------
86
+ # Preferido em produção/online (passa por firewalls que bloqueiam SMTP).
87
+ # Como obter o refresh token: Google Cloud Console -> ativar "Gmail API" ->
88
+ # criar credencial OAuth -> no OAuth Playground (https://developers.google.com/oauthplayground)
89
+ # usar suas credenciais e autorizar o escopo
90
+ # https://www.googleapis.com/auth/gmail.send -> trocar pelo refresh token.
91
+ GMAIL_CLIENT_ID=
92
+ GMAIL_CLIENT_SECRET=
93
+ GMAIL_REFRESH_TOKEN=
94
+ # E-mail remetente (a conta autorizada no OAuth acima).
95
+ GMAIL_SENDER=
96
+
97
  # --------------------- ClickUp (opcional) ------------------
98
  CLICKUP_API_TOKEN=
99
  CLICKUP_LIST_ID=
scripts/test_gmail_oauth.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Testa o envio de e-mail via Gmail API (OAuth refresh token).
2
+
3
+ Valida as credenciais antes do deploy. Envia um e-mail curto de teste.
4
+
5
+ Uso:
6
+ python scripts/test_gmail_oauth.py
7
+
8
+ Variáveis necessárias no .env:
9
+ GMAIL_CLIENT_ID, GMAIL_CLIENT_SECRET, GMAIL_REFRESH_TOKEN,
10
+ GMAIL_SENDER (remetente autorizado), REPORT_RECIPIENT_EMAIL (destinatário).
11
+
12
+ Não imprime tokens nem segredos.
13
+ """
14
+
15
+ import base64
16
+ import os
17
+ from email.mime.text import MIMEText
18
+
19
+ import requests
20
+ from dotenv import load_dotenv
21
+
22
+ load_dotenv()
23
+
24
+ CLIENT_ID = os.getenv("GMAIL_CLIENT_ID", "")
25
+ CLIENT_SECRET = os.getenv("GMAIL_CLIENT_SECRET", "")
26
+ REFRESH_TOKEN = os.getenv("GMAIL_REFRESH_TOKEN", "")
27
+ SENDER = os.getenv("GMAIL_SENDER", "")
28
+ RECIPIENT = os.getenv("REPORT_RECIPIENT_EMAIL", "")
29
+
30
+
31
+ def validar() -> None:
32
+ """Confere se as variáveis obrigatórias estão preenchidas."""
33
+ faltando = [
34
+ nome
35
+ for nome, valor in {
36
+ "GMAIL_CLIENT_ID": CLIENT_ID,
37
+ "GMAIL_CLIENT_SECRET": CLIENT_SECRET,
38
+ "GMAIL_REFRESH_TOKEN": REFRESH_TOKEN,
39
+ "GMAIL_SENDER": SENDER,
40
+ "REPORT_RECIPIENT_EMAIL": RECIPIENT,
41
+ }.items()
42
+ if not valor
43
+ ]
44
+ if faltando:
45
+ raise SystemExit(f"Variáveis ausentes no .env: {', '.join(faltando)}")
46
+
47
+
48
+ def obter_access_token() -> str:
49
+ """Troca o refresh token por um access token (sem imprimir segredos)."""
50
+ resp = requests.post(
51
+ "https://oauth2.googleapis.com/token",
52
+ data={
53
+ "grant_type": "refresh_token",
54
+ "client_id": CLIENT_ID,
55
+ "client_secret": CLIENT_SECRET,
56
+ "refresh_token": REFRESH_TOKEN,
57
+ },
58
+ timeout=30,
59
+ )
60
+ if resp.status_code != 200:
61
+ try:
62
+ erro = resp.json()
63
+ detalhe = f"{erro.get('error')} - {erro.get('error_description', '')}"
64
+ except ValueError:
65
+ detalhe = "resposta inválida do servidor OAuth."
66
+ print("Falha no OAuth do Google. Status:", resp.status_code, "-", detalhe.strip(" -"))
67
+ raise SystemExit(1)
68
+ return resp.json()["access_token"]
69
+
70
+
71
+ def main() -> None:
72
+ """Executa o teste de envio."""
73
+ validar()
74
+ print("Obtendo access token do Google...")
75
+ token = obter_access_token()
76
+ print("Access token obtido com sucesso.")
77
+
78
+ mensagem = MIMEText(
79
+ "Teste de envio do Analytical-Force via Gmail API. Se você recebeu, está funcionando.",
80
+ "plain",
81
+ "utf-8",
82
+ )
83
+ mensagem["Subject"] = "Analytical-Force — Teste Gmail API"
84
+ mensagem["From"] = SENDER
85
+ mensagem["To"] = RECIPIENT
86
+ raw = base64.urlsafe_b64encode(mensagem.as_bytes()).decode("utf-8")
87
+
88
+ resp = requests.post(
89
+ "https://gmail.googleapis.com/gmail/v1/users/me/messages/send",
90
+ headers={"Authorization": f"Bearer {token}"},
91
+ json={"raw": raw},
92
+ timeout=30,
93
+ )
94
+ if resp.status_code >= 400:
95
+ print("Falha ao enviar. Status:", resp.status_code, "-", resp.text[:300])
96
+ raise SystemExit(1)
97
+ print(f"E-mail de teste enviado com sucesso para {RECIPIENT}.")
98
+
99
+
100
+ if __name__ == "__main__":
101
+ main()
src/config/settings.py CHANGED
@@ -251,12 +251,33 @@ class EmailSettings:
251
  smtp_user: str = ""
252
  smtp_password: str = ""
253
  recipient_email: str = ""
 
 
 
 
 
 
254
 
255
  @property
256
- def is_configured(self) -> bool:
257
- """Indica se há dados mínimos para enviar e-mail via SMTP."""
 
 
 
 
 
 
 
 
 
 
258
  return bool(self.smtp_host and self.smtp_port and self.recipient_email)
259
 
 
 
 
 
 
260
 
261
  @dataclass(frozen=True)
262
  class ClickUpSettings:
@@ -592,6 +613,10 @@ def get_settings() -> Settings:
592
  smtp_user=_get_str("SMTP_USER"),
593
  smtp_password=_get_str("SMTP_PASSWORD"),
594
  recipient_email=_get_str("REPORT_RECIPIENT_EMAIL"),
 
 
 
 
595
  )
596
 
597
  clickup = ClickUpSettings(
 
251
  smtp_user: str = ""
252
  smtp_password: str = ""
253
  recipient_email: str = ""
254
+ # Gmail API (HTTP/OAuth) — envia por HTTPS, funcionando onde o SMTP é
255
+ # bloqueado (ex.: Hugging Face Spaces). Preferido quando configurado.
256
+ gmail_client_id: str = ""
257
+ gmail_client_secret: str = ""
258
+ gmail_refresh_token: str = ""
259
+ gmail_sender: str = ""
260
 
261
  @property
262
+ def gmail_api_configured(self) -> bool:
263
+ """Indica se há credenciais para enviar via Gmail API (HTTP)."""
264
+ return bool(
265
+ self.gmail_client_id
266
+ and self.gmail_client_secret
267
+ and self.gmail_refresh_token
268
+ and self.recipient_email
269
+ )
270
+
271
+ @property
272
+ def smtp_configured(self) -> bool:
273
+ """Indica se há dados mínimos para enviar via SMTP."""
274
  return bool(self.smtp_host and self.smtp_port and self.recipient_email)
275
 
276
+ @property
277
+ def is_configured(self) -> bool:
278
+ """Indica se há ALGUM método de envio configurado (Gmail API ou SMTP)."""
279
+ return self.gmail_api_configured or self.smtp_configured
280
+
281
 
282
  @dataclass(frozen=True)
283
  class ClickUpSettings:
 
613
  smtp_user=_get_str("SMTP_USER"),
614
  smtp_password=_get_str("SMTP_PASSWORD"),
615
  recipient_email=_get_str("REPORT_RECIPIENT_EMAIL"),
616
+ gmail_client_id=_get_str("GMAIL_CLIENT_ID"),
617
+ gmail_client_secret=_get_str("GMAIL_CLIENT_SECRET"),
618
+ gmail_refresh_token=_get_str("GMAIL_REFRESH_TOKEN"),
619
+ gmail_sender=_get_str("GMAIL_SENDER"),
620
  )
621
 
622
  clickup = ClickUpSettings(
src/delivery/clickup_sender.py CHANGED
@@ -34,6 +34,25 @@ def _link_salesforce(instance_url: str | None, record_id: str | None) -> str | N
34
  return f"{instance_url.rstrip('/')}/{record_id}"
35
 
36
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  def _moeda(valor: Any) -> str:
38
  """Formata um número como moeda em Real (R$ 1.234,56)."""
39
  try:
@@ -211,11 +230,13 @@ def criar_tarefas_de_alertas(
211
 
212
  for alerta in criticos:
213
  severidade = alerta.get("severity", "high")
 
214
  corpo: dict[str, Any] = {
215
  "name": f"[Analytical-Force] {alerta.get('title', 'Alerta crítico')}",
216
- "markdown_description": _descricao_markdown(
217
- alerta, instance_url, report_date
218
- ),
 
219
  "priority": _PRIORIDADE_SEVERIDADE.get(severidade, 1),
220
  }
221
  if assignees:
@@ -232,7 +253,8 @@ def criar_tarefas_de_alertas(
232
  except Exception: # resposta sem JSON não deve quebrar o fluxo
233
  pass
234
  except Exception as exc: # não derruba o agente
235
- logger.error("Falha ao criar tarefa no ClickUp: %s", type(exc).__name__)
 
236
 
237
  logger.info("Tarefas criadas no ClickUp: %d.", criadas)
238
  return criadas
 
34
  return f"{instance_url.rstrip('/')}/{record_id}"
35
 
36
 
37
+ def _detalhe_erro_clickup(exc: Exception) -> str:
38
+ """Extrai status + mensagem do erro do ClickUp, para diagnóstico (sem segredos).
39
+
40
+ A resposta de erro do ClickUp traz campos como ``err`` e ``ECODE`` — úteis
41
+ para entender a recusa (token, lista, assignee, etc.).
42
+ """
43
+ resp = getattr(exc, "response", None)
44
+ if resp is not None:
45
+ try:
46
+ corpo = resp.json()
47
+ msg = corpo.get("err") or corpo.get("error") or ""
48
+ ecode = corpo.get("ECODE", "")
49
+ return f"HTTP {resp.status_code} {ecode}: {msg}".strip()
50
+ except Exception:
51
+ texto = str(getattr(resp, "text", ""))[:300]
52
+ return f"HTTP {getattr(resp, 'status_code', '?')}: {texto}"
53
+ return f"{type(exc).__name__}: {exc}"
54
+
55
+
56
  def _moeda(valor: Any) -> str:
57
  """Formata um número como moeda em Real (R$ 1.234,56)."""
58
  try:
 
230
 
231
  for alerta in criticos:
232
  severidade = alerta.get("severity", "high")
233
+ markdown = _descricao_markdown(alerta, instance_url, report_date)
234
  corpo: dict[str, Any] = {
235
  "name": f"[Analytical-Force] {alerta.get('title', 'Alerta crítico')}",
236
+ # ClickUp usa 'markdown_content' para a descrição em Markdown;
237
+ # mantemos 'description' como texto simples de fallback.
238
+ "description": str(alerta.get("description") or alerta.get("title", "")),
239
+ "markdown_content": markdown,
240
  "priority": _PRIORIDADE_SEVERIDADE.get(severidade, 1),
241
  }
242
  if assignees:
 
253
  except Exception: # resposta sem JSON não deve quebrar o fluxo
254
  pass
255
  except Exception as exc: # não derruba o agente
256
+ # Surface do motivo real do ClickUp (status + mensagem), sem segredos.
257
+ logger.error("Falha ao criar tarefa no ClickUp: %s", _detalhe_erro_clickup(exc))
258
 
259
  logger.info("Tarefas criadas no ClickUp: %d.", criadas)
260
  return criadas
src/delivery/email_sender.py CHANGED
@@ -8,11 +8,14 @@ retorna ``False``.
8
 
9
  from __future__ import annotations
10
 
 
11
  import smtplib
12
  from email.mime.multipart import MIMEMultipart
13
  from email.mime.text import MIMEText
14
  from typing import Any
15
 
 
 
16
  from ..config.settings import EmailSettings
17
  from ..utils.logger import get_logger
18
 
@@ -265,10 +268,13 @@ def enviar_relatorio_email(
265
  metrics: dict[str, Any],
266
  alerts: list[dict[str, Any]],
267
  ) -> bool:
268
- """Envia o relatório executivo por e-mail, se o SMTP estiver configurado.
 
 
 
269
 
270
  Args:
271
- config: Configurações de SMTP/destinatário.
272
  assunto: Assunto do e-mail.
273
  report_date: Data de referência do relatório (texto).
274
  metrics: Métricas calculadas (leads/opportunities/tasks/...).
@@ -278,19 +284,28 @@ def enviar_relatorio_email(
278
  ``True`` se enviado; ``False`` se não configurado ou em caso de erro.
279
  """
280
  if not config.is_configured:
281
- logger.info("Envio de e-mail ignorado: SMTP não configurado.")
282
  return False
283
 
284
  html = _montar_html(report_date, metrics, alerts)
285
  texto = _montar_texto(report_date, metrics, alerts)
286
 
 
287
  mensagem = MIMEMultipart("alternative")
288
  mensagem["Subject"] = assunto
289
- mensagem["From"] = config.smtp_user or "analytical-force@localhost"
290
  mensagem["To"] = config.recipient_email
291
  mensagem.attach(MIMEText(texto, "plain", "utf-8"))
292
  mensagem.attach(MIMEText(html, "html", "utf-8"))
293
 
 
 
 
 
 
 
 
 
294
  try:
295
  with smtplib.SMTP(config.smtp_host, config.smtp_port, timeout=30) as servidor:
296
  servidor.starttls()
@@ -299,9 +314,57 @@ def enviar_relatorio_email(
299
  servidor.sendmail(
300
  mensagem["From"], [config.recipient_email], mensagem.as_string()
301
  )
302
- logger.info("Relatório enviado por e-mail para %s.", config.recipient_email)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
303
  return True
304
  except Exception as exc: # não deve derrubar o agente
305
- # Surface do tipo de erro para diagnóstico (sem expor senha).
306
- logger.error("Falha ao enviar e-mail: %s", type(exc).__name__)
307
  return False
 
8
 
9
  from __future__ import annotations
10
 
11
+ import base64
12
  import smtplib
13
  from email.mime.multipart import MIMEMultipart
14
  from email.mime.text import MIMEText
15
  from typing import Any
16
 
17
+ import requests
18
+
19
  from ..config.settings import EmailSettings
20
  from ..utils.logger import get_logger
21
 
 
268
  metrics: dict[str, Any],
269
  alerts: list[dict[str, Any]],
270
  ) -> bool:
271
+ """Envia o relatório executivo por e-mail.
272
+
273
+ Prefere a **Gmail API (HTTP)** quando configurada — funciona em ambientes
274
+ que bloqueiam SMTP (ex.: Hugging Face Spaces). Caso contrário, usa SMTP.
275
 
276
  Args:
277
+ config: Configurações de e-mail (Gmail API e/ou SMTP).
278
  assunto: Assunto do e-mail.
279
  report_date: Data de referência do relatório (texto).
280
  metrics: Métricas calculadas (leads/opportunities/tasks/...).
 
284
  ``True`` se enviado; ``False`` se não configurado ou em caso de erro.
285
  """
286
  if not config.is_configured:
287
+ logger.info("Envio de e-mail ignorado: nenhum método configurado.")
288
  return False
289
 
290
  html = _montar_html(report_date, metrics, alerts)
291
  texto = _montar_texto(report_date, metrics, alerts)
292
 
293
+ remetente = config.gmail_sender or config.smtp_user or "analytical-force@localhost"
294
  mensagem = MIMEMultipart("alternative")
295
  mensagem["Subject"] = assunto
296
+ mensagem["From"] = remetente
297
  mensagem["To"] = config.recipient_email
298
  mensagem.attach(MIMEText(texto, "plain", "utf-8"))
299
  mensagem.attach(MIMEText(html, "html", "utf-8"))
300
 
301
+ # Gmail API tem prioridade (HTTPS; passa por firewalls que bloqueiam SMTP).
302
+ if config.gmail_api_configured:
303
+ return _enviar_via_gmail_api(config, mensagem)
304
+ return _enviar_via_smtp(config, mensagem)
305
+
306
+
307
+ def _enviar_via_smtp(config: EmailSettings, mensagem: MIMEMultipart) -> bool:
308
+ """Envia a mensagem via SMTP (STARTTLS). Usado em ambiente local."""
309
  try:
310
  with smtplib.SMTP(config.smtp_host, config.smtp_port, timeout=30) as servidor:
311
  servidor.starttls()
 
314
  servidor.sendmail(
315
  mensagem["From"], [config.recipient_email], mensagem.as_string()
316
  )
317
+ logger.info("Relatório enviado por e-mail (SMTP) para %s.", config.recipient_email)
318
+ return True
319
+ except Exception as exc: # não deve derrubar o agente
320
+ # Surface do tipo de erro (sem expor senha). OSError costuma indicar
321
+ # SMTP bloqueado pelo ambiente (ex.: Hugging Face) — use a Gmail API.
322
+ logger.error("Falha ao enviar e-mail (SMTP): %s", type(exc).__name__)
323
+ return False
324
+
325
+
326
+ def _obter_access_token_gmail(config: EmailSettings) -> str:
327
+ """Obtém um access_token do Google via refresh_token (OAuth).
328
+
329
+ Não registra o token nem o payload (contêm segredos).
330
+ """
331
+ resposta = requests.post(
332
+ "https://oauth2.googleapis.com/token",
333
+ data={
334
+ "grant_type": "refresh_token",
335
+ "client_id": config.gmail_client_id,
336
+ "client_secret": config.gmail_client_secret,
337
+ "refresh_token": config.gmail_refresh_token,
338
+ },
339
+ timeout=30,
340
+ )
341
+ if resposta.status_code != 200:
342
+ try:
343
+ erro = resposta.json()
344
+ detalhe = f"{erro.get('error')} - {erro.get('error_description', '')}".strip(" -")
345
+ except ValueError:
346
+ detalhe = "resposta inválida do servidor OAuth do Google."
347
+ raise RuntimeError(f"OAuth Google falhou (status {resposta.status_code}): {detalhe}")
348
+ return resposta.json()["access_token"]
349
+
350
+
351
+ def _enviar_via_gmail_api(config: EmailSettings, mensagem: MIMEMultipart) -> bool:
352
+ """Envia a mensagem usando a Gmail API (HTTP), via OAuth refresh token."""
353
+ try:
354
+ token = _obter_access_token_gmail(config)
355
+ raw = base64.urlsafe_b64encode(mensagem.as_bytes()).decode("utf-8")
356
+ resposta = requests.post(
357
+ "https://gmail.googleapis.com/gmail/v1/users/me/messages/send",
358
+ headers={"Authorization": f"Bearer {token}"},
359
+ json={"raw": raw},
360
+ timeout=30,
361
+ )
362
+ resposta.raise_for_status()
363
+ logger.info(
364
+ "Relatório enviado por e-mail (Gmail API) para %s.", config.recipient_email
365
+ )
366
  return True
367
  except Exception as exc: # não deve derrubar o agente
368
+ # Surface do tipo de erro, sem expor token.
369
+ logger.error("Falha ao enviar e-mail (Gmail API): %s", type(exc).__name__)
370
  return False