@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 +11 -3
- package/hermes_ui/pipeline_worker.py +12 -1
- package/hermes_ui/storage.py +1 -0
- package/integrations/composio_upload.py +40 -8
- package/integrations/upload_routing.py +1 -0
- package/integrations/youtube_upload.py +26 -10
- package/package.json +1 -1
- package/seed/skills/mpt_agent.py +1 -1
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(
|
|
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="
|
|
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
|
-
|
|
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.
|
package/hermes_ui/storage.py
CHANGED
|
@@ -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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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 = [
|
|
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.
|
|
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 =
|
|
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(
|
|
175
|
+
temporary.replace(destination)
|
|
160
176
|
try:
|
|
161
|
-
os.chmod(
|
|
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
package/seed/skills/mpt_agent.py
CHANGED
|
@@ -697,7 +697,7 @@ def ensure_moviepy(root: Path, uv: str) -> None:
|
|
|
697
697
|
MOVIEPY_REQUIREMENT,
|
|
698
698
|
"python",
|
|
699
699
|
"-c",
|
|
700
|
-
"import
|
|
700
|
+
"import importlib.metadata; print(f\"moviepy {importlib.metadata.version('moviepy')}\")",
|
|
701
701
|
],
|
|
702
702
|
cwd=root,
|
|
703
703
|
)
|