@danhachuel/thunderbolt 0.6.2 → 0.6.4

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 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, 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
@@ -694,6 +694,25 @@ def generate_topic_for_ui(settings: dict[str, Any], channel: dict, user_context:
694
694
  return generate_topic_for_channel(settings, channel, blueprint_for_channel(channel), user_context=user_context)
695
695
 
696
696
 
697
+ def resolve_video_subject_for_generation(
698
+ settings: dict[str, Any],
699
+ channel: dict,
700
+ entered_subject: str,
701
+ blueprint: dict[str, Any] | None = None,
702
+ ) -> tuple[str, dict[str, Any] | None]:
703
+ """Keep the user's Video Subject or generate one only when the field is empty."""
704
+ manual_subject = str(entered_subject or "").strip()
705
+ if manual_subject:
706
+ return manual_subject, None
707
+
708
+ selected_blueprint = blueprint if isinstance(blueprint, dict) else blueprint_for_channel(channel)
709
+ topic_result = generate_topic_for_channel(settings, channel, selected_blueprint)
710
+ generated_subject = str(topic_result.get("topic") or "").strip()
711
+ if not generated_subject:
712
+ raise CreativeGenerationError("A IA não devolveu um Video Subject válido.")
713
+ return generated_subject, topic_result
714
+
715
+
697
716
  def generate_video_content_for_ui(
698
717
  settings: dict[str, Any],
699
718
  channel: dict,
@@ -703,19 +722,7 @@ def generate_video_content_for_ui(
703
722
  blueprint: dict[str, Any] | None = None,
704
723
  ) -> dict[str, Any]:
705
724
  """Generate the subject, script and English keywords in one MoneyPrinter-style action."""
706
- entered_subject = str(subject or "").strip()
707
- subject = entered_subject
708
- topic_result: dict[str, Any] | None = None
709
- if entered_subject:
710
- # Um Video Subject escrito pelo utilizador é sempre o tema editorial;
711
- # nunca o substituímos por um tópico aleatório gerado pela IA.
712
- subject = entered_subject
713
- else:
714
- selected_blueprint = blueprint if isinstance(blueprint, dict) else blueprint_for_channel(channel)
715
- topic_result = generate_topic_for_channel(settings, channel, selected_blueprint)
716
- subject = str(topic_result.get("topic") or "").strip()
717
- if not subject:
718
- raise CreativeGenerationError("A IA não devolveu um Video Subject válido.")
725
+ subject, topic_result = resolve_video_subject_for_generation(settings, channel, subject, blueprint)
719
726
 
720
727
  blueprint = blueprint if isinstance(blueprint, dict) else blueprint_for_channel(channel)
721
728
  script_result = generate_script_document(
@@ -8021,19 +8028,39 @@ def render_settings():
8021
8028
  _api_status_badge("Configured" if bilibili_ready else "Missing configuration", "ready" if bilibili_ready else "missing")
8022
8029
  render_bilibili_api_cards(settings)
8023
8030
  with st.expander("Composio", expanded=False):
8024
- st.caption("A API key é guardada apenas no storage local. O slug da ferramenta e o provider são descobertos no Upload via Composio; a autenticação da conta é feita pelo Connect Link do Composio.")
8031
+ composio_operation_options = COMPOSIO_OPERATION_OPTIONS
8032
+ composio_operation_slugs = list(composio_operation_options)
8033
+ st.caption("Os slugs e parâmetros técnicos do Composio são definidos pelo Thunderbolt conforme a operação; a autenticação da conta continua a ser feita pelo Connect Link do Composio.")
8025
8034
  composio_enabled = st.checkbox("Activar Composio", value=bool(settings.get("composio_enabled", False)), key="upload_composio_enabled")
8026
8035
  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.")
8027
8036
  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.")
8028
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.")
8029
- composio_toolkit = st.text_input("Toolkit preferido (opcional)", value=str(settings.get("composio_toolkit") or ""), key="upload_composio_toolkit", help="Deixe vazio para descobrir ferramentas em todos os toolkits.")
8030
- composio_tool_slug = st.text_input("Slug da ferramenta para Automação Youtube", value=str(settings.get("composio_tool_slug") or ""), key="upload_composio_tool_slug", help="Depois de descobrir uma ferramenta em Upload via Composio, copie aqui o slug para a Automação Youtube.")
8031
- composio_file_field = st.text_input("Campo do ficheiro na ferramenta", value=str(settings.get("composio_file_field") or "videoFilePath"), key="upload_composio_file_field", help="Para YOUTUBE_UPLOAD_VIDEO use videoFilePath; o Composio envia para o canal da conta YouTube ligada.")
8032
- composio_channel_field = st.text_input("Campo do canal na ferramenta (opcional)", value=str(settings.get("composio_channel_field") or ""), key="upload_composio_channel_field", help="As ferramentas YouTube actuais usam a conta ligada e não aceitam channel_id. Preencha apenas se a ferramenta descoberta exigir explicitamente esse campo.")
8033
- composio_privacy_field = st.text_input("Campo de privacidade", value=str(settings.get("composio_privacy_field") or "privacy_status"), key="upload_composio_privacy_field", help="Recebe sempre `unlisted` (Não listado), como no upload oficial.")
8034
- composio_category_field = st.text_input("Campo de categoria", value=str(settings.get("composio_category_field") or "category_id"), key="upload_composio_category_field", help="Recebe sempre `22`, categoria Pessoas e blogs, como no upload oficial.")
8035
- composio_language_field = st.text_input("Campo de idioma", value=str(settings.get("composio_language_field") or "language"), key="upload_composio_language_field", help="Recebe o locale do idioma do vídeo/canal, por exemplo `pt-BR`.")
8036
- composio_arguments_json = st.text_area("Argumentos JSON da Automação Youtube", value=str(settings.get("composio_arguments_json") or "{}"), key="upload_composio_arguments_json", height=120)
8038
+ current_tool_slug = str(settings.get("composio_tool_slug") or "upload_video")
8039
+ if current_tool_slug not in composio_operation_slugs:
8040
+ current_tool_slug = "upload_video"
8041
+ composio_tool_slug = st.selectbox("Slug da ferramenta para Automação Youtube", composio_operation_slugs, index=composio_operation_slugs.index(current_tool_slug), format_func=lambda slug: f"{composio_operation_options[slug]} ({slug})", key="upload_composio_tool_slug", help="O Thunderbolt selecciona internamente o slug da operação; `upload_video` é o default.")
8042
+ current_toolkit = str(settings.get("composio_toolkit") or composio_tool_slug)
8043
+ if current_toolkit not in composio_operation_slugs:
8044
+ current_toolkit = composio_tool_slug
8045
+ composio_toolkit = st.selectbox("Toolkit preferido (opcional)", composio_operation_slugs, index=composio_operation_slugs.index(current_toolkit), format_func=lambda slug: f"{composio_operation_options[slug]} ({slug})", key="upload_composio_toolkit", help="A lista segue as operações suportadas pelo Thunderbolt.")
8046
+ st.text_input("Campo do ficheiro na ferramenta", value="videoFilePath", disabled=True, help="Configurado internamente pelo backend. O path do vídeo é injectado automaticamente a partir do artefacto da tarefa.")
8047
+ composio_privacy_options = ["unlisted", "listed"]
8048
+ current_privacy = str(settings.get("composio_privacy_status") or "unlisted")
8049
+ if current_privacy not in composio_privacy_options:
8050
+ current_privacy = "unlisted"
8051
+ composio_privacy_status = st.selectbox("Campo de privacidade", composio_privacy_options, index=composio_privacy_options.index(current_privacy), key="upload_composio_privacy_status", help="O valor é enviado pelo backend para a operação de publicação; `unlisted` é o default.")
8052
+ composio_category_options = [str(category_id) for category_id in range(1, 101)]
8053
+ current_category = str(settings.get("composio_category_id") or "22")
8054
+ if current_category not in composio_category_options:
8055
+ current_category = "22"
8056
+ composio_category_id = st.selectbox("Campo de categoria", composio_category_options, index=composio_category_options.index(current_category), key="upload_composio_category_id", help="Categorias YouTube de 1 a 100; `22` é o default.")
8057
+ composio_language_options = list(LANGUAGE_CODES)
8058
+ current_language = str(settings.get("composio_language") or "pt")
8059
+ if current_language not in composio_language_options:
8060
+ current_language = "pt"
8061
+ composio_language = st.selectbox("Campo de idioma", composio_language_options, index=composio_language_options.index(current_language), format_func=language_label, key="upload_composio_language", help="Idioma usado pelo backend nos argumentos da ferramenta.")
8062
+ composio_channel_field = str(settings.get("composio_channel_field") or "")
8063
+ composio_arguments_json = str(settings.get("composio_arguments_json") or "{}")
8037
8064
  _render_credential_status(composio_api_key)
8038
8065
  composio_action_cols = st.columns(2)
8039
8066
  with composio_action_cols[0]:
@@ -8041,7 +8068,7 @@ def render_settings():
8041
8068
  with composio_action_cols[1]:
8042
8069
  test_composio = st.button("Testar configuração", use_container_width=True, key="upload_composio_test")
8043
8070
  if save_composio:
8044
- 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.strip(), "composio_tool_slug": composio_tool_slug.strip(), "composio_file_field": composio_file_field.strip() or "videoFilePath", "composio_channel_field": composio_channel_field.strip(), "composio_privacy_field": composio_privacy_field.strip() or "privacy_status", "composio_category_field": composio_category_field.strip() or "category_id", "composio_language_field": composio_language_field.strip() or "language", "composio_arguments_json": composio_arguments_json.strip() or "{}"})
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 "{}"})
8045
8072
  write_json("settings.json", settings)
8046
8073
  st.success("Configuração Composio guardada.")
8047
8074
  st.rerun()
@@ -291,13 +291,16 @@ DEFAULTS: dict[str, Any] = {
291
291
  "composio_auto_upload": True,
292
292
  "composio_api_key": "",
293
293
  "composio_user_id": "thunderbolt-local",
294
- "composio_toolkit": "",
295
- "composio_tool_slug": "",
294
+ "composio_toolkit": "upload_video",
295
+ "composio_tool_slug": "upload_video",
296
296
  "composio_file_field": "videoFilePath",
297
297
  "composio_channel_field": "",
298
- "composio_privacy_field": "privacy_status",
299
- "composio_category_field": "category_id",
300
- "composio_language_field": "language",
298
+ "composio_privacy_status": "unlisted",
299
+ "composio_category_id": "22",
300
+ "composio_language": "pt",
301
+ "composio_privacy_field": "privacyStatus",
302
+ "composio_category_field": "categoryId",
303
+ "composio_language_field": "defaultLanguage",
301
304
  "composio_arguments_json": "{}",
302
305
  "postiz_enabled": False,
303
306
  "postiz_api_key": "",
@@ -25,6 +25,15 @@ from hermes_ui.languages import language_locale
25
25
 
26
26
  OFFICIAL_DAILY_LIMIT = 5
27
27
  QUOTA_FILENAME = "official_upload_quota.json"
28
+ COMPOSIO_VIDEO_FILE_FIELD = "videoFilePath"
29
+ COMPOSIO_OPERATION_OPTIONS = {
30
+ "upload_video": "Upload Video",
31
+ "update_video": "Update video",
32
+ "upload_tiktok_video": "Upload TikTok Video",
33
+ "upload_instagram_media": "Upload Instagram vídeo/Reel/foto",
34
+ }
35
+ COMPOSIO_PRIVACY_VALUES = {"listed", "unlisted"}
36
+ COMPOSIO_CATEGORY_RANGE = range(1, 101)
28
37
 
29
38
 
30
39
  def _attempt_record(name: str, result: IntegrationResult, *, skipped: bool = False) -> dict[str, Any]:
@@ -232,7 +241,8 @@ def upload_with_default_route(
232
241
  def _composio_upload(settings: dict[str, Any], *, channel: dict[str, Any], **kwargs: Any) -> IntegrationResult:
233
242
  slug = str(settings.get("composio_tool_slug") or "").strip()
234
243
  normalized_slug = slug.upper().replace("-", "_")
235
- youtube_upload_tool = normalized_slug in {"YOUTUBE_UPLOAD", "YOUTUBE_UPLOAD_VIDEO", "YOUTUBE_MULTIPART_UPLOAD_VIDEO"} or ("YOUTUBE" in normalized_slug and "UPLOAD" in normalized_slug)
244
+ connected_account_tool = normalized_slug in {"UPLOAD_VIDEO", "UPDATE_VIDEO", "UPLOAD_TIKTOK_VIDEO", "UPLOAD_INSTAGRAM_MEDIA"}
245
+ 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")
236
246
  channel_id = str(
237
247
  channel.get("youtube_channel_id")
238
248
  or channel.get("youtube_id")
@@ -245,24 +255,32 @@ def _composio_upload(settings: dict[str, Any], *, channel: dict[str, Any], **kwa
245
255
  match = re.search(r"/channel/(UC[\w-]+)", str(channel.get("url") or ""))
246
256
  channel_id = match.group(1) if match else ""
247
257
  channel_field = str(settings.get("composio_channel_field") or "channel_id").strip()
248
- if not youtube_upload_tool and not channel_id:
258
+ if not connected_account_tool and not youtube_upload_tool and not channel_id:
249
259
  return IntegrationResult(False, "Composio não foi executado: o canal da tarefa não tem um YouTube channel ID sincronizado.", {"missing": ["youtube_channel_id"]})
250
- if not youtube_upload_tool and not channel_field:
260
+ if not connected_account_tool and not youtube_upload_tool and not channel_field:
251
261
  return IntegrationResult(False, "Composio não foi executado: configure o campo de canal da ferramenta.", {"missing": ["composio_channel_field"]})
252
- file_field = str(settings.get("composio_file_field") or "").strip()
253
- if not file_field or (youtube_upload_tool and file_field == "file"):
254
- file_field = "videoFilePath"
262
+ # O path técnico nunca vem da configuração editável: é injectado pelo backend.
263
+ file_field = COMPOSIO_VIDEO_FILE_FIELD
255
264
  try:
256
265
  arguments = str(settings.get("composio_arguments_json") or "{}").strip() or "{}"
257
266
  import json
258
267
  parsed_arguments = json.loads(arguments)
259
268
  if not isinstance(parsed_arguments, dict):
260
269
  return IntegrationResult(False, "Composio não foi executado: os argumentos JSON devem ser um objecto.", {})
270
+ privacy_value = str(settings.get("composio_privacy_status") or kwargs.get("privacy_status") or "unlisted").strip().lower()
271
+ if privacy_value not in COMPOSIO_PRIVACY_VALUES:
272
+ privacy_value = "unlisted"
273
+ try:
274
+ category_value = int(str(settings.get("composio_category_id") or kwargs.get("category_id") or "22").strip())
275
+ except ValueError:
276
+ category_value = 22
277
+ if category_value not in COMPOSIO_CATEGORY_RANGE:
278
+ category_value = 22
261
279
  locked_values = {
262
280
  **({channel_field: channel_id} if channel_field and not youtube_upload_tool else {}),
263
- str(settings.get("composio_privacy_field") or "privacy_status").strip(): str(kwargs.get("privacy_status") or "unlisted"),
264
- str(settings.get("composio_category_field") or "category_id").strip(): str(kwargs.get("category_id") or "22"),
265
- str(settings.get("composio_language_field") or "language").strip(): language_locale(kwargs.get("language") or channel.get("language") or "pt"),
281
+ str(settings.get("composio_privacy_field") or "privacy_status").strip(): privacy_value,
282
+ str(settings.get("composio_category_field") or "category_id").strip(): str(category_value),
283
+ str(settings.get("composio_language_field") or "language").strip(): language_locale(settings.get("composio_language") or kwargs.get("language") or channel.get("language") or "pt"),
266
284
  }
267
285
  for field, expected in locked_values.items():
268
286
  if not field:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.6.02",
3
+ "version": "0.6.04",
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",