@danhachuel/thunderbolt 0.3.34 → 0.3.36

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.
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import logging
5
6
  import re
6
7
  import uuid
7
8
  from datetime import datetime, timezone
@@ -40,6 +41,8 @@ EVENT_CATALOG: tuple[dict[str, str], ...] = (
40
41
  {"code": "mcp_operation_completed", "category": "Integrações", "label": "Operação MCP concluída", "description": "Quando uma operação mutável de integração MCP terminar com sucesso."},
41
42
  )
42
43
  EVENTS_BY_CODE = {item["code"]: item for item in EVENT_CATALOG}
44
+ LOGGER = logging.getLogger(__name__)
45
+
43
46
  SENSITIVE_MARKERS = (
44
47
  "token",
45
48
  "secret",
@@ -119,6 +122,24 @@ def _history() -> list[dict[str, Any]]:
119
122
  return [item for item in saved if isinstance(item, dict)]
120
123
 
121
124
 
125
+ def _dispatch_telegram_notification(entry: dict[str, Any]) -> None:
126
+ """Deliver a persisted event to Telegram without affecting local flows."""
127
+ try:
128
+ settings = storage.read_json("settings.json", {})
129
+ if not isinstance(settings, dict) or not bool(settings.get("telegram_enabled", False)):
130
+ return
131
+ from integrations.telegram_gateway import send_notification_to_telegram
132
+
133
+ result = send_notification_to_telegram(entry, settings)
134
+ if not result.ok:
135
+ error_type = result.data.get("error_type") if isinstance(result.data, dict) else ""
136
+ if error_type != "missing_configuration":
137
+ LOGGER.warning("Telegram notification delivery failed (%s).", error_type or "unknown_error")
138
+ except Exception as exc:
139
+ # External notification delivery must never break production, uploads or UI.
140
+ LOGGER.warning("Telegram notification delivery failed (%s).", type(exc).__name__)
141
+
142
+
122
143
  def record_notification(
123
144
  event_type: str,
124
145
  title: str,
@@ -151,6 +172,7 @@ def record_notification(
151
172
  "dedupe_key": str(dedupe_key or ""),
152
173
  }
153
174
  storage.write_json(NOTIFICATIONS_FILE, [entry, *history][:MAX_NOTIFICATIONS])
175
+ _dispatch_telegram_notification(entry)
154
176
  return entry
155
177
 
156
178
 
@@ -96,6 +96,11 @@ DEFAULTS: dict[str, Any] = {
96
96
  "youtube_client_secret": "",
97
97
  "youtube_batch_accounts": [],
98
98
  "youtube_batch_selected_account_id": "",
99
+ "telegram_enabled": False,
100
+ "telegram_bot_token": "",
101
+ "telegram_chat_id": "",
102
+ "telegram_proxy_url": "",
103
+ "telegram_timeout_seconds": 15,
99
104
  "notification_preferences": {
100
105
  "video_completed": True,
101
106
  "music_completed": True,
@@ -263,6 +268,31 @@ DEFAULTS: dict[str, Any] = {
263
268
  "suno_api_key": "",
264
269
  "suno_api_base_url": "",
265
270
  "suno_api_endpoint": "/api/generate",
271
+ "jewelmusic_enabled": False,
272
+ "jewelmusic_api_key": "",
273
+ "jewelmusic_base_url": "https://api.jewelmusic.com",
274
+ "jewelmusic_proxy_url": "",
275
+ "jewelmusic_timeout_seconds": 120,
276
+ "pushtunes_enabled": False,
277
+ "pushtunes_executable": "pushtunes",
278
+ "pushtunes_source": "csv",
279
+ "pushtunes_target": "ytm",
280
+ "pushtunes_operation": "tracks",
281
+ "pushtunes_profile": "",
282
+ "pushtunes_csv_file": "",
283
+ "pushtunes_ytm_auth_file": "",
284
+ "pushtunes_tidal_session_file": "",
285
+ "pushtunes_playlist_name": "",
286
+ "pushtunes_similarity": 0.8,
287
+ "pushtunes_working_directory": "",
288
+ "pushtunes_spotify_client_id": "",
289
+ "pushtunes_spotify_client_secret": "",
290
+ "pushtunes_spotify_redirect_uri": "",
291
+ "pushtunes_timeout_seconds": 1800,
292
+ "ytmusicapi_enabled": False,
293
+ "ytmusicapi_auth_file": "",
294
+ "ytmusicapi_proxy_url": "",
295
+ "ytmusicapi_timeout_seconds": 240,
266
296
  "voice_preview_provider": "edge",
267
297
  "voice_preview_rate": "+0%",
268
298
  "direct_cookie_sid": "",
@@ -0,0 +1,381 @@
1
+ """Music upload integrations used by the Thunderbolt Upload Música page.
2
+
3
+ The adapters deliberately keep provider-specific behaviour behind the existing
4
+ IntegrationResult contract. They never log or return secrets and they do not
5
+ perform a remote write from a credential-validation call.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import mimetypes
12
+ import os
13
+ import shutil
14
+ import subprocess
15
+ import sys
16
+ import tempfile
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ import requests
21
+
22
+ from .platforms import IntegrationResult
23
+
24
+
25
+ MUSIC_UPLOAD_EXTENSIONS = {".mp3", ".m4a", ".wma", ".flac", ".ogg", ".wav", ".aac"}
26
+ YT_MUSIC_UPLOAD_EXTENSIONS = {".mp3", ".m4a", ".wma", ".flac", ".ogg"}
27
+ DEFAULT_JEWELMUSIC_BASE_URL = "https://api.jewelmusic.com"
28
+ DEFAULT_JEWELMUSIC_TIMEOUT = 120
29
+ DEFAULT_YTMUSICAPI_TIMEOUT = 240
30
+ DEFAULT_PUSHTUNES_TIMEOUT = 1800
31
+ PUSHTUNES_SOURCES = ("subsonic", "jellyfin", "csv", "spotify", "ytm")
32
+ PUSHTUNES_TARGETS = ("spotify", "ytm", "tidal", "csv")
33
+ PUSHTUNES_OPERATIONS = ("tracks", "albums", "playlist")
34
+
35
+
36
+ def _bounded_timeout(value: Any, default: int, maximum: int) -> int:
37
+ try:
38
+ return max(5, min(maximum, int(value)))
39
+ except (TypeError, ValueError):
40
+ return default
41
+
42
+
43
+ def _proxy_kwargs(proxy_url: str) -> dict[str, dict[str, str]]:
44
+ proxy = str(proxy_url or "").strip()
45
+ return {"proxies": {"http": proxy, "https": proxy}} if proxy else {}
46
+
47
+
48
+ def _audio_path(value: str | Path, allowed_extensions: set[str] | None = None) -> tuple[Path | None, str | None]:
49
+ path = Path(value).expanduser() if str(value or "").strip() else Path("")
50
+ if not path.is_file():
51
+ return None, f"Ficheiro de música não encontrado: {path}"
52
+ if path.stat().st_size <= 0:
53
+ return None, f"O ficheiro de música está vazio: {path.name}"
54
+ extensions = allowed_extensions or MUSIC_UPLOAD_EXTENSIONS
55
+ if path.suffix.lower() not in extensions:
56
+ allowed = ", ".join(sorted(extensions))
57
+ return None, f"Formato não suportado para este destino: {path.suffix or '(sem extensão)'}. Use {allowed}."
58
+ return path, None
59
+
60
+
61
+ def _response_payload(response: requests.Response) -> Any:
62
+ try:
63
+ return response.json()
64
+ except ValueError:
65
+ return {"text": response.text[:2000]}
66
+
67
+
68
+ def _redact_output(text: str, secrets: list[str]) -> str:
69
+ result = str(text or "")
70
+ for secret in secrets:
71
+ if secret:
72
+ result = result.replace(secret, "[redacted]")
73
+ return result[-8000:]
74
+
75
+
76
+ def _error(message: str, data: dict[str, Any] | None = None) -> IntegrationResult:
77
+ return IntegrationResult(False, message, data or {})
78
+
79
+
80
+ class JewelMusicAdapter:
81
+ """Client for JewelMusic's documented ``POST /v1/tracks/upload`` API.
82
+
83
+ The public GitHub repository currently documents the Python SDK but does
84
+ not publish ``jewelmusic-sdk`` on PyPI. Thunderbolt therefore mirrors the
85
+ SDK's documented HTTP contract with requests instead of adding an
86
+ unavailable dependency.
87
+ """
88
+
89
+ def __init__(self, settings: dict[str, Any] | None = None):
90
+ self.settings = settings or {}
91
+ self.enabled = bool(self.settings.get("jewelmusic_enabled", False))
92
+ self.api_key = str(self.settings.get("jewelmusic_api_key") or "").strip()
93
+ self.base_url = str(self.settings.get("jewelmusic_base_url") or DEFAULT_JEWELMUSIC_BASE_URL).strip().rstrip("/")
94
+ self.timeout = _bounded_timeout(self.settings.get("jewelmusic_timeout_seconds"), DEFAULT_JEWELMUSIC_TIMEOUT, 900)
95
+ self.proxy_url = str(self.settings.get("jewelmusic_proxy_url") or "").strip()
96
+
97
+ def _url(self, path: str) -> str:
98
+ return f"{self.base_url}/v1/{path.lstrip('/')}"
99
+
100
+ def _headers(self) -> dict[str, str]:
101
+ return {
102
+ "Authorization": f"Bearer {self.api_key}",
103
+ "Accept": "application/json",
104
+ "User-Agent": "Thunderbolt-JewelMusic/0.3",
105
+ }
106
+
107
+ def status(self) -> IntegrationResult:
108
+ if not self.enabled:
109
+ return _error("JewelMusic está desactivado nesta subaba.", {"status": "disabled"})
110
+ if not self.api_key:
111
+ return _error("JewelMusic não está configurado: introduza a API Key.", {"status": "missing_api_key"})
112
+ if not self.base_url.startswith(("http://", "https://")):
113
+ return _error("A Base URL JewelMusic deve começar por http:// ou https://.", {"status": "invalid_base_url"})
114
+ return IntegrationResult(True, "JewelMusic configurado.", {"base_url": self.base_url, "api_key_configured": True})
115
+
116
+ def test_connection(self) -> IntegrationResult:
117
+ status = self.status()
118
+ if not status.ok:
119
+ return status
120
+ try:
121
+ response = requests.get(self._url("ping"), headers=self._headers(), timeout=self.timeout, **_proxy_kwargs(self.proxy_url))
122
+ except requests.RequestException as exc:
123
+ return _error(f"Não foi possível contactar o JewelMusic: {exc}", {"status": "network_error"})
124
+ payload = _response_payload(response)
125
+ if response.status_code >= 400:
126
+ return _error(f"JewelMusic rejeitou a validação (HTTP {response.status_code}).", {"status_code": response.status_code, "payload": payload})
127
+ return IntegrationResult(True, "Ligação JewelMusic validada sem criar uma track.", {"status_code": response.status_code, "payload": payload})
128
+
129
+ def upload_track(self, audio_path: str | Path, *, title: str, artist: str, album: str = "", year: str = "", genre: str = "") -> IntegrationResult:
130
+ status = self.status()
131
+ if not status.ok:
132
+ return status
133
+ path, error = _audio_path(audio_path)
134
+ if error or path is None:
135
+ return _error(error or "Ficheiro de música inválido.")
136
+ clean_title = str(title or "").strip() or path.stem
137
+ clean_artist = str(artist or "").strip()
138
+ if not clean_artist:
139
+ return _error("Indique o artista antes de enviar para o JewelMusic.")
140
+ metadata = {
141
+ "title": clean_title,
142
+ "artist": clean_artist,
143
+ "album": str(album or "").strip(),
144
+ "year": str(year or "").strip(),
145
+ "genre": str(genre or "").strip(),
146
+ }
147
+ metadata = {key: value for key, value in metadata.items() if value}
148
+ content_type = mimetypes.guess_type(path.name)[0] or "audio/mpeg"
149
+ try:
150
+ with path.open("rb") as handle:
151
+ response = requests.post(
152
+ self._url("tracks/upload"),
153
+ headers=self._headers(),
154
+ data=metadata,
155
+ files={"file": (path.name, handle, content_type)},
156
+ timeout=self.timeout,
157
+ **_proxy_kwargs(self.proxy_url),
158
+ )
159
+ except requests.RequestException as exc:
160
+ return _error(f"Não foi possível enviar a música para o JewelMusic: {exc}", {"filename": path.name})
161
+ payload = _response_payload(response)
162
+ if response.status_code >= 400:
163
+ return _error(f"JewelMusic rejeitou a música (HTTP {response.status_code}).", {"filename": path.name, "status_code": response.status_code, "payload": payload})
164
+ return IntegrationResult(True, f"Música enviada para o JewelMusic: {clean_title}.", {"filename": path.name, "title": clean_title, "artist": clean_artist, "payload": payload, "status_code": response.status_code})
165
+
166
+
167
+ class YTMusicApiAdapter:
168
+ """Adapter around ytmusicapi's browser-authenticated upload_song method."""
169
+
170
+ def __init__(self, settings: dict[str, Any] | None = None):
171
+ self.settings = settings or {}
172
+ self.enabled = bool(self.settings.get("ytmusicapi_enabled", False))
173
+ self.auth_file = str(self.settings.get("ytmusicapi_auth_file") or "").strip()
174
+ self.proxy_url = str(self.settings.get("ytmusicapi_proxy_url") or "").strip()
175
+ self.timeout = _bounded_timeout(self.settings.get("ytmusicapi_timeout_seconds"), DEFAULT_YTMUSICAPI_TIMEOUT, 900)
176
+
177
+ def _auth_path(self) -> Path:
178
+ return Path(self.auth_file).expanduser()
179
+
180
+ def status(self) -> IntegrationResult:
181
+ if not self.enabled:
182
+ return _error("ytmusicapi está desactivado nesta subaba.", {"status": "disabled"})
183
+ if not self.auth_file:
184
+ return _error("ytmusicapi não está configurado: indique o caminho do browser.json.", {"status": "missing_auth_file"})
185
+ path = self._auth_path()
186
+ if not path.is_file():
187
+ return _error(f"Ficheiro de autenticação ytmusicapi não encontrado: {path}", {"status": "missing_auth_file", "path": str(path)})
188
+ try:
189
+ from ytmusicapi import YTMusic # type: ignore
190
+ except ImportError:
191
+ return _error("A dependência ytmusicapi não está instalada. Execute novamente a instalação do Thunderbolt.", {"status": "dependency_missing"})
192
+ try:
193
+ YTMusic(str(path), proxies=_proxy_kwargs(self.proxy_url).get("proxies"))
194
+ except Exception as exc:
195
+ return _error(f"O browser.json não foi aceite pelo ytmusicapi: {exc}", {"status": "invalid_auth_file", "path": str(path)})
196
+ return IntegrationResult(True, "ytmusicapi pronto com autenticação de browser.", {"auth_file": str(path), "auth_type": "browser"})
197
+
198
+ def _client(self):
199
+ from ytmusicapi import YTMusic # type: ignore
200
+
201
+ return YTMusic(str(self._auth_path()), proxies=_proxy_kwargs(self.proxy_url).get("proxies"))
202
+
203
+ def test_connection(self) -> IntegrationResult:
204
+ status = self.status()
205
+ if not status.ok:
206
+ return status
207
+ try:
208
+ songs = self._client().get_library_upload_songs(limit=1)
209
+ except Exception as exc:
210
+ return _error(f"Não foi possível consultar a biblioteca de uploads do YouTube Music: {exc}", {"status": "network_or_auth_error"})
211
+ count = len(songs) if isinstance(songs, list) else 0
212
+ return IntegrationResult(True, "Autenticação ytmusicapi validada com consulta read-only.", {"sample_count": count})
213
+
214
+ def upload_song(self, audio_path: str | Path) -> IntegrationResult:
215
+ status = self.status()
216
+ if not status.ok:
217
+ return status
218
+ path, error = _audio_path(audio_path, YT_MUSIC_UPLOAD_EXTENSIONS)
219
+ if error or path is None:
220
+ return _error(error or "Ficheiro de música inválido.")
221
+ if path.stat().st_size >= 300 * 1024 * 1024:
222
+ return _error("O YouTube Music não aceita uploads com 300 MB ou mais.", {"filename": path.name})
223
+ try:
224
+ result = self._client().upload_song(str(path))
225
+ except Exception as exc:
226
+ return _error(f"Não foi possível enviar a música para o YouTube Music: {exc}", {"filename": path.name})
227
+ result_name = str(getattr(result, "name", result))
228
+ if "SUCCEEDED" not in result_name.upper():
229
+ data: dict[str, Any] = {"filename": path.name, "result": result_name}
230
+ if hasattr(result, "status_code"):
231
+ data["status_code"] = result.status_code
232
+ return _error(f"O YouTube Music não confirmou o upload de {path.name}.", data)
233
+ return IntegrationResult(True, f"Música enviada para o YouTube Music: {path.stem}.", {"filename": path.name, "result": result_name})
234
+
235
+
236
+ class PushtunesAdapter:
237
+ """Safe wrapper around the Pushtunes CLI library synchronizer.
238
+
239
+ Pushtunes moves library metadata between local sources and Spotify, YTM or
240
+ Tidal. It does not upload arbitrary local MP3 bytes, so the UI exposes the
241
+ supported source/target sync operations instead of mislabelling them as a
242
+ single-file upload.
243
+ """
244
+
245
+ def __init__(self, settings: dict[str, Any] | None = None):
246
+ self.settings = settings or {}
247
+ self.enabled = bool(self.settings.get("pushtunes_enabled", False))
248
+ self.executable = str(self.settings.get("pushtunes_executable") or "pushtunes").strip()
249
+ self.source = str(self.settings.get("pushtunes_source") or "csv").strip().lower()
250
+ self.target = str(self.settings.get("pushtunes_target") or "ytm").strip().lower()
251
+ self.operation = str(self.settings.get("pushtunes_operation") or "tracks").strip().lower()
252
+ self.profile = str(self.settings.get("pushtunes_profile") or "").strip()
253
+ self.csv_file = str(self.settings.get("pushtunes_csv_file") or "").strip()
254
+ self.ytm_auth_file = str(self.settings.get("pushtunes_ytm_auth_file") or "").strip()
255
+ self.tidal_session_file = str(self.settings.get("pushtunes_tidal_session_file") or "").strip()
256
+ self.playlist_name = str(self.settings.get("pushtunes_playlist_name") or "").strip()
257
+ self.similarity = self.settings.get("pushtunes_similarity", 0.8)
258
+ self.working_directory = str(self.settings.get("pushtunes_working_directory") or "").strip()
259
+ self.spotify_client_id = str(self.settings.get("pushtunes_spotify_client_id") or "").strip()
260
+ self.spotify_client_secret = str(self.settings.get("pushtunes_spotify_client_secret") or "").strip()
261
+ self.spotify_redirect_uri = str(self.settings.get("pushtunes_spotify_redirect_uri") or "").strip()
262
+ self.timeout = _bounded_timeout(self.settings.get("pushtunes_timeout_seconds"), DEFAULT_PUSHTUNES_TIMEOUT, 3600)
263
+
264
+ def _command_prefix(self) -> list[str] | None:
265
+ if Path(self.executable).expanduser().is_file() or shutil.which(self.executable):
266
+ return [str(Path(self.executable).expanduser())] if Path(self.executable).expanduser().is_file() else [self.executable]
267
+ if self.executable == "pushtunes":
268
+ try:
269
+ import importlib.util
270
+
271
+ if importlib.util.find_spec("pushtunes") is not None:
272
+ return [sys.executable, "-m", "pushtunes.cli.main"]
273
+ except (ImportError, ValueError):
274
+ pass
275
+ return None
276
+
277
+ def status(self) -> IntegrationResult:
278
+ if not self.enabled:
279
+ return _error("Pushtunes está desactivado nesta subaba.", {"status": "disabled"})
280
+ prefix = self._command_prefix()
281
+ if prefix is None:
282
+ return _error("Pushtunes não está instalado ou o executável não foi encontrado. Execute novamente a instalação do Thunderbolt.", {"status": "executable_missing", "executable": self.executable})
283
+ if self.source not in PUSHTUNES_SOURCES:
284
+ return _error(f"Fonte Pushtunes inválida: {self.source}.", {"status": "invalid_source", "allowed": list(PUSHTUNES_SOURCES)})
285
+ if self.target not in PUSHTUNES_TARGETS:
286
+ return _error(f"Destino Pushtunes inválido: {self.target}.", {"status": "invalid_target", "allowed": list(PUSHTUNES_TARGETS)})
287
+ if self.operation not in PUSHTUNES_OPERATIONS:
288
+ return _error(f"Operação Pushtunes inválida: {self.operation}.", {"status": "invalid_operation", "allowed": list(PUSHTUNES_OPERATIONS)})
289
+ if self.profile and not Path(self.profile).expanduser().is_file():
290
+ return _error(f"Perfil Pushtunes não encontrado: {self.profile}", {"status": "missing_profile"})
291
+ if self.source == "csv" and not self.csv_file:
292
+ return _error("Indique um CSV de origem quando a fonte Pushtunes for csv.", {"status": "missing_csv"})
293
+ if self.csv_file and not Path(self.csv_file).expanduser().is_file() and self.source == "csv":
294
+ return _error(f"CSV de origem Pushtunes não encontrado: {self.csv_file}", {"status": "missing_csv"})
295
+ if self.ytm_auth_file and not Path(self.ytm_auth_file).expanduser().is_file():
296
+ return _error(f"Ficheiro browser.json do YouTube Music não encontrado: {self.ytm_auth_file}", {"status": "missing_ytm_auth"})
297
+ if self.target == "tidal" and not self.tidal_session_file:
298
+ return _error("Indique o ficheiro tidal-session.json para usar o destino Tidal.", {"status": "missing_tidal_session"})
299
+ if self.tidal_session_file and not Path(self.tidal_session_file).expanduser().is_file():
300
+ return _error(f"Ficheiro de sessão Tidal não encontrado: {self.tidal_session_file}", {"status": "missing_tidal_session"})
301
+ if self.target == "csv" and not self.csv_file:
302
+ return _error("Indique um CSV de destino quando o alvo Pushtunes for csv.", {"status": "missing_csv"})
303
+ if self.operation == "playlist" and not self.playlist_name and not self.profile:
304
+ return _error("Indique o nome da playlist ou use um perfil Pushtunes.", {"status": "missing_playlist_name"})
305
+ if self.working_directory and not Path(self.working_directory).expanduser().is_dir():
306
+ return _error(f"Directório de trabalho Pushtunes não encontrado: {self.working_directory}", {"status": "missing_working_directory"})
307
+ return IntegrationResult(True, "Pushtunes pronto para sincronização de biblioteca.", {"command": prefix, "source": self.source, "target": self.target, "operation": self.operation})
308
+
309
+ def _args(self) -> list[str]:
310
+ args = ["push", self.operation, "--from", self.source, "--to", self.target, "--no-color"]
311
+ try:
312
+ args.extend(["--similarity", str(max(0.0, min(1.0, float(self.similarity))))])
313
+ except (TypeError, ValueError):
314
+ args.extend(["--similarity", "0.8"])
315
+ if self.profile:
316
+ args.extend(["--profile", str(Path(self.profile).expanduser().resolve())])
317
+ if self.csv_file:
318
+ args.extend(["--csv-file", str(Path(self.csv_file).expanduser().resolve())])
319
+ if self.ytm_auth_file and self.operation in {"albums", "playlist"}:
320
+ args.extend(["--ytm-auth", str(Path(self.ytm_auth_file).expanduser().resolve())])
321
+ if self.operation == "playlist" and self.playlist_name:
322
+ args.extend(["--playlist-name", self.playlist_name])
323
+ return args
324
+
325
+ def sync(self) -> IntegrationResult:
326
+ status = self.status()
327
+ if not status.ok:
328
+ return status
329
+ prefix = self._command_prefix() or []
330
+ env = os.environ.copy()
331
+ if self.spotify_client_id:
332
+ env["SPOTIFY_CLIENT_ID"] = self.spotify_client_id
333
+ if self.spotify_client_secret:
334
+ env["SPOTIFY_CLIENT_SECRET"] = self.spotify_client_secret
335
+ if self.spotify_redirect_uri:
336
+ env["SPOTIFY_REDIRECT_URI"] = self.spotify_redirect_uri
337
+ command = [*prefix, *self._args()]
338
+ secrets = [self.spotify_client_id, self.spotify_client_secret]
339
+ temporary_cwd: tempfile.TemporaryDirectory[str] | None = None
340
+ execution_cwd = str(Path(self.working_directory).expanduser().resolve()) if self.working_directory else None
341
+ if (self.ytm_auth_file and self.operation == "tracks") or self.tidal_session_file:
342
+ temporary_cwd = tempfile.TemporaryDirectory(prefix="thunderbolt-pushtunes-")
343
+ if self.ytm_auth_file and self.operation == "tracks":
344
+ shutil.copy2(Path(self.ytm_auth_file).expanduser(), Path(temporary_cwd.name) / "browser.json")
345
+ if self.tidal_session_file:
346
+ shutil.copy2(Path(self.tidal_session_file).expanduser(), Path(temporary_cwd.name) / "tidal-session.json")
347
+ execution_cwd = temporary_cwd.name
348
+ try:
349
+ completed = subprocess.run(
350
+ command,
351
+ cwd=execution_cwd,
352
+ env=env,
353
+ capture_output=True,
354
+ text=True,
355
+ timeout=self.timeout,
356
+ check=False,
357
+ )
358
+ except (OSError, subprocess.SubprocessError) as exc:
359
+ return _error(f"Não foi possível iniciar o Pushtunes: {exc}", {"status": "process_error"})
360
+ finally:
361
+ if temporary_cwd is not None:
362
+ temporary_cwd.cleanup()
363
+ stdout = _redact_output(completed.stdout, secrets)
364
+ stderr = _redact_output(completed.stderr, secrets)
365
+ data = {"command": command, "returncode": completed.returncode, "stdout": stdout, "stderr": stderr, "source": self.source, "target": self.target, "operation": self.operation}
366
+ if completed.returncode != 0:
367
+ return _error("O Pushtunes terminou com erro. Consulte a saída técnica abaixo e confirme as credenciais do serviço de origem/destino.", data)
368
+ return IntegrationResult(True, f"Pushtunes concluiu a sincronização {self.source} → {self.target}.", data)
369
+
370
+
371
+ __all__ = [
372
+ "DEFAULT_JEWELMUSIC_BASE_URL",
373
+ "JewelMusicAdapter",
374
+ "MUSIC_UPLOAD_EXTENSIONS",
375
+ "PUSHTUNES_OPERATIONS",
376
+ "PUSHTUNES_SOURCES",
377
+ "PUSHTUNES_TARGETS",
378
+ "PushtunesAdapter",
379
+ "YTMusicApiAdapter",
380
+ "YT_MUSIC_UPLOAD_EXTENSIONS",
381
+ ]
@@ -0,0 +1,158 @@
1
+ """Outbound Telegram Gateway integration for persisted Thunderbolt notifications."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import Any, Callable
7
+
8
+ import requests
9
+
10
+ from .platforms import IntegrationResult
11
+
12
+ DEFAULT_TELEGRAM_API_BASE_URL = "https://api.telegram.org"
13
+ TELEGRAM_MAX_MESSAGE_LENGTH = 4096
14
+ DEFAULT_TELEGRAM_TIMEOUT = 15
15
+
16
+
17
+ class TelegramGatewayAdapter:
18
+ """Send Thunderbolt notifications through the Telegram Bot API.
19
+
20
+ The adapter deliberately implements outbound delivery only. It does not
21
+ poll Telegram or receive messages, which keeps notification delivery
22
+ isolated from the production and upload flows.
23
+ """
24
+
25
+ def __init__(self, settings: dict[str, Any] | None = None, *, request_client: Any = requests) -> None:
26
+ self.settings = settings or {}
27
+ self.enabled = bool(self.settings.get("telegram_enabled", False))
28
+ self.bot_token = str(self.settings.get("telegram_bot_token") or os.getenv("TELEGRAM_BOT_TOKEN", "")).strip()
29
+ self.chat_id = str(self.settings.get("telegram_chat_id") or os.getenv("TELEGRAM_CHAT_ID", "")).strip()
30
+ self.proxy_url = str(self.settings.get("telegram_proxy_url") or os.getenv("TELEGRAM_PROXY_URL", "")).strip()
31
+ self.timeout = max(1, int(self.settings.get("telegram_timeout_seconds", DEFAULT_TELEGRAM_TIMEOUT) or DEFAULT_TELEGRAM_TIMEOUT))
32
+ self._request_client = request_client
33
+
34
+ def _error(self, message: str, *, status_code: int | None = None, error_type: str = "") -> IntegrationResult:
35
+ data: dict[str, Any] = {}
36
+ if status_code is not None:
37
+ data["status_code"] = status_code
38
+ if error_type:
39
+ data["error_type"] = error_type
40
+ return IntegrationResult(False, message, data)
41
+
42
+ def status(self) -> IntegrationResult:
43
+ if not self.enabled:
44
+ return self._error("Telegram está desactivado nas notificações.", error_type="disabled")
45
+ if not self.bot_token or not self.chat_id:
46
+ return self._error("Telegram não está configurado: indique o Bot Token e o Chat ID.", error_type="missing_configuration")
47
+ return IntegrationResult(True, "Telegram configurado para notificações.", {"chat_id_configured": True})
48
+
49
+ def _url(self, method: str) -> str:
50
+ return f"{DEFAULT_TELEGRAM_API_BASE_URL}/bot{self.bot_token}/{method}"
51
+
52
+ def _request_kwargs(self, payload: dict[str, Any]) -> dict[str, Any]:
53
+ kwargs: dict[str, Any] = {"json": payload, "timeout": self.timeout}
54
+ if self.proxy_url:
55
+ kwargs["proxies"] = {"http": self.proxy_url, "https": self.proxy_url}
56
+ return kwargs
57
+
58
+ def _post(self, method: str, payload: dict[str, Any]) -> IntegrationResult:
59
+ try:
60
+ response = self._request_client.post(self._url(method), **self._request_kwargs(payload))
61
+ except requests.RequestException as exc:
62
+ return self._error("Não foi possível contactar o Telegram.", error_type=type(exc).__name__)
63
+ except Exception as exc:
64
+ return self._error("A chamada ao Telegram falhou.", error_type=type(exc).__name__)
65
+
66
+ status_code = int(getattr(response, "status_code", 0) or 0)
67
+ if status_code == 429:
68
+ return self._error("O Telegram limitou a chamada de notificação; tente novamente mais tarde.", status_code=status_code, error_type="rate_limited")
69
+ if status_code in {401, 403}:
70
+ return self._error("O Telegram rejeitou o Bot Token ou não permite o envio para este Chat ID.", status_code=status_code, error_type="unauthorized")
71
+ if status_code < 200 or status_code >= 300:
72
+ return self._error("O Telegram rejeitou a notificação.", status_code=status_code or None, error_type="http_error")
73
+ try:
74
+ payload_response = response.json()
75
+ except (TypeError, ValueError):
76
+ return self._error("O Telegram devolveu uma resposta inválida.", status_code=status_code, error_type="invalid_json")
77
+ if not isinstance(payload_response, dict) or payload_response.get("ok") is not True:
78
+ return self._error("O Telegram não aceitou a notificação.", status_code=status_code, error_type="api_error")
79
+ return IntegrationResult(True, "Notificação enviada pelo Telegram.", {"status_code": status_code, "result": payload_response.get("result")})
80
+
81
+ @staticmethod
82
+ def split_message(text: str, *, max_length: int = TELEGRAM_MAX_MESSAGE_LENGTH) -> list[str]:
83
+ """Split a message at line boundaries while respecting Telegram's limit."""
84
+ normalized = str(text or "").strip()
85
+ if not normalized:
86
+ return []
87
+ chunks: list[str] = []
88
+ remaining = normalized
89
+ while len(remaining) > max_length:
90
+ cut = remaining.rfind("\n", 0, max_length + 1)
91
+ if cut < max_length // 2:
92
+ cut = max_length
93
+ chunks.append(remaining[:cut].rstrip())
94
+ remaining = remaining[cut:].lstrip()
95
+ if remaining:
96
+ chunks.append(remaining)
97
+ return chunks
98
+
99
+ @staticmethod
100
+ def format_notification(notification: dict[str, Any]) -> str:
101
+ """Format an already-redacted local notification for Telegram."""
102
+ title = str(notification.get("title") or notification.get("label") or "Notificação").strip()
103
+ message = str(notification.get("message") or "").strip()
104
+ category = str(notification.get("category") or "Sistema").strip()
105
+ created_at = str(notification.get("created_at") or "").strip()
106
+ lines = [title]
107
+ if message:
108
+ lines.append(message)
109
+ details: list[str] = []
110
+ if category:
111
+ details.append(f"Categoria: {category}")
112
+ if created_at:
113
+ details.append(f"Data: {created_at}")
114
+ metadata = notification.get("metadata")
115
+ if isinstance(metadata, dict):
116
+ for key, value in metadata.items():
117
+ if value in (None, "", [], {}):
118
+ continue
119
+ details.append(f"{key}: {value}")
120
+ if details:
121
+ lines.extend(["", *details])
122
+ return "\n".join(lines)
123
+
124
+ def send_message(self, text: str) -> IntegrationResult:
125
+ """Send plain text to the configured chat, splitting oversized messages."""
126
+ status = self.status()
127
+ if not status.ok:
128
+ return status
129
+ chunks = self.split_message(text)
130
+ if not chunks:
131
+ return self._error("A notificação Telegram está vazia.", error_type="empty_message")
132
+ message_ids: list[str] = []
133
+ for chunk in chunks:
134
+ result = self._post("sendMessage", {"chat_id": self.chat_id, "text": chunk})
135
+ if not result.ok:
136
+ return result
137
+ message_result = result.data.get("result") if isinstance(result.data, dict) else None
138
+ if isinstance(message_result, dict) and message_result.get("message_id") is not None:
139
+ message_ids.append(str(message_result["message_id"]))
140
+ return IntegrationResult(True, "Notificação enviada pelo Telegram.", {"message_ids": message_ids, "chunks": len(chunks)})
141
+
142
+ def send_notification(self, notification: dict[str, Any]) -> IntegrationResult:
143
+ """Format and send one persisted Thunderbolt notification."""
144
+ return self.send_message(self.format_notification(notification))
145
+
146
+
147
+ def send_notification_to_telegram(notification: dict[str, Any], settings: dict[str, Any] | None = None) -> IntegrationResult:
148
+ """Convenience function used by the notification persistence layer."""
149
+ return TelegramGatewayAdapter(settings).send_notification(notification)
150
+
151
+
152
+ __all__ = [
153
+ "DEFAULT_TELEGRAM_API_BASE_URL",
154
+ "DEFAULT_TELEGRAM_TIMEOUT",
155
+ "TELEGRAM_MAX_MESSAGE_LENGTH",
156
+ "TelegramGatewayAdapter",
157
+ "send_notification_to_telegram",
158
+ ]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.3.34",
3
+ "version": "0.3.36",
4
4
  "description": "Thunderbolt — interface local para operação de canais faceless e motor MoneyPrinterTurbo",
5
5
  "license": "MIT",
6
6
  "main": "scripts/cli.mjs",
package/requirements.txt CHANGED
@@ -1,5 +1,5 @@
1
1
  streamlit>=1.37,<2
2
- requests>=2.31,<3
2
+ requests[socks]>=2.31,<3
3
3
  pandas>=2.0,<3
4
4
  imageio-ffmpeg>=0.6,<1
5
5
  toml>=0.10,<1
@@ -12,5 +12,7 @@ mlxtend>=0.23,<1
12
12
  plotly>=5.18,<7
13
13
  seaborn>=0.13,<1
14
14
  yt-dlp>=2025.1.15,<2027
15
+ ytmusicapi>=1.12,<2
16
+ pushtunes>=2.15,<3
15
17
  matplotlib>=3.7,<4
16
18
  kagglehub>=0.3,<1