@danhachuel/thunderbolt 0.2.47 → 0.2.49

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.
@@ -11,6 +11,36 @@ from typing import Any
11
11
  from integrations.platforms import IntegrationResult
12
12
 
13
13
  BATCH_SCOPES = ["https://www.googleapis.com/auth/youtube.readonly"]
14
+ DEFAULT_LOOPBACK_HOST = "127.0.0.1"
15
+ DEFAULT_LOOPBACK_PORT = 8765
16
+
17
+
18
+ def loopback_host() -> str:
19
+ return _text(os.getenv("THUNDERBOLT_OAUTH_LOOPBACK_HOST")) or DEFAULT_LOOPBACK_HOST
20
+
21
+
22
+ def loopback_port() -> int:
23
+ try:
24
+ port = int(os.getenv("THUNDERBOLT_OAUTH_LOOPBACK_PORT", str(DEFAULT_LOOPBACK_PORT)))
25
+ except (TypeError, ValueError):
26
+ port = DEFAULT_LOOPBACK_PORT
27
+ return port if 1024 <= port <= 65535 else DEFAULT_LOOPBACK_PORT
28
+
29
+
30
+ def loopback_redirect_uri() -> str:
31
+ return f"http://{loopback_host()}:{loopback_port()}/"
32
+
33
+
34
+ def _authorization_error_message(email: str, exc: Exception) -> str:
35
+ detail = str(exc)
36
+ if "redirect_uri_mismatch" in detail.lower():
37
+ return (
38
+ f"A autorização da conta {email} foi rejeitada pelo Google (redirect_uri_mismatch). "
39
+ f"Use um cliente OAuth do tipo Desktop app ou adicione exactamente {loopback_redirect_uri()} "
40
+ "em Google Cloud > APIs e serviços > Credenciais > URIs de redireccionamento autorizados. "
41
+ "Não use uma URI sem a porta, com localhost diferente ou sem a barra final."
42
+ )
43
+ return f"A autorização da conta {email} falhou: {detail}"
14
44
 
15
45
 
16
46
  def _text(value: Any) -> str:
@@ -35,7 +65,7 @@ def _client_config(account: dict[str, Any]) -> dict[str, Any]:
35
65
  "client_secret": _text(account.get("client_secret")),
36
66
  "auth_uri": "https://accounts.google.com/o/oauth2/auth",
37
67
  "token_uri": "https://oauth2.googleapis.com/token",
38
- "redirect_uris": ["http://localhost"],
68
+ "redirect_uris": [loopback_redirect_uri()],
39
69
  }
40
70
  }
41
71
 
@@ -84,8 +114,8 @@ def authorize_account(account: dict[str, Any], storage_root: Path, *, open_brows
84
114
  try:
85
115
  flow = InstalledAppFlow.from_client_config(_client_config(account), BATCH_SCOPES)
86
116
  credentials = flow.run_local_server(
87
- host="localhost",
88
- port=0,
117
+ host=loopback_host(),
118
+ port=loopback_port(),
89
119
  open_browser=open_browser,
90
120
  access_type="offline",
91
121
  prompt="consent",
@@ -95,7 +125,7 @@ def authorize_account(account: dict[str, Any], storage_root: Path, *, open_brows
95
125
  _save_credentials(path, credentials)
96
126
  return IntegrationResult(True, f"Conta Google autorizada para listagem de canais: {email}.", {"status": "authorized", "email": email, "token_path": str(path)})
97
127
  except Exception as exc:
98
- return IntegrationResult(False, f"A autorização da conta {email} falhou: {exc}", {"status": "authorization_failed", "email": email})
128
+ return IntegrationResult(False, _authorization_error_message(email, exc), {"status": "authorization_failed", "email": email, "redirect_uri": loopback_redirect_uri()})
99
129
 
100
130
 
101
131
  def delete_account_token(account: dict[str, Any], storage_root: Path) -> None:
@@ -4,6 +4,7 @@ import json
4
4
  import os
5
5
  import re
6
6
  import secrets
7
+ import shutil
7
8
  from pathlib import Path
8
9
  from typing import Any
9
10
 
@@ -78,14 +79,16 @@ def parse_cookie_file(content: bytes, filename: str = "cookies.json") -> dict[st
78
79
  return {key: pairs[key] for key in COOKIE_KEYS}
79
80
 
80
81
 
81
- def parse_credentials_document(content: bytes, filename: str = DIRECT_DOCUMENT_NAME) -> dict[str, Any]:
82
+ def parse_credentials_document(content: bytes, filename: str = DIRECT_DOCUMENT_NAME, *, session_info_override: str = "") -> dict[str, Any]:
82
83
  try:
83
84
  raw = json.loads(content.decode("utf-8-sig", errors="replace"))
84
85
  except (UnicodeDecodeError, json.JSONDecodeError) as exc:
85
86
  raise ValueError(f"O documento de credenciais {filename} deve ser JSON válido.") from exc
86
87
  if not isinstance(raw, dict):
87
88
  raise ValueError(f"O documento de credenciais {filename} deve conter um objecto JSON.")
88
- document = _normalise_document(raw, {"id": raw.get("account_id", ""), "email": raw.get("email", "")})
89
+ document = _normalise_document(raw, {"id": raw.get("account_id", ""), "email": raw.get("email", ""), "sessionInfo": session_info_override})
90
+ if session_info_override.strip():
91
+ document["sessionInfo"] = session_info_override.strip()
89
92
  missing = [key for key in COOKIE_KEYS if not document["cookies"].get(key)]
90
93
  if missing:
91
94
  raise ValueError(f"Faltam cookies obrigatórios no documento {filename}: {', '.join(missing)}.")
@@ -114,6 +117,10 @@ def _channel_keys(channel: dict[str, Any]) -> list[str]:
114
117
  return keys
115
118
 
116
119
 
120
+ def _account_session_info(account: dict[str, Any]) -> str:
121
+ return str(account.get("sessionInfo") or account.get("session_info") or account.get("direct_session_info") or "").strip()
122
+
123
+
117
124
  def _normalise_document(raw: Any, account: dict[str, Any]) -> dict[str, Any]:
118
125
  raw = raw if isinstance(raw, dict) else {}
119
126
  cookies = _normalise_pairs(raw.get("cookies", raw))
@@ -129,7 +136,7 @@ def _normalise_document(raw: Any, account: dict[str, Any]) -> dict[str, Any]:
129
136
  return {
130
137
  "account_id": str(raw.get("account_id") or account.get("id") or "").strip(),
131
138
  "email": str(raw.get("email") or account.get("email") or "").strip(),
132
- "sessionInfo": str(raw.get("sessionInfo") or raw.get("session_info") or raw.get("direct_session_info") or "").strip(),
139
+ "sessionInfo": str(raw.get("sessionInfo") or raw.get("session_info") or raw.get("direct_session_info") or _account_session_info(account)).strip(),
133
140
  "cookies": {key: cookies.get(key, "") for key in COOKIE_KEYS},
134
141
  "INNERTUBE_API_KEY": str(raw.get("INNERTUBE_API_KEY") or raw.get("innertube_api_key") or raw.get("direct_innertube_api_key") or "").strip(),
135
142
  "chunk_size": _safe_chunk_size(raw.get("chunk_size", raw.get("direct_chunk_size", DEFAULT_CHUNK_SIZE))),
@@ -156,7 +163,7 @@ def _legacy_document(storage_root: Path, account: dict[str, Any], settings: dict
156
163
  return _normalise_document({
157
164
  "account_id": account.get("id"),
158
165
  "email": account.get("email"),
159
- "sessionInfo": account.get("direct_session_info") or settings.get("direct_session_info"),
166
+ "sessionInfo": _account_session_info(account) or settings.get("direct_session_info"),
160
167
  "cookies": legacy_cookies,
161
168
  "INNERTUBE_API_KEY": settings.get("direct_innertube_api_key"),
162
169
  "chunk_size": settings.get("direct_chunk_size", DEFAULT_CHUNK_SIZE),
@@ -191,6 +198,30 @@ def save_credentials_document(storage_root: Path, account: dict[str, Any], docum
191
198
  return destination
192
199
 
193
200
 
201
+ def update_credentials_document_session_info(storage_root: Path, account: dict[str, Any], session_info: str) -> Path | None:
202
+ """Update only sessionInfo in an existing credentials document."""
203
+ path = credentials_document_path(storage_root, account)
204
+ if not path.exists():
205
+ return None
206
+ raw = _read_json_document(path) or {}
207
+ document = _normalise_document(raw, {**account, "sessionInfo": session_info})
208
+ document["sessionInfo"] = str(session_info or "").strip()
209
+ return save_credentials_document(storage_root, account, document)
210
+
211
+
212
+ def delete_credentials_document(storage_root: Path, account: dict[str, Any]) -> None:
213
+ """Remove all direct-upload credentials belonging only to one Google account."""
214
+ if not str(account.get("id") or "").strip():
215
+ return
216
+ directory = account_directory(storage_root, account)
217
+ try:
218
+ shutil.rmtree(directory)
219
+ except FileNotFoundError:
220
+ pass
221
+ except OSError:
222
+ pass
223
+
224
+
194
225
  def _read_json_document(path: Path) -> dict[str, Any] | None:
195
226
  try:
196
227
  raw = json.loads(path.read_text(encoding="utf-8"))
@@ -8,6 +8,7 @@ from pathlib import Path
8
8
  from typing import Any, Callable
9
9
 
10
10
  from integrations.platforms import IntegrationResult
11
+ from integrations.youtube_batch import loopback_host, loopback_port, loopback_redirect_uri
11
12
 
12
13
 
13
14
  # Mantemos os mesmos escopos usados pelo youtube-automation-agent.
@@ -111,7 +112,7 @@ class _GoogleYouTubeBase:
111
112
  "client_secret": self.client_secret,
112
113
  "auth_uri": "https://accounts.google.com/o/oauth2/auth",
113
114
  "token_uri": "https://oauth2.googleapis.com/token",
114
- "redirect_uris": ["http://localhost"],
115
+ "redirect_uris": [loopback_redirect_uri()],
115
116
  }
116
117
  }
117
118
 
@@ -167,8 +168,8 @@ class _GoogleYouTubeBase:
167
168
  try:
168
169
  flow = InstalledAppFlow.from_client_config(self._client_config(), self.scopes)
169
170
  credentials = flow.run_local_server(
170
- host="localhost",
171
- port=0,
171
+ host=loopback_host(),
172
+ port=loopback_port(),
172
173
  open_browser=open_browser,
173
174
  access_type="offline",
174
175
  prompt="consent",
@@ -177,7 +178,17 @@ class _GoogleYouTubeBase:
177
178
  self._save_credentials(credentials, nested=self.__class__.__name__ == "YouTubeAutomationAgentUploader")
178
179
  return IntegrationResult(True, "Conta YouTube autorizada com sucesso.", {"status": "authorized", "token_path": str(self.token_path)})
179
180
  except Exception as exc:
180
- return IntegrationResult(False, f"A autorização Google falhou: {exc}", {"status": "authorization_failed"})
181
+ detail = str(exc)
182
+ if "redirect_uri_mismatch" in detail.lower():
183
+ message = (
184
+ "A autorização Google foi rejeitada (redirect_uri_mismatch). "
185
+ f"Use um cliente OAuth do tipo Desktop app ou adicione exactamente {loopback_redirect_uri()} "
186
+ "em Google Cloud > APIs e serviços > Credenciais > URIs de redireccionamento autorizados. "
187
+ "Não use uma URI sem a porta, com localhost diferente ou sem a barra final."
188
+ )
189
+ else:
190
+ message = f"A autorização Google falhou: {detail}"
191
+ return IntegrationResult(False, message, {"status": "authorization_failed", "redirect_uri": loopback_redirect_uri()})
181
192
 
182
193
  def status(self) -> IntegrationResult:
183
194
  if not self.configured:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.2.47",
3
+ "version": "0.2.49",
4
4
  "description": "Thunderbolt — interface local para operação de canais faceless e motor MoneyPrinterTurbo",
5
5
  "main": "scripts/cli.mjs",
6
6
  "type": "module",