@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 =
|
|
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 =
|
|
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=
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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