@danhachuel/thunderbolt 0.6.6 → 0.6.8

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
@@ -118,7 +118,7 @@ from integrations.upload_post import UploadPostAdapter, UPLOAD_POST_PLATFORM_OPT
118
118
  from integrations.bilibili_upload import BilibiliApiAdapter, BILIBILI_DEFAULT_TID, BILIBILI_VIDEO_EXTENSIONS, normalise_bilibili_api_cards
119
119
  from integrations.distrokid_upload import DistroKidAdapter, DISTROKID_AUDIO_EXTENSIONS, DISTROKID_COVER_EXTENSIONS, close_distrokid_session
120
120
  from integrations.music_uploads import JewelMusicAdapter, PushtunesAdapter, YTMusicApiAdapter, MUSIC_UPLOAD_EXTENSIONS, PUSHTUNES_OPERATIONS, PUSHTUNES_SOURCES, PUSHTUNES_TARGETS, YT_MUSIC_UPLOAD_EXTENSIONS
121
- from integrations.upload_routing import COMPOSIO_OPERATION_OPTIONS, OFFICIAL_DAILY_LIMIT, official_upload_count, upload_with_default_route
121
+ from integrations.upload_routing import COMPOSIO_OPERATION_OPTIONS, OFFICIAL_DAILY_LIMIT, official_upload_count, resolve_youtube_account, upload_with_default_route
122
122
  from integrations.youtube_direct_upload import YouTubeDirectUploader
123
123
  from integrations.youtube_direct_credentials import delete_credentials_document, direct_account_status, document_status, ensure_credentials_document, load_credentials_document, merge_credentials_document, parse_credentials_document, save_credentials_document, update_credentials_document_session_info
124
124
  from integrations.session_info_health import check_account_session_info_health
@@ -5597,7 +5597,7 @@ def render_upload_direct():
5597
5597
  with st.container(border=True):
5598
5598
  st.write(f"**{task.get('topic', 'Sem tópico')}** — {task.get('channel_name', 'Canal')}")
5599
5599
  st.caption(video_path or "Sem caminho de vídeo registado")
5600
- account = direct_accounts.get(str(channel.get("google_account_id", "")))
5600
+ account = resolve_youtube_account(settings, channel)
5601
5601
  credential_status = document_status(STORAGE, account, channel, settings, channels) if account else None
5602
5602
  if not account:
5603
5603
  st.warning("Este canal não tem uma conta Google associada. Associe a conta no cartão deste canal em Canais cadastrados.")
@@ -6364,7 +6364,7 @@ def render_upload_conventional():
6364
6364
  st.caption(video_path or "Sem caminho de vídeo registado")
6365
6365
  selected_youtube_channel = upload_targets.get("YouTube") if "YouTube" in destination else None
6366
6366
  channel = selected_youtube_channel or channel_map.get(str(task.get("channel_id")), {})
6367
- account = direct_accounts.get(str(channel.get("google_account_id", "")))
6367
+ account = resolve_youtube_account(settings, channel)
6368
6368
  if "YouTube" in destination:
6369
6369
  detected_language = normalize_video_language(task.get("language") or channel.get("language") or "pt")
6370
6370
  language_state_key = f"yt_language_{task['id']}"
@@ -23,7 +23,7 @@ from typing import Any
23
23
 
24
24
  from integrations.moneyprinter_config import sync_moneyprinter_config
25
25
  from integrations.session_info_health import check_all_accounts_session_info_health, emit_session_info_health_alerts
26
- from integrations.upload_routing import upload_with_default_route
26
+ from integrations.upload_routing import resolve_youtube_account, upload_with_default_route
27
27
  from hermes_ui.creative_generation import CreativeGenerationError, generate_creative_package, generate_title_and_keywords, generate_thumbnail_prompt, generate_topic_for_channel
28
28
  from hermes_ui.script_documents import save_script_document
29
29
  from hermes_ui.script_generation import generate_script_document
@@ -1566,7 +1566,7 @@ def _run_task(task: dict[str, Any]) -> dict[str, Any]:
1566
1566
  settings,
1567
1567
  storage_root=STORAGE,
1568
1568
  channel=channel,
1569
- account=next((item for item in settings.get("youtube_batch_accounts", []) if isinstance(item, dict) and str(item.get("id")) == str(channel.get("google_account_id"))), None),
1569
+ account=resolve_youtube_account(settings, channel),
1570
1570
  video_path=str(video_path),
1571
1571
  title=title,
1572
1572
  description=str(script.get("summary") or "") + "\n\n" + str(script.get("content") or "")[:5000],
@@ -10,6 +10,14 @@ class ComposioUploadError(ValueError):
10
10
  """Safe, user-facing validation or integration error."""
11
11
 
12
12
 
13
+ COMPOSIO_OPERATION_SEARCH = {
14
+ "upload_video": {"query": "Upload Video", "toolkit": "YOUTUBE"},
15
+ "update_video": {"query": "Update Video", "toolkit": "YOUTUBE"},
16
+ "upload_tiktok_video": {"query": "Upload Video", "toolkit": "TIKTOK"},
17
+ "upload_instagram_media": {"query": "Upload Video Reel Photo", "toolkit": "INSTAGRAM"},
18
+ }
19
+
20
+
13
21
  def _safe_value(value: Any) -> Any:
14
22
  if value is None or isinstance(value, (str, int, float, bool)):
15
23
  return value
@@ -119,6 +127,38 @@ def discover_tools(api_key: str, user_id: str, query: str, toolkit: str = "") ->
119
127
  raise ComposioUploadError(f"Não foi possível descobrir ferramentas Composio: {type(exc).__name__}: {exc}") from exc
120
128
 
121
129
 
130
+ def resolve_tool_slug(api_key: str, user_id: str, configured_slug: str, toolkit: str = "") -> str:
131
+ """Resolve a Thunderbolt operation alias to a real Composio tool slug."""
132
+ slug = str(configured_slug or "").strip()
133
+ operation = COMPOSIO_OPERATION_SEARCH.get(slug)
134
+ if operation is None:
135
+ return slug
136
+ search_toolkit = str(operation["toolkit"] or toolkit or "").strip()
137
+ tools = discover_tools(api_key, user_id, str(operation["query"]), search_toolkit)
138
+ if not tools:
139
+ raise ComposioUploadError(
140
+ f"Não foi encontrada uma ferramenta Composio para `{slug}`. "
141
+ f"Ligue o toolkit {search_toolkit or 'correspondente'} e use Descobrir ferramentas."
142
+ )
143
+
144
+ def score(item: dict[str, Any]) -> tuple[int, str]:
145
+ candidate = str(item.get("slug") or "").strip()
146
+ normalized = candidate.upper().replace("-", "_")
147
+ if slug == "upload_video":
148
+ priority = {
149
+ "YOUTUBE_UPLOAD_VIDEO": 0,
150
+ "YOUTUBE_MULTIPART_UPLOAD_VIDEO": 1,
151
+ "YOUTUBE_UPLOAD": 2,
152
+ }.get(normalized, 3)
153
+ elif slug == "update_video":
154
+ priority = 0 if normalized == "YOUTUBE_UPDATE_VIDEO" else 1
155
+ else:
156
+ priority = 0 if "VIDEO" in normalized and "UPLOAD" in normalized else 1
157
+ return priority, candidate
158
+
159
+ return min((item for item in tools if item.get("slug")), key=score)["slug"]
160
+
161
+
122
162
  def authorize_toolkit(api_key: str, user_id: str, toolkit: str) -> dict[str, Any]:
123
163
  toolkit = str(toolkit or "").strip()
124
164
  if not toolkit:
@@ -19,7 +19,7 @@ from integrations.session_info_health import check_account_session_info_health,
19
19
  from integrations.youtube_direct_credentials import document_status
20
20
  from integrations.youtube_direct_upload import YouTubeDirectUploader
21
21
  from integrations.youtube_session_manager import renew_account_session
22
- from integrations.composio_upload import ComposioUploadError, execute_upload
22
+ from integrations.composio_upload import ComposioUploadError, execute_upload, resolve_tool_slug
23
23
  from hermes_ui.storage import read_json, write_json
24
24
  from hermes_ui.languages import language_locale
25
25
 
@@ -45,11 +45,41 @@ def _attempt_record(name: str, result: IntegrationResult, *, skipped: bool = Fal
45
45
  }
46
46
 
47
47
 
48
+ def resolve_youtube_account(settings: dict[str, Any], channel: dict[str, Any] | None) -> dict[str, Any] | None:
49
+ """Resolve the configured Google account without relying only on the channel ID.
50
+
51
+ Older channel records may retain the account e-mail while the account ID is
52
+ missing or stale. Prefer an exact ID match, then use a case-insensitive
53
+ e-mail match, and finally accept a sole configured account only when the
54
+ channel has no contradictory account reference.
55
+ """
56
+ if not isinstance(channel, dict):
57
+ return None
58
+ accounts = [item for item in settings.get("youtube_batch_accounts", []) if isinstance(item, dict) and item.get("id")]
59
+ if not accounts:
60
+ return None
61
+ account_id = str(channel.get("google_account_id") or "").strip()
62
+ if account_id:
63
+ match = next((item for item in accounts if str(item.get("id") or "").strip() == account_id), None)
64
+ if match is not None:
65
+ return match
66
+ account_email = str(channel.get("google_account_email") or "").strip().casefold()
67
+ if account_email:
68
+ match = next((item for item in accounts if str(item.get("email") or "").strip().casefold() == account_email), None)
69
+ if match is not None:
70
+ return match
71
+ if not account_id and not account_email and len(accounts) == 1:
72
+ return accounts[0]
73
+ return None
74
+
75
+
48
76
  def _quota_key(channel: dict[str, Any], account: dict[str, Any] | None) -> str:
49
77
  if account and account.get("id"):
50
78
  return f"account:{account['id']}"
51
79
  if channel.get("google_account_id"):
52
80
  return f"account:{channel['google_account_id']}"
81
+ if channel.get("google_account_email"):
82
+ return f"account-email:{str(channel['google_account_email']).strip().casefold()}"
53
83
  return f"channel:{channel.get('id', 'unknown')}"
54
84
 
55
85
 
@@ -107,6 +137,7 @@ def upload_with_default_route(
107
137
  composio_publisher: Callable[..., IntegrationResult] | None = None,
108
138
  ) -> IntegrationResult:
109
139
  attempts: list[dict[str, Any]] = []
140
+ account = account or resolve_youtube_account(settings, channel)
110
141
  composio_enabled = bool(settings.get("composio_enabled", False)) and bool(settings.get("composio_auto_upload", True))
111
142
  composio_ready = composio_enabled and bool(settings.get("composio_api_key")) and bool(settings.get("composio_tool_slug"))
112
143
  if composio_enabled and not composio_ready:
@@ -239,7 +270,16 @@ def upload_with_default_route(
239
270
 
240
271
 
241
272
  def _composio_upload(settings: dict[str, Any], *, channel: dict[str, Any], **kwargs: Any) -> IntegrationResult:
242
- slug = str(settings.get("composio_tool_slug") or "").strip()
273
+ configured_slug = str(settings.get("composio_tool_slug") or "").strip()
274
+ try:
275
+ slug = resolve_tool_slug(
276
+ str(settings.get("composio_api_key") or ""),
277
+ str(settings.get("composio_user_id") or ""),
278
+ configured_slug,
279
+ str(settings.get("composio_toolkit") or ""),
280
+ )
281
+ except ComposioUploadError as exc:
282
+ return IntegrationResult(False, str(exc), {"status": "tool_resolution_failed", "configured_slug": configured_slug})
243
283
  normalized_slug = slug.upper().replace("-", "_")
244
284
  connected_account_tool = normalized_slug in {"UPLOAD_VIDEO", "UPDATE_VIDEO", "UPLOAD_TIKTOK_VIDEO", "UPLOAD_INSTAGRAM_MEDIA"}
245
285
  youtube_upload_tool = normalized_slug in {"UPLOAD_VIDEO", "YOUTUBE_UPLOAD_VIDEO", "YOUTUBE_MULTIPART_UPLOAD_VIDEO"} or ("YOUTUBE" in normalized_slug and "UPLOAD" in normalized_slug and normalized_slug != "YOUTUBE_UPLOAD")
@@ -294,7 +334,7 @@ def _composio_upload(settings: dict[str, Any], *, channel: dict[str, Any], **kwa
294
334
  result = execute_upload(
295
335
  str(settings.get("composio_api_key") or ""),
296
336
  str(settings.get("composio_user_id") or ""),
297
- str(settings.get("composio_tool_slug") or ""),
337
+ slug,
298
338
  str(kwargs.get("video_path") or ""),
299
339
  file_field,
300
340
  json.dumps(parsed_arguments, ensure_ascii=False),
@@ -304,5 +344,5 @@ def _composio_upload(settings: dict[str, Any], *, channel: dict[str, Any], **kwa
304
344
  return IntegrationResult(
305
345
  bool(result.get("successful")),
306
346
  str(result.get("error") or "Upload via Composio concluído."),
307
- {"composio_data": result.get("data") or {}, "log_id": result.get("log_id") or "", "tool_slug": result.get("tool_slug") or slug},
347
+ {"composio_data": result.get("data") or {}, "log_id": result.get("log_id") or "", "tool_slug": result.get("tool_slug") or slug, "configured_tool_slug": configured_slug},
308
348
  )
@@ -11,13 +11,9 @@ from integrations.platforms import IntegrationResult
11
11
  from integrations.youtube_batch import loopback_host, loopback_port, loopback_redirect_uri, token_path as batch_token_path
12
12
 
13
13
 
14
- # Mantemos os mesmos escopos usados pelo youtube-automation-agent.
15
- AGENT_SCOPES = [
16
- "https://www.googleapis.com/auth/youtube.upload",
17
- "https://www.googleapis.com/auth/youtube",
18
- "https://www.googleapis.com/auth/youtube.readonly",
19
- "https://www.googleapis.com/auth/yt-analytics.readonly",
20
- ]
14
+ # O fluxo de upload precisa apenas do scope de publicação; Analytics e leitura
15
+ # são autorizados separadamente e não devem ser enviados neste refresh OAuth.
16
+ AGENT_SCOPES = ["https://www.googleapis.com/auth/youtube.upload"]
21
17
  FALLBACK_SCOPES = ["https://www.googleapis.com/auth/youtube.upload"]
22
18
 
23
19
 
@@ -137,7 +133,9 @@ class _GoogleYouTubeBase:
137
133
  raw = json.loads(token_source.read_text(encoding="utf-8"))
138
134
  if isinstance(raw, dict) and isinstance(raw.get("youtube"), dict):
139
135
  raw = raw["youtube"]
140
- credentials = Credentials.from_authorized_user_info(raw, self.scopes)
136
+ # Não reenviar self.scopes durante o refresh: o Google deve reutilizar
137
+ # os scopes efectivamente concedidos no token guardado.
138
+ credentials = Credentials.from_authorized_user_info(raw, None)
141
139
  granted_scopes = {str(scope).strip() for scope in (credentials.scopes or [])}
142
140
  if "https://www.googleapis.com/auth/youtube.upload" in self.scopes and granted_scopes and "https://www.googleapis.com/auth/youtube.upload" not in granted_scopes:
143
141
  email = _safe_text((self.account or {}).get("email")) or "a conta seleccionada"
@@ -316,7 +314,9 @@ class DirectYouTubeOAuthUploader(_GoogleYouTubeBase):
316
314
  settings,
317
315
  storage_root / "state" / "youtube_oauth_fallback_token.json",
318
316
  FALLBACK_SCOPES,
319
- alternate_token_path=storage_root / "state" / "youtube_agent_tokens.json",
317
+ # Não reutilizar o token do agente: pode conter scopes de leitura/Analytics
318
+ # que provocam invalid_scope no refresh do fallback de upload.
319
+ alternate_token_path=None,
320
320
  account=account,
321
321
  )
322
322
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.6.06",
3
+ "version": "0.6.08",
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",