@danhachuel/thunderbolt 0.6.8 → 0.6.10

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.
package/app/main.py CHANGED
@@ -124,6 +124,7 @@ from integrations.youtube_direct_credentials import delete_credentials_document,
124
124
  from integrations.session_info_health import check_account_session_info_health
125
125
  from integrations.youtube_session_manager import renew_account_session
126
126
  from integrations.youtube_batch import account_key as youtube_batch_account_key, account_status as youtube_batch_account_status, authorize_account as authorize_youtube_batch_account, delete_account_token as delete_youtube_batch_token, list_my_channels as list_youtube_batch_channels, loopback_redirect_uri
127
+ from integrations.youtube_upload import authorize_youtube_agent
127
128
  from integrations.youtube_growth_api import find_account_for_channel as find_youtube_growth_account
128
129
  from integrations.local_runtime import MoneyPrinterRuntime
129
130
  from integrations.moneyprinter_config import sync_moneyprinter_config
@@ -6767,7 +6768,7 @@ def render_google_accounts(*, include_innertube: bool = True):
6767
6768
  document_save = st.button("Guardar documento nesta conta", key=f"save_direct_account_{account_id}", use_container_width=True)
6768
6769
 
6769
6770
  account_status = youtube_batch_account_status(batch_account, STORAGE)
6770
- status_cols = st.columns(3)
6771
+ status_cols = st.columns(4)
6771
6772
  with status_cols[0]:
6772
6773
  (st.success if account_status.ok else st.warning)(account_status.message)
6773
6774
  with status_cols[1]:
@@ -6777,6 +6778,12 @@ def render_google_accounts(*, include_innertube: bool = True):
6777
6778
  if result.ok:
6778
6779
  st.rerun()
6779
6780
  with status_cols[2]:
6781
+ if st.button("Autorizar upload YouTube", key=f"batch_authorize_upload_{account_id}", use_container_width=True, help="Autoriza esta conta com o escopo de publicação YouTube; é separado da autorização de leitura/listagem dos canais." ):
6782
+ result = authorize_youtube_agent(settings, STORAGE, account=batch_account)
6783
+ (st.success if result.ok else st.error)(result.message)
6784
+ if result.ok:
6785
+ st.rerun()
6786
+ with status_cols[3]:
6780
6787
  if st.button("Apagar conta", icon=":material/delete:", key=f"batch_remove_settings_{account_id}", use_container_width=True):
6781
6788
  delete_youtube_batch_token(batch_account, STORAGE)
6782
6789
  delete_credentials_document(STORAGE, batch_account)
@@ -8034,7 +8041,8 @@ def render_settings():
8034
8041
  composio_enabled = st.checkbox("Activar Composio", value=bool(settings.get("composio_enabled", False)), key="upload_composio_enabled")
8035
8042
  composio_auto_upload = st.checkbox("Usar Composio por defeito na Automação Youtube", value=bool(settings.get("composio_auto_upload", True)), key="upload_composio_auto_upload", help="Quando estiver configurado, Composio é tentado antes da API Oficial, Upload directo e Postiz.")
8036
8043
  composio_api_key = st.text_input("Composio API key", value=str(settings.get("composio_api_key") or ""), type="password", key="upload_composio_api_key", help="Use a API key de projecto do Composio Platform. Nunca coloque esta chave no GitHub, em URLs ou em mensagens.")
8037
- composio_user_id = st.text_input("Composio user ID", value=str(settings.get("composio_user_id") or "thunderbolt-local"), key="upload_composio_user_id", help="Identidade estável usada para associar as contas conectadas no Composio.")
8044
+ composio_user_id = st.text_input("Composio user ID", value=str(settings.get("composio_user_id") or "thunderbolt-local"), key="upload_composio_user_id", help="Tem de ser o mesmo user ID usado quando as contas YouTube foram ligadas no Composio.")
8045
+ composio_connected_account_id = st.text_input("Connected account YouTube (ID ou alias)", value=str(settings.get("composio_connected_account_id") or ""), key="upload_composio_connected_account_id", help="Opcional. Selecciona uma conta activa do Composio. Use o ID exacto ou alias configurado.")
8038
8046
  current_tool_slug = str(settings.get("composio_tool_slug") or "upload_video")
8039
8047
  if current_tool_slug not in composio_operation_slugs:
8040
8048
  current_tool_slug = "upload_video"
@@ -8068,7 +8076,7 @@ def render_settings():
8068
8076
  with composio_action_cols[1]:
8069
8077
  test_composio = st.button("Testar configuração", use_container_width=True, key="upload_composio_test")
8070
8078
  if save_composio:
8071
- settings.update({"composio_enabled": bool(composio_enabled), "composio_auto_upload": bool(composio_auto_upload), "composio_api_key": composio_api_key.strip(), "composio_user_id": composio_user_id.strip() or "thunderbolt-local", "composio_toolkit": composio_toolkit, "composio_tool_slug": composio_tool_slug, "composio_file_field": "videoFilePath", "composio_channel_field": composio_channel_field, "composio_privacy_status": composio_privacy_status, "composio_category_id": composio_category_id, "composio_language": composio_language, "composio_privacy_field": "privacyStatus", "composio_category_field": "categoryId", "composio_language_field": "defaultLanguage", "composio_arguments_json": composio_arguments_json or "{}"})
8079
+ settings.update({"composio_enabled": bool(composio_enabled), "composio_auto_upload": bool(composio_auto_upload), "composio_api_key": composio_api_key.strip(), "composio_user_id": composio_user_id.strip() or "thunderbolt-local", "composio_connected_account_id": composio_connected_account_id.strip(), "composio_toolkit": composio_toolkit, "composio_tool_slug": composio_tool_slug, "composio_file_field": "videoFilePath", "composio_channel_field": composio_channel_field, "composio_privacy_status": composio_privacy_status, "composio_category_id": composio_category_id, "composio_language": composio_language, "composio_privacy_field": "privacyStatus", "composio_category_field": "categoryId", "composio_language_field": "defaultLanguage", "composio_arguments_json": composio_arguments_json or "{}"})
8072
8080
  write_json("settings.json", settings)
8073
8081
  st.success("Configuração Composio guardada.")
8074
8082
  st.rerun()
@@ -693,7 +693,18 @@ def _material_video_attempts(task: dict[str, Any], settings: dict[str, Any]) ->
693
693
  fallback = [item for item in attempts if item[0] != route]
694
694
  ordered = preferred + fallback
695
695
  if ordered:
696
- return ordered
696
+ # Uma execução do helper já recebe a lista de chaves do provider
697
+ # configurado; não repetir o mesmo provider por cada cartão/key.
698
+ # Repetições anteriores reinstalavam dependências e produziam cascatas
699
+ # como Pexels → Pexels → Pixabay sem acrescentar uma estratégia real.
700
+ unique: list[tuple[str, str]] = []
701
+ seen: set[str] = set()
702
+ for provider, api_key in ordered:
703
+ if provider in seen:
704
+ continue
705
+ seen.add(provider)
706
+ unique.append((provider, api_key))
707
+ return unique
697
708
  # Legacy settings keep the complete key list in one config field. Let
698
709
  # MoneyPrinterTurbo retain its own list handling when no individual cards
699
710
  # are available, preserving backwards compatibility for older installs.
@@ -291,6 +291,7 @@ DEFAULTS: dict[str, Any] = {
291
291
  "composio_auto_upload": True,
292
292
  "composio_api_key": "",
293
293
  "composio_user_id": "thunderbolt-local",
294
+ "composio_connected_account_id": "",
294
295
  "composio_toolkit": "upload_video",
295
296
  "composio_tool_slug": "upload_video",
296
297
  "composio_file_field": "videoFilePath",
@@ -111,6 +111,35 @@ def _client(api_key: str, *, upload_dir: Path | None = None):
111
111
  return Composio(**kwargs)
112
112
 
113
113
 
114
+ def _connected_account_id(client: Any, user_id: str, toolkit: str, selector: str) -> str:
115
+ """Resolve a connected-account ID from an ID or UI alias."""
116
+ value = str(selector or "").strip()
117
+ if not value:
118
+ return ""
119
+ try:
120
+ response = client.connected_accounts.list(user_ids=[_require_user_id(user_id)], statuses=["ACTIVE"])
121
+ raw = _safe_value(response)
122
+ items = raw.get("items", []) if isinstance(raw, dict) else raw
123
+ if not isinstance(items, list):
124
+ items = []
125
+ wanted = value.casefold()
126
+ for item in items:
127
+ if not isinstance(item, dict):
128
+ continue
129
+ toolkit_value = item.get("toolkit")
130
+ if isinstance(toolkit_value, dict):
131
+ toolkit_value = toolkit_value.get("slug") or toolkit_value.get("name")
132
+ candidates = [item.get("id"), item.get("nanoid"), item.get("alias"), item.get("name")]
133
+ if str(toolkit or "").strip() and toolkit.casefold() not in str(toolkit_value or "").casefold():
134
+ continue
135
+ if any(str(candidate or "").strip().casefold() == wanted for candidate in candidates):
136
+ return str(item.get("id") or item.get("nanoid") or value).strip()
137
+ except Exception:
138
+ # Preserve the original selector so Composio returns its actionable error.
139
+ return value
140
+ return value
141
+
142
+
114
143
  def discover_tools(api_key: str, user_id: str, query: str, toolkit: str = "") -> list[dict[str, Any]]:
115
144
  client = _client(api_key)
116
145
  try:
@@ -183,7 +212,7 @@ def parse_arguments(arguments_json: str) -> dict[str, Any]:
183
212
  return parsed
184
213
 
185
214
 
186
- def execute_upload(api_key: str, user_id: str, slug: str, video_path: str, file_field: str, arguments_json: str = "") -> dict[str, Any]:
215
+ def execute_upload(api_key: str, user_id: str, slug: str, video_path: str, file_field: str, arguments_json: str = "", connected_account_id: str = "") -> dict[str, Any]:
187
216
  path = Path(str(video_path or "").strip()).expanduser()
188
217
  slug = str(slug or "").strip()
189
218
  file_field = str(file_field or "").strip()
@@ -201,13 +230,16 @@ def execute_upload(api_key: str, user_id: str, slug: str, video_path: str, file_
201
230
  arguments[file_field] = str(path.resolve())
202
231
  client = _client(api_key, upload_dir=path.parent)
203
232
  try:
204
- result = client.tools.execute(
205
- slug,
206
- arguments=arguments,
207
- user_id=_require_user_id(user_id),
208
- version="latest",
209
- dangerously_skip_version_check=True,
210
- )
233
+ execute_kwargs: dict[str, Any] = {
234
+ "arguments": arguments,
235
+ "user_id": _require_user_id(user_id),
236
+ "version": "latest",
237
+ "dangerously_skip_version_check": True,
238
+ }
239
+ selected_account = _connected_account_id(client, user_id, "youtube", connected_account_id)
240
+ if selected_account:
241
+ execute_kwargs["connected_account_id"] = selected_account
242
+ result = client.tools.execute(slug, **execute_kwargs)
211
243
  response = _response(result)
212
244
  if not response["successful"] and not response["error"]:
213
245
  response["error"] = f"A ferramenta `{slug}` devolveu uma resposta sem sucesso."
@@ -338,6 +338,7 @@ def _composio_upload(settings: dict[str, Any], *, channel: dict[str, Any], **kwa
338
338
  str(kwargs.get("video_path") or ""),
339
339
  file_field,
340
340
  json.dumps(parsed_arguments, ensure_ascii=False),
341
+ str(settings.get("composio_connected_account_id") or channel.get("composio_connected_account_id") or "").strip(),
341
342
  )
342
343
  except ComposioUploadError as exc:
343
344
  return IntegrationResult(False, str(exc), {})
@@ -8,7 +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, token_path as batch_token_path
11
+ from integrations.youtube_batch import account_key, loopback_host, loopback_port, loopback_redirect_uri, token_path as batch_token_path
12
12
 
13
13
 
14
14
  # O fluxo de upload precisa apenas do scope de publicação; Analytics e leitura
@@ -27,6 +27,13 @@ def _safe_text(value: Any, default: str = "") -> str:
27
27
  return str(value if value is not None else default).strip()
28
28
 
29
29
 
30
+ def _upload_account_token_path(storage_root: Path, account: dict[str, Any] | None, purpose: str) -> Path | None:
31
+ if not isinstance(account, dict) or not (account.get("id") or account.get("email")):
32
+ return None
33
+ safe_purpose = str(purpose or "upload").strip().lower() or "upload"
34
+ return Path(storage_root) / "state" / "youtube_upload_tokens" / f"{account_key(account)}-{safe_purpose}.json"
35
+
36
+
30
37
  def _path_value(value: Any) -> Path | None:
31
38
  if isinstance(value, dict):
32
39
  value = value.get("path") or value.get("file") or value.get("filepath")
@@ -103,8 +110,16 @@ class _GoogleYouTubeBase:
103
110
  return any(path.exists() and path.stat().st_size > 0 for path in self._token_candidates())
104
111
 
105
112
  def _token_candidates(self) -> list[Path]:
106
- paths = [self.token_path]
113
+ paths: list[Path] = []
114
+ account_token = _upload_account_token_path(self.token_path.parents[1], self.account, self.__class__.__name__)
115
+ if account_token is not None:
116
+ paths.append(account_token)
117
+ if not self.account:
118
+ paths.append(self.token_path)
107
119
  if self.account:
120
+ # Compatibilidade com autorizações de publicação antigas guardadas
121
+ # no caminho global; o token de leitura/listagem continua separado.
122
+ paths.append(self.token_path)
108
123
  paths.append(batch_token_path(self.token_path.parents[1], self.account))
109
124
  if self.alternate_token_path and self.alternate_token_path not in paths:
110
125
  paths.append(self.alternate_token_path)
@@ -146,19 +161,20 @@ class _GoogleYouTubeBase:
146
161
  return credentials
147
162
 
148
163
  def _save_credentials(self, credentials: Any, *, nested: bool = False) -> None:
149
- self.token_path.parent.mkdir(parents=True, exist_ok=True)
164
+ destination = _upload_account_token_path(self.token_path.parents[1], self.account, self.__class__.__name__) or self.token_path
165
+ destination.parent.mkdir(parents=True, exist_ok=True)
150
166
  payload = json.loads(credentials.to_json())
151
167
  if nested:
152
168
  payload = {"youtube": payload}
153
- temporary = self.token_path.with_name(f".{self.token_path.name}.{secrets.token_hex(6)}.tmp")
169
+ temporary = destination.with_name(f".{destination.name}.{secrets.token_hex(6)}.tmp")
154
170
  temporary.write_text(json.dumps(payload, indent=2), encoding="utf-8")
155
171
  try:
156
172
  os.chmod(temporary, 0o600)
157
173
  except OSError:
158
174
  pass
159
- temporary.replace(self.token_path)
175
+ temporary.replace(destination)
160
176
  try:
161
- os.chmod(self.token_path, 0o600)
177
+ os.chmod(destination, 0o600)
162
178
  except OSError:
163
179
  pass
164
180
 
@@ -382,12 +398,12 @@ def upload_youtube_with_fallback(
382
398
  return IntegrationResult(False, f"O agente de upload falhou e o fallback OAuth também falhou. Agente: {primary_result.message} Fallback: {fallback_result.message}", {"status": "all_upload_mechanisms_failed", "attempts": attempts})
383
399
 
384
400
 
385
- def authorize_youtube_agent(settings: dict[str, Any], storage_root: Path, *, open_browser: bool = True) -> IntegrationResult:
386
- return YouTubeAutomationAgentUploader(settings, storage_root).authorize(open_browser=open_browser)
401
+ def authorize_youtube_agent(settings: dict[str, Any], storage_root: Path, *, account: dict[str, Any] | None = None, open_browser: bool = True) -> IntegrationResult:
402
+ return YouTubeAutomationAgentUploader(settings, storage_root, account=account).authorize(open_browser=open_browser)
387
403
 
388
404
 
389
- def authorize_youtube_fallback(settings: dict[str, Any], storage_root: Path, *, open_browser: bool = True) -> IntegrationResult:
390
- return DirectYouTubeOAuthUploader(settings, storage_root).authorize(open_browser=open_browser)
405
+ def authorize_youtube_fallback(settings: dict[str, Any], storage_root: Path, *, account: dict[str, Any] | None = None, open_browser: bool = True) -> IntegrationResult:
406
+ return DirectYouTubeOAuthUploader(settings, storage_root, account=account).authorize(open_browser=open_browser)
391
407
 
392
408
 
393
409
  def youtube_upload_status(settings: dict[str, Any], storage_root: Path) -> dict[str, IntegrationResult]:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.6.08",
3
+ "version": "0.6.10",
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",
@@ -697,7 +697,7 @@ def ensure_moviepy(root: Path, uv: str) -> None:
697
697
  MOVIEPY_REQUIREMENT,
698
698
  "python",
699
699
  "-c",
700
- "import moviepy; assert moviepy.__version__ == '2.2.1'",
700
+ "import importlib.metadata; print(f\"moviepy {importlib.metadata.version('moviepy')}\")",
701
701
  ],
702
702
  cwd=root,
703
703
  )