socialcli 0.3.0__py3-none-any.whl
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.
Potentially problematic release.
This version of socialcli might be problematic. Click here for more details.
- socialcli-0.3.0.dist-info/METADATA +168 -0
- socialcli-0.3.0.dist-info/RECORD +224 -0
- socialcli-0.3.0.dist-info/WHEEL +4 -0
- socialcli-0.3.0.dist-info/entry_points.txt +3 -0
- socialcli-0.3.0.dist-info/licenses/LICENSE +21 -0
- socialctl/__init__.py +0 -0
- socialctl/__main__.py +6 -0
- socialctl/adapters/__init__.py +6 -0
- socialctl/adapters/base.py +68 -0
- socialctl/adapters/errores.py +56 -0
- socialctl/adapters/facebook.py +180 -0
- socialctl/adapters/instagram.py +442 -0
- socialctl/adapters/tiktok.py +535 -0
- socialctl/adapters/youtube.py +331 -0
- socialctl/agent_errors.py +176 -0
- socialctl/agent_guide.py +23 -0
- socialctl/approval_cli.py +27 -0
- socialctl/auth.py +139 -0
- socialctl/authflow.py +344 -0
- socialctl/brands.py +194 -0
- socialctl/capabilities.py +57 -0
- socialctl/capabilities.yml +5945 -0
- socialctl/cli.py +1766 -0
- socialctl/connection_service/__init__.py +0 -0
- socialctl/connection_service/__main__.py +33 -0
- socialctl/connection_service/app.py +496 -0
- socialctl/connection_service/assets/PLATFORM-LOGOS-LICENSE.txt +50 -0
- socialctl/connection_service/assets/connection.css +75 -0
- socialctl/connection_service/assets/facebook.webp +0 -0
- socialctl/connection_service/assets/instagram.svg +194 -0
- socialctl/connection_service/assets/socialcli-logo.png +0 -0
- socialctl/connection_service/assets/tiktok.png +0 -0
- socialctl/connection_service/assets/youtube.svg +1 -0
- socialctl/connection_service/landing.py +146 -0
- socialctl/connection_service/legacy.py +48 -0
- socialctl/connection_service/peer.py +13 -0
- socialctl/connection_service/providers.py +324 -0
- socialctl/connection_service/settings.py +76 -0
- socialctl/connection_service/store.py +124 -0
- socialctl/connections/__init__.py +0 -0
- socialctl/connections/account_cli.py +438 -0
- socialctl/connections/cli.py +379 -0
- socialctl/connections/client.py +126 -0
- socialctl/connections/credential_cli.py +224 -0
- socialctl/connections/credential_store.py +49 -0
- socialctl/connections/file_store.py +193 -0
- socialctl/connections/keychain.py +78 -0
- socialctl/connections/meta_user.py +81 -0
- socialctl/connections/rotation_cli.py +116 -0
- socialctl/connections/saas_client.py +91 -0
- socialctl/connections/temporary_media.py +62 -0
- socialctl/delivery_options.py +70 -0
- socialctl/engagement.py +200 -0
- socialctl/engagement_cli.py +50 -0
- socialctl/executor.py +135 -0
- socialctl/formatter.py +291 -0
- socialctl/hosted_media.py +152 -0
- socialctl/identity.py +200 -0
- socialctl/inbox.py +298 -0
- socialctl/inbox_cli.py +46 -0
- socialctl/inventory.py +155 -0
- socialctl/inventory_cli.py +171 -0
- socialctl/inventory_sources.py +101 -0
- socialctl/management/__init__.py +1 -0
- socialctl/management/batch_cli.py +115 -0
- socialctl/management/batches.py +325 -0
- socialctl/management/changes.py +485 -0
- socialctl/management/cli.py +227 -0
- socialctl/management/comments_cli.py +352 -0
- socialctl/management/community_batch_cli.py +63 -0
- socialctl/management/community_batches.py +294 -0
- socialctl/management/community_changes.py +370 -0
- socialctl/management/community_cli.py +142 -0
- socialctl/management/community_schema.py +119 -0
- socialctl/management/facebook_groups.py +1118 -0
- socialctl/management/groups_cli.py +274 -0
- socialctl/management/inbox_readers.py +87 -0
- socialctl/management/meta_changes.py +197 -0
- socialctl/management/meta_cli.py +342 -0
- socialctl/management/meta_client.py +631 -0
- socialctl/management/meta_comment_inbox.py +565 -0
- socialctl/management/meta_comments.py +587 -0
- socialctl/management/meta_errors.py +59 -0
- socialctl/management/meta_pagination.py +52 -0
- socialctl/management/meta_schema.py +128 -0
- socialctl/management/meta_webhooks.py +147 -0
- socialctl/management/metadata_parts.py +144 -0
- socialctl/management/owned_assets.py +85 -0
- socialctl/management/owned_changes.py +470 -0
- socialctl/management/owned_cli.py +128 -0
- socialctl/management/owned_reorder.py +62 -0
- socialctl/management/owned_schema.py +368 -0
- socialctl/management/reply_context.py +24 -0
- socialctl/management/reply_evidence.py +80 -0
- socialctl/management/resource_changes.py +407 -0
- socialctl/management/resource_cli.py +136 -0
- socialctl/management/snippet_readback.py +26 -0
- socialctl/management/stories.py +713 -0
- socialctl/management/stories_cli.py +172 -0
- socialctl/management/youtube.py +323 -0
- socialctl/management/youtube_community.py +326 -0
- socialctl/management/youtube_metadata.py +122 -0
- socialctl/management/youtube_owned.py +259 -0
- socialctl/management/youtube_resources.py +305 -0
- socialctl/media.py +269 -0
- socialctl/media_cli.py +183 -0
- socialctl/media_ingress.py +211 -0
- socialctl/media_preflight.py +114 -0
- socialctl/media_registry.py +301 -0
- socialctl/media_requirements.py +52 -0
- socialctl/metricas/__init__.py +6 -0
- socialctl/metricas/almacen.py +133 -0
- socialctl/metricas/analytics_changes.py +295 -0
- socialctl/metricas/analytics_cli.py +191 -0
- socialctl/metricas/analytics_client.py +304 -0
- socialctl/metricas/analytics_ingest.py +260 -0
- socialctl/metricas/analytics_schema.py +174 -0
- socialctl/metricas/base.py +147 -0
- socialctl/metricas/facebook.py +418 -0
- socialctl/metricas/insights.py +227 -0
- socialctl/metricas/instagram.py +133 -0
- socialctl/metricas/modelos.py +87 -0
- socialctl/metricas/piezas.py +134 -0
- socialctl/metricas/reach.py +94 -0
- socialctl/metricas/reporting_client.py +112 -0
- socialctl/metricas/resumen.py +251 -0
- socialctl/metricas/slugs.py +89 -0
- socialctl/metricas/tiktok.py +185 -0
- socialctl/metricas/youtube.py +403 -0
- socialctl/migration_cli.py +60 -0
- socialctl/migration_files.py +68 -0
- socialctl/models.py +150 -0
- socialctl/native_schedule/__init__.py +13 -0
- socialctl/native_schedule/api_delivery.py +15 -0
- socialctl/native_schedule/api_reconcile.py +40 -0
- socialctl/native_schedule/approval.py +160 -0
- socialctl/native_schedule/cadence.py +73 -0
- socialctl/native_schedule/cli.py +510 -0
- socialctl/native_schedule/dispatcher.py +120 -0
- socialctl/native_schedule/facebook.py +112 -0
- socialctl/native_schedule/facebook_reels.py +140 -0
- socialctl/native_schedule/first_comment.py +181 -0
- socialctl/native_schedule/handoff.py +193 -0
- socialctl/native_schedule/instagram_delivery.py +224 -0
- socialctl/native_schedule/instagram_runtime.py +66 -0
- socialctl/native_schedule/inventory.py +70 -0
- socialctl/native_schedule/migration.py +310 -0
- socialctl/native_schedule/models.py +103 -0
- socialctl/native_schedule/reconcile.py +83 -0
- socialctl/native_schedule/remote_data.py +177 -0
- socialctl/native_schedule/report.py +151 -0
- socialctl/native_schedule/store.py +560 -0
- socialctl/native_schedule/verification.py +32 -0
- socialctl/native_schedule/youtube.py +339 -0
- socialctl/operation_output.py +87 -0
- socialctl/plantillas/accounts.yml +33 -0
- socialctl/plantillas/brand.md +95 -0
- socialctl/plantillas/estrategia.md +64 -0
- socialctl/postfile.py +240 -0
- socialctl/preflight.py +73 -0
- socialctl/publication_policy.py +34 -0
- socialctl/publication_status.py +75 -0
- socialctl/publication_steps.py +398 -0
- socialctl/publish_output.py +96 -0
- socialctl/publisher.py +445 -0
- socialctl/queue_migration.py +302 -0
- socialctl/read_budget.py +224 -0
- socialctl/read_reports.py +91 -0
- socialctl/rutas.py +103 -0
- socialctl/saas/__init__.py +1 -0
- socialctl/saas/access.py +75 -0
- socialctl/saas/auth.py +213 -0
- socialctl/saas/backup.py +124 -0
- socialctl/saas/billing.py +327 -0
- socialctl/saas/billing_enrollment.py +1237 -0
- socialctl/saas/billing_enrollment_schema.py +519 -0
- socialctl/saas/billing_inventory.py +775 -0
- socialctl/saas/billing_reconcile.py +127 -0
- socialctl/saas/billing_routes.py +27 -0
- socialctl/saas/budget.py +66 -0
- socialctl/saas/connections.py +374 -0
- socialctl/saas/consent.py +52 -0
- socialctl/saas/lifecycle.py +341 -0
- socialctl/saas/mail.py +89 -0
- socialctl/saas/maintenance.py +68 -0
- socialctl/saas/meta_user.py +298 -0
- socialctl/saas/meta_webhooks.py +358 -0
- socialctl/saas/migration.py +107 -0
- socialctl/saas/models.py +8 -0
- socialctl/saas/operations.py +102 -0
- socialctl/saas/pages.py +126 -0
- socialctl/saas/pairing.py +158 -0
- socialctl/saas/permissions.py +57 -0
- socialctl/saas/reminders.py +128 -0
- socialctl/saas/renewal.py +74 -0
- socialctl/saas/restore_guard.py +229 -0
- socialctl/saas/revocation.py +97 -0
- socialctl/saas/rotation.py +114 -0
- socialctl/saas/routes.py +221 -0
- socialctl/saas/settings.py +34 -0
- socialctl/saas/store.py +106 -0
- socialctl/saas/temporary_media.py +245 -0
- socialctl/saas/webhooks.py +76 -0
- socialctl/schedule_remote.py +262 -0
- socialctl/schedule_sqlite.py +44 -0
- socialctl/scheduler.py +581 -0
- socialctl/scheduler_launchd.py +319 -0
- socialctl/scheduler_systemd.py +167 -0
- socialctl/source_links.py +119 -0
- socialctl/ui/__init__.py +1 -0
- socialctl/ui/assets/agent-manual.md +850 -0
- socialctl/ui/assets/agents.md +27 -0
- socialctl/ui/assets/components.css +20 -0
- socialctl/ui/assets/connection-layout.css +53 -0
- socialctl/ui/assets/otp.template.html +10 -0
- socialctl/ui/assets/tokens.css +1 -0
- socialctl/ui/assets/trial.template.html +11 -0
- socialctl/ui/assets/web-layout.css +38 -0
- socialctl/ui/email/otp.html +10 -0
- socialctl/ui/email/trial.html +11 -0
- socialctl/upload_consent.py +13 -0
- socialctl/workspace.py +8 -0
- socialctl/youtube_data.py +471 -0
- socialctl/youtube_data_service.py +225 -0
socialctl/auth.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""OAuth token retrieval and refresh for explicit brands and platforms."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from socialctl.agent_errors import with_error_code, with_http_error_code
|
|
11
|
+
from socialctl.brands import Brand
|
|
12
|
+
from socialctl.models import Platform
|
|
13
|
+
|
|
14
|
+
MARGEN_S = 60 # se refresca un poco antes de que caduque
|
|
15
|
+
|
|
16
|
+
URL_REFRESCO = {
|
|
17
|
+
Platform.YOUTUBE: "https://oauth2.googleapis.com/token",
|
|
18
|
+
Platform.TIKTOK: "https://open.tiktokapis.com/v2/oauth/token/",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AuthError(Exception):
|
|
23
|
+
"""Authentication is missing or no longer valid."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _pedir_auth(platform: Platform, motivo: str) -> AuthError:
|
|
27
|
+
return AuthError(
|
|
28
|
+
f"{motivo}. Run: socialcli auth {platform.value} --brand <brand>"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _caducado(secreto: dict, platform: Platform) -> bool:
|
|
33
|
+
"""Return whether the saved token has expired or is about to expire."""
|
|
34
|
+
expira = secreto.get("expira_en")
|
|
35
|
+
if expira is None:
|
|
36
|
+
return False
|
|
37
|
+
try:
|
|
38
|
+
expira = float(expira)
|
|
39
|
+
except (TypeError, ValueError):
|
|
40
|
+
raise _pedir_auth(
|
|
41
|
+
platform, "the saved credential has an invalid expiration date"
|
|
42
|
+
) from None
|
|
43
|
+
return time.time() >= expira - MARGEN_S
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _refrescar(
|
|
47
|
+
brand: Brand, platform: Platform, secreto: dict, client: httpx.Client
|
|
48
|
+
) -> dict:
|
|
49
|
+
"""Refresh the platform token and persist the result without exposing secrets."""
|
|
50
|
+
url = URL_REFRESCO.get(platform)
|
|
51
|
+
if url is None:
|
|
52
|
+
# Meta usa tokens de página de larga duración que no se refrescan así.
|
|
53
|
+
raise _pedir_auth(platform, "the token has expired")
|
|
54
|
+
|
|
55
|
+
if platform is Platform.YOUTUBE:
|
|
56
|
+
datos = {
|
|
57
|
+
"grant_type": "refresh_token",
|
|
58
|
+
"refresh_token": secreto.get("refresh_token", ""),
|
|
59
|
+
"client_id": secreto.get("client_id", ""),
|
|
60
|
+
"client_secret": secreto.get("client_secret", ""),
|
|
61
|
+
}
|
|
62
|
+
else:
|
|
63
|
+
datos = {
|
|
64
|
+
"grant_type": "refresh_token",
|
|
65
|
+
"refresh_token": secreto.get("refresh_token", ""),
|
|
66
|
+
"client_key": secreto.get("client_key", ""),
|
|
67
|
+
"client_secret": secreto.get("client_secret", ""),
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
respuesta = client.post(url, data=datos)
|
|
72
|
+
except httpx.InvalidURL as exc:
|
|
73
|
+
# No hereda de httpx.HTTPError, así que necesita su propio except; la
|
|
74
|
+
# URL es una constante de este módulo (URL_REFRESCO), pero se cubre
|
|
75
|
+
# por si acaso, igual que en los adaptadores de este proyecto.
|
|
76
|
+
raise with_error_code(_pedir_auth(platform, "the token refresh URL is invalid"), exc, read_only=False) from None
|
|
77
|
+
except httpx.TimeoutException as exc:
|
|
78
|
+
# Subclase de httpx.HTTPError: debe ir antes que ese except para no
|
|
79
|
+
# quedar inalcanzable.
|
|
80
|
+
raise with_error_code(_pedir_auth(platform, "token refresh timed out"), exc, read_only=False) from None
|
|
81
|
+
except httpx.HTTPError as exc:
|
|
82
|
+
raise with_error_code(_pedir_auth(platform, "could not connect to refresh the token"), exc, read_only=False) from None
|
|
83
|
+
except Exception:
|
|
84
|
+
# Resguardo final para cualquier fallo imprevisto que no sea de las
|
|
85
|
+
# familias anteriores: nunca se interpola el propio error, podría
|
|
86
|
+
# arrastrar la petición con el refresh_token o el client_secret.
|
|
87
|
+
raise _pedir_auth(
|
|
88
|
+
platform, "an unexpected error occurred while refreshing the token"
|
|
89
|
+
) from None
|
|
90
|
+
|
|
91
|
+
if respuesta.status_code != 200:
|
|
92
|
+
if platform is Platform.YOUTUBE:
|
|
93
|
+
try: rejected=respuesta.json()
|
|
94
|
+
except (ValueError,TypeError): rejected={}
|
|
95
|
+
if isinstance(rejected,dict) and rejected.get('error')=='invalid_grant':
|
|
96
|
+
from socialctl.youtube_data import request_youtube_purge
|
|
97
|
+
request_youtube_purge(brand.raiz)
|
|
98
|
+
raise with_http_error_code(_pedir_auth(platform, "the token refresh was rejected"),
|
|
99
|
+
respuesta, read_only=False)
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
cuerpo = respuesta.json()
|
|
103
|
+
secreto["access_token"] = cuerpo["access_token"]
|
|
104
|
+
except (json.JSONDecodeError, KeyError, TypeError):
|
|
105
|
+
raise _pedir_auth(
|
|
106
|
+
platform, "the token refresh response is invalid"
|
|
107
|
+
) from None
|
|
108
|
+
|
|
109
|
+
if "refresh_token" in cuerpo:
|
|
110
|
+
secreto["refresh_token"] = cuerpo["refresh_token"]
|
|
111
|
+
from socialctl.identity import record_granted_scopes
|
|
112
|
+
record_granted_scopes(secreto, cuerpo)
|
|
113
|
+
secreto["expira_en"] = (
|
|
114
|
+
time.time() + float(cuerpo["expires_in"]) if "expires_in" in cuerpo else None
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
brand.guardar_secreto(platform, secreto)
|
|
118
|
+
return secreto
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def obtener_token(brand: Brand, platform: Platform, client: httpx.Client) -> str:
|
|
122
|
+
"""Return a valid access token, refreshing it when necessary."""
|
|
123
|
+
secreto = brand.leer_secreto(platform)
|
|
124
|
+
if secreto.get("auth_mode") == "broker":
|
|
125
|
+
from socialctl.connections.client import broker_access_token
|
|
126
|
+
try:
|
|
127
|
+
return broker_access_token(brand, platform, client)
|
|
128
|
+
except Exception as exc:
|
|
129
|
+
raise with_error_code(AuthError("shared connection unavailable; run socialcli connections --brand <brand> and reconnect if needed"), exc, read_only=False, fallback="authorization_unavailable") from None
|
|
130
|
+
if not secreto.get("access_token"):
|
|
131
|
+
raise _pedir_auth(
|
|
132
|
+
platform, f"no credentials for {platform.value} for {brand.nombre}"
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
if not _caducado(secreto, platform):
|
|
136
|
+
return secreto["access_token"]
|
|
137
|
+
|
|
138
|
+
secreto = _refrescar(brand, platform, secreto, client)
|
|
139
|
+
return secreto["access_token"]
|
socialctl/authflow.py
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
"""Initial platform OAuth authorization; errors must never contain credentials, authorization codes or tokens."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import hashlib
|
|
7
|
+
import secrets
|
|
8
|
+
import time
|
|
9
|
+
from urllib.parse import urlencode
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from socialctl.models import Platform
|
|
14
|
+
|
|
15
|
+
GRAFO = "https://graph.facebook.com/v26.0"
|
|
16
|
+
|
|
17
|
+
SCOPES = {
|
|
18
|
+
# A los scopes de subida se suman los de lectura, que usa `socialctl
|
|
19
|
+
# stats`: `yt-analytics.readonly` para los informes de la YouTube
|
|
20
|
+
# Analytics API (vistas, minutos vistos, retención, fuentes de tráfico) y
|
|
21
|
+
# `youtube.readonly` para el catálogo de vídeos de la Data API. Se piden
|
|
22
|
+
# junto al de subida para que un mismo token sirva para publicar y para
|
|
23
|
+
# leer, sin tener que autenticar dos veces. Los dos añadidos son de solo
|
|
24
|
+
# lectura: no permiten modificar ni borrar nada del canal. El scope de
|
|
25
|
+
# gestión es opt-in y se añade en `construir_url_autorizacion` cuando el
|
|
26
|
+
# CLI recibe `auth youtube --management`.
|
|
27
|
+
# El separador de Google es un espacio, no una coma (a diferencia de
|
|
28
|
+
# TikTok, más abajo).
|
|
29
|
+
Platform.YOUTUBE: (
|
|
30
|
+
"https://www.googleapis.com/auth/youtube.upload"
|
|
31
|
+
" https://www.googleapis.com/auth/yt-analytics.readonly"
|
|
32
|
+
" https://www.googleapis.com/auth/youtube.readonly"
|
|
33
|
+
),
|
|
34
|
+
# The public TikTok authorization flow is inbox-only. Request only the
|
|
35
|
+
# permissions submitted for review; analytics and Direct Post are deferred.
|
|
36
|
+
Platform.TIKTOK: "user.info.basic,video.upload",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
YOUTUBE_MANAGEMENT_SCOPE = "https://www.googleapis.com/auth/youtube.force-ssl"
|
|
40
|
+
|
|
41
|
+
URL_AUTORIZACION = {
|
|
42
|
+
Platform.YOUTUBE: "https://accounts.google.com/o/oauth2/v2/auth",
|
|
43
|
+
Platform.TIKTOK: "https://www.tiktok.com/v2/auth/authorize/",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
URL_TOKEN = {
|
|
47
|
+
Platform.YOUTUBE: "https://oauth2.googleapis.com/token",
|
|
48
|
+
Platform.TIKTOK: "https://open.tiktokapis.com/v2/oauth/token/",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
AYUDA_META = (
|
|
52
|
+
"Facebook and Instagram use a pasted USER token rather than a local redirect. "
|
|
53
|
+
"Obtain it from the Graph API Explorer (https://developers.facebook.com/tools/explorer/): "
|
|
54
|
+
"select your app, choose 'Generate Access Token' and grant seven permissions: "
|
|
55
|
+
"pages_show_list, pages_manage_posts, pages_read_engagement and instagram_content_publish "
|
|
56
|
+
"for publication, plus read_insights, instagram_basic and instagram_manage_insights "
|
|
57
|
+
"for reading metrics with `socialcli stats`. Granting them together avoids repeating "
|
|
58
|
+
"this step. This command exchanges the token for a long-lived Page token for the "
|
|
59
|
+
"Page configured in accounts.yml (facebook.page_id)."
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class EstadoInvalido(Exception):
|
|
64
|
+
"""The received OAuth state does not match the generated state."""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def generar_state() -> str:
|
|
68
|
+
"""Generate a random state value for CSRF protection."""
|
|
69
|
+
return secrets.token_urlsafe(32)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def generar_code_verifier() -> str:
|
|
73
|
+
"""Generate a valid PKCE verifier for Google and TikTok."""
|
|
74
|
+
return secrets.token_urlsafe(64)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _challenge_base64url(code_verifier: str) -> str:
|
|
78
|
+
"""Return the standard RFC 7636 PKCE challenge used by Google."""
|
|
79
|
+
digest = hashlib.sha256(code_verifier.encode("ascii")).digest()
|
|
80
|
+
return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _challenge_hex(code_verifier: str) -> str:
|
|
84
|
+
"""Return TikTok's hexadecimal SHA-256 PKCE challenge."""
|
|
85
|
+
return hashlib.sha256(code_verifier.encode("ascii")).hexdigest()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
_GENERADOR_CHALLENGE = {
|
|
89
|
+
Platform.YOUTUBE: _challenge_base64url,
|
|
90
|
+
Platform.TIKTOK: _challenge_hex,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def calcular_code_challenge(platform: Platform, code_verifier: str) -> str:
|
|
95
|
+
"""Derive the code challenge using the platform's required encoding."""
|
|
96
|
+
if platform not in _GENERADOR_CHALLENGE:
|
|
97
|
+
raise ValueError(AYUDA_META)
|
|
98
|
+
return _GENERADOR_CHALLENGE[platform](code_verifier)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def verificar_state(esperado: str, recibido: str | None) -> None:
|
|
102
|
+
"""Abort unless the received state exactly matches the expected state."""
|
|
103
|
+
if recibido is None or not secrets.compare_digest(esperado, recibido):
|
|
104
|
+
raise EstadoInvalido(
|
|
105
|
+
"received 'state' does not match the value generated when authorization started (possible CSRF); aborting without exchanging any code. Run 'socialcli auth' again."
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def construir_url_autorizacion(
|
|
110
|
+
platform: Platform,
|
|
111
|
+
client_id: str,
|
|
112
|
+
redirect_uri: str,
|
|
113
|
+
*,
|
|
114
|
+
state: str,
|
|
115
|
+
code_verifier: str,
|
|
116
|
+
youtube_management: bool = False,
|
|
117
|
+
) -> str:
|
|
118
|
+
"""Build the URL where the user authorizes their own app."""
|
|
119
|
+
if platform not in URL_AUTORIZACION:
|
|
120
|
+
raise ValueError(AYUDA_META)
|
|
121
|
+
if youtube_management and platform is not Platform.YOUTUBE:
|
|
122
|
+
raise ValueError("the --management permission is only available for YouTube")
|
|
123
|
+
|
|
124
|
+
code_challenge = calcular_code_challenge(platform, code_verifier)
|
|
125
|
+
|
|
126
|
+
if platform is Platform.YOUTUBE:
|
|
127
|
+
parametros = {
|
|
128
|
+
"client_id": client_id,
|
|
129
|
+
"redirect_uri": redirect_uri,
|
|
130
|
+
"response_type": "code",
|
|
131
|
+
"scope": (
|
|
132
|
+
f"{SCOPES[platform]} {YOUTUBE_MANAGEMENT_SCOPE}"
|
|
133
|
+
if youtube_management
|
|
134
|
+
else SCOPES[platform]
|
|
135
|
+
),
|
|
136
|
+
"access_type": "offline",
|
|
137
|
+
"prompt": "consent",
|
|
138
|
+
"state": state,
|
|
139
|
+
"code_challenge": code_challenge,
|
|
140
|
+
"code_challenge_method": "S256",
|
|
141
|
+
}
|
|
142
|
+
else:
|
|
143
|
+
parametros = {
|
|
144
|
+
"client_key": client_id,
|
|
145
|
+
"redirect_uri": redirect_uri,
|
|
146
|
+
"response_type": "code",
|
|
147
|
+
"scope": SCOPES[platform],
|
|
148
|
+
"state": state,
|
|
149
|
+
"code_challenge": code_challenge,
|
|
150
|
+
"code_challenge_method": "S256",
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return f"{URL_AUTORIZACION[platform]}?{urlencode(parametros)}"
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def canjear_codigo(
|
|
157
|
+
platform: Platform,
|
|
158
|
+
codigo: str,
|
|
159
|
+
credenciales_app: dict,
|
|
160
|
+
redirect_uri: str,
|
|
161
|
+
client: httpx.Client,
|
|
162
|
+
*,
|
|
163
|
+
code_verifier: str,
|
|
164
|
+
) -> dict:
|
|
165
|
+
"""Exchange an authorization code for tokens and return credentials to persist."""
|
|
166
|
+
if platform not in URL_TOKEN:
|
|
167
|
+
raise ValueError(AYUDA_META)
|
|
168
|
+
|
|
169
|
+
if platform is Platform.YOUTUBE:
|
|
170
|
+
datos = {
|
|
171
|
+
"grant_type": "authorization_code",
|
|
172
|
+
"code": codigo,
|
|
173
|
+
"redirect_uri": redirect_uri,
|
|
174
|
+
"client_id": credenciales_app["client_id"],
|
|
175
|
+
"client_secret": credenciales_app["client_secret"],
|
|
176
|
+
"code_verifier": code_verifier,
|
|
177
|
+
}
|
|
178
|
+
else:
|
|
179
|
+
datos = {
|
|
180
|
+
"grant_type": "authorization_code",
|
|
181
|
+
"code": codigo,
|
|
182
|
+
"redirect_uri": redirect_uri,
|
|
183
|
+
"client_key": credenciales_app["client_key"],
|
|
184
|
+
"client_secret": credenciales_app["client_secret"],
|
|
185
|
+
"code_verifier": code_verifier,
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
try:
|
|
189
|
+
respuesta = client.post(URL_TOKEN[platform], data=datos)
|
|
190
|
+
except httpx.InvalidURL:
|
|
191
|
+
# No hereda de httpx.HTTPError, así que necesita su propio except; la
|
|
192
|
+
# URL es una constante de este módulo (URL_TOKEN), no algo escrito a
|
|
193
|
+
# mano por el usuario, pero se cubre por si acaso, igual que en los
|
|
194
|
+
# adaptadores de este proyecto.
|
|
195
|
+
raise RuntimeError(
|
|
196
|
+
f'could not contact {platform.value} to exchange the authorization code: the configured URL is invalid. Run the auth command again.'
|
|
197
|
+
) from None
|
|
198
|
+
except httpx.TimeoutException:
|
|
199
|
+
# Subclase de httpx.HTTPError: debe ir antes que ese except para no
|
|
200
|
+
# quedar inalcanzable.
|
|
201
|
+
raise RuntimeError(
|
|
202
|
+
f'timed out while contacting {platform.value} to exchange the authorization code. Run the auth command again.'
|
|
203
|
+
) from None
|
|
204
|
+
except httpx.HTTPError:
|
|
205
|
+
raise RuntimeError(
|
|
206
|
+
f'could not connect to {platform.value} to exchange the authorization code. Check your internet connection and run the auth command again.'
|
|
207
|
+
) from None
|
|
208
|
+
except Exception:
|
|
209
|
+
# Resguardo final para cualquier fallo imprevisto que no sea de las
|
|
210
|
+
# familias anteriores: nunca se interpola el propio error (podría
|
|
211
|
+
# arrastrar la petición, con el client_secret y el code_verifier).
|
|
212
|
+
raise RuntimeError(
|
|
213
|
+
f'an unexpected error occurred while exchanging the authorization code with {platform.value}. Run the auth command again.'
|
|
214
|
+
) from None
|
|
215
|
+
|
|
216
|
+
if respuesta.status_code != 200:
|
|
217
|
+
raise RuntimeError(
|
|
218
|
+
f"{platform.value} rejected the authorization code "
|
|
219
|
+
f"(HTTP {respuesta.status_code}). Run the auth command again."
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
try:
|
|
223
|
+
cuerpo = respuesta.json()
|
|
224
|
+
access_token = cuerpo["access_token"]
|
|
225
|
+
except Exception:
|
|
226
|
+
# Cubre JSON mal formado, una raíz que no es un objeto, o un objeto
|
|
227
|
+
# sin 'access_token'. No se interpola el cuerpo de la respuesta en
|
|
228
|
+
# el mensaje: en teoría podría arrastrar algún dato sensible del
|
|
229
|
+
# intercambio.
|
|
230
|
+
raise RuntimeError(
|
|
231
|
+
f'{platform.value} returned an unexpected format while exchanging the authorization code'
|
|
232
|
+
) from None
|
|
233
|
+
|
|
234
|
+
secreto = dict(credenciales_app)
|
|
235
|
+
from socialctl.identity import record_granted_scopes
|
|
236
|
+
record_granted_scopes(secreto, cuerpo)
|
|
237
|
+
secreto["access_token"] = access_token
|
|
238
|
+
if isinstance(cuerpo.get("refresh_token"), str):
|
|
239
|
+
secreto["refresh_token"] = cuerpo["refresh_token"]
|
|
240
|
+
# TikTok incluye el `open_id` de la cuenta directamente en esta misma
|
|
241
|
+
# respuesta (confirmado con Context7: developers.tiktok.com/docs/en/
|
|
242
|
+
# oauth-user-access-token-management -"these values, along with the
|
|
243
|
+
# user's unique open_id and authorized scopes, should be stored"- y los
|
|
244
|
+
# ejemplos de /docs/en/silent-login y /docs/en/minis-oauth, cuyo JSON de
|
|
245
|
+
# respuesta trae "open_id" junto a "access_token"). No hace falta una
|
|
246
|
+
# llamada aparte a /v2/user/info/ para obtenerlo: quien llama a esta
|
|
247
|
+
# función (`_auth_oauth_redireccion` en cli.py) lo toma de aquí. Google
|
|
248
|
+
# no documenta este campo, así que en YouTube simplemente no aparecerá.
|
|
249
|
+
if isinstance(cuerpo.get("open_id"), str) and cuerpo["open_id"]:
|
|
250
|
+
secreto["open_id"] = cuerpo["open_id"]
|
|
251
|
+
secreto["expira_en"] = time.time() + float(cuerpo["expires_in"]) if "expires_in" in cuerpo else None
|
|
252
|
+
return secreto
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def intercambiar_token_meta(
|
|
256
|
+
client_id: str, client_secret: str, token_corto: str, client: httpx.Client
|
|
257
|
+
) -> str:
|
|
258
|
+
"""Exchange a Meta USER token for a long-lived token of approximately 60 days."""
|
|
259
|
+
try:
|
|
260
|
+
respuesta = client.get(
|
|
261
|
+
f"{GRAFO}/oauth/access_token",
|
|
262
|
+
params={
|
|
263
|
+
"grant_type": "fb_exchange_token",
|
|
264
|
+
"client_id": client_id,
|
|
265
|
+
"client_secret": client_secret,
|
|
266
|
+
"fb_exchange_token": token_corto,
|
|
267
|
+
},
|
|
268
|
+
)
|
|
269
|
+
except httpx.InvalidURL:
|
|
270
|
+
# No hereda de httpx.HTTPError, así que necesita su propio except.
|
|
271
|
+
raise RuntimeError(
|
|
272
|
+
'could not contact Meta to exchange the token: the configured URL is invalid. Run the auth command again.'
|
|
273
|
+
) from None
|
|
274
|
+
except httpx.TimeoutException:
|
|
275
|
+
# Subclase de httpx.HTTPError: debe ir antes que ese except para no
|
|
276
|
+
# quedar inalcanzable.
|
|
277
|
+
raise RuntimeError(
|
|
278
|
+
'contacting Meta to exchange the token timed out. Run the auth command again.'
|
|
279
|
+
) from None
|
|
280
|
+
except httpx.HTTPError:
|
|
281
|
+
raise RuntimeError(
|
|
282
|
+
'could not connect to Meta to exchange the token. Check your internet connection and run the auth command again.'
|
|
283
|
+
) from None
|
|
284
|
+
except Exception:
|
|
285
|
+
# Resguardo final: nunca se interpola el propio error, podría
|
|
286
|
+
# arrastrar la petición con el App secret o el token pegado.
|
|
287
|
+
raise RuntimeError(
|
|
288
|
+
'an unexpected error occurred while exchanging the token with Meta. Run the auth command again.'
|
|
289
|
+
) from None
|
|
290
|
+
|
|
291
|
+
if respuesta.status_code != 200:
|
|
292
|
+
raise RuntimeError(
|
|
293
|
+
f'Meta rejected the token exchange (HTTP {respuesta.status_code}). Check the app ID, app secret and whether the pasted token is valid and unexpired.'
|
|
294
|
+
)
|
|
295
|
+
try:
|
|
296
|
+
return respuesta.json()["access_token"]
|
|
297
|
+
except Exception:
|
|
298
|
+
raise RuntimeError(
|
|
299
|
+
"Meta returned an unexpected format while exchanging the token"
|
|
300
|
+
) from None
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def obtener_paginas_meta(token_usuario: str, client: httpx.Client) -> list[dict]:
|
|
304
|
+
"""List Pages administered by the user, including their Page tokens."""
|
|
305
|
+
try:
|
|
306
|
+
respuesta = client.get(f"{GRAFO}/me/accounts", params={"access_token": token_usuario})
|
|
307
|
+
except httpx.InvalidURL:
|
|
308
|
+
# No hereda de httpx.HTTPError, así que necesita su propio except.
|
|
309
|
+
raise RuntimeError(
|
|
310
|
+
'could not contact Meta to list pages: the configured URL is invalid. Run the auth command again.'
|
|
311
|
+
) from None
|
|
312
|
+
except httpx.TimeoutException:
|
|
313
|
+
# Subclase de httpx.HTTPError: debe ir antes que ese except para no
|
|
314
|
+
# quedar inalcanzable.
|
|
315
|
+
raise RuntimeError(
|
|
316
|
+
'contacting Meta to list pages timed out. Run the auth command again.'
|
|
317
|
+
) from None
|
|
318
|
+
except httpx.HTTPError:
|
|
319
|
+
raise RuntimeError(
|
|
320
|
+
'could not connect to Meta to list pages. Check your internet connection and run the auth command again.'
|
|
321
|
+
) from None
|
|
322
|
+
except Exception:
|
|
323
|
+
# Resguardo final: nunca se interpola el propio error, podría
|
|
324
|
+
# arrastrar la petición con el token de usuario de larga duración.
|
|
325
|
+
raise RuntimeError(
|
|
326
|
+
'an unexpected error occurred while listing Meta pages. Run the auth command again.'
|
|
327
|
+
) from None
|
|
328
|
+
|
|
329
|
+
if respuesta.status_code != 200:
|
|
330
|
+
raise RuntimeError(
|
|
331
|
+
f"Meta rejected the pages request (HTTP {respuesta.status_code})."
|
|
332
|
+
)
|
|
333
|
+
try:
|
|
334
|
+
paginas = respuesta.json()["data"]
|
|
335
|
+
except Exception:
|
|
336
|
+
raise RuntimeError(
|
|
337
|
+
"Meta returned an unexpected format while listing pages"
|
|
338
|
+
) from None
|
|
339
|
+
|
|
340
|
+
if not isinstance(paginas, list):
|
|
341
|
+
raise RuntimeError(
|
|
342
|
+
"Meta returned an unexpected format while listing pages"
|
|
343
|
+
)
|
|
344
|
+
return paginas
|
socialctl/brands.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Load and create explicitly named brands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import yaml
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
|
|
14
|
+
from socialctl.models import Platform
|
|
15
|
+
from socialctl.rutas import validar_componente_de_ruta
|
|
16
|
+
|
|
17
|
+
PLANTILLAS = Path(__file__).parent / "plantillas"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class BrandNoEncontrada(Exception):
|
|
21
|
+
"""The requested brand directory does not exist."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class BrandYaExiste(Exception):
|
|
25
|
+
"""The requested brand directory already exists."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class NombreDeMarcaInvalido(Exception):
|
|
29
|
+
"""The brand name is not a safe direct child of the workspace root."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AccountsInvalido(Exception):
|
|
33
|
+
"""accounts.yml cannot be interpreted as account configuration."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _validar_nombre_de_marca(raiz_social: Path, nombre: str) -> Path:
|
|
37
|
+
"""Validate a brand name and return its directory within the workspace root."""
|
|
38
|
+
return validar_componente_de_ruta(
|
|
39
|
+
raiz_social, nombre, NombreDeMarcaInvalido, "brand name"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Brand(BaseModel):
|
|
44
|
+
nombre: str
|
|
45
|
+
raiz: Path
|
|
46
|
+
voz: str = ""
|
|
47
|
+
cuentas: dict = {}
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def dir_posts(self) -> Path:
|
|
51
|
+
return self.raiz / "posts"
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def dir_secretos(self) -> Path:
|
|
55
|
+
return self.raiz / ".secrets"
|
|
56
|
+
|
|
57
|
+
def leer_secreto(self, platform: Platform) -> dict:
|
|
58
|
+
fichero = self.dir_secretos / f"{platform.value}.json"
|
|
59
|
+
if not fichero.exists():
|
|
60
|
+
return {}
|
|
61
|
+
return json.loads(fichero.read_text(encoding="utf-8"))
|
|
62
|
+
|
|
63
|
+
def guardar_secreto(self, platform: Platform, datos: dict) -> None:
|
|
64
|
+
self.dir_secretos.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
self.dir_secretos.chmod(0o700)
|
|
66
|
+
|
|
67
|
+
fichero = self.dir_secretos / f"{platform.value}.json"
|
|
68
|
+
contenido = json.dumps(datos, indent=2).encode("utf-8")
|
|
69
|
+
|
|
70
|
+
descriptor = os.open(fichero, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
71
|
+
try:
|
|
72
|
+
# os.open solo aplica el modo 0o600 al CREAR el fichero: si ya
|
|
73
|
+
# existia con permisos mas laxos, hay que forzarlos aqui, antes
|
|
74
|
+
# de escribir el contenido, para que el secreto nunca toque disco
|
|
75
|
+
# con permisos mas abiertos que 0o600 (ni siquiera un instante).
|
|
76
|
+
os.fchmod(descriptor, 0o600)
|
|
77
|
+
os.write(descriptor, contenido)
|
|
78
|
+
finally:
|
|
79
|
+
os.close(descriptor)
|
|
80
|
+
|
|
81
|
+
def guardar_open_id_tiktok(self, open_id: str) -> str | None:
|
|
82
|
+
"""Write open_id to tiktok.open_id in accounts.yml without rewriting other configuration."""
|
|
83
|
+
fichero = self.raiz / "accounts.yml"
|
|
84
|
+
texto = fichero.read_text(encoding="utf-8") if fichero.exists() else ""
|
|
85
|
+
lineas = texto.splitlines(keepends=True)
|
|
86
|
+
|
|
87
|
+
idx_tiktok = next(
|
|
88
|
+
(i for i, linea in enumerate(lineas) if re.match(r"^tiktok:\s*(#.*)?$", linea)),
|
|
89
|
+
None,
|
|
90
|
+
)
|
|
91
|
+
if idx_tiktok is None:
|
|
92
|
+
raise AccountsInvalido(
|
|
93
|
+
f"the 'tiktok:' section was not found in {fichero}; could not save open_id automatically. Add the section manually (see socialctl/plantillas/accounts.yml) and try again."
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
idx_open_id = None
|
|
97
|
+
for i in range(idx_tiktok + 1, len(lineas)):
|
|
98
|
+
# Una línea que no empieza por espacio/tab (y no está en blanco)
|
|
99
|
+
# marca el fin de la sección tiktok: dejar de buscar ahí.
|
|
100
|
+
if lineas[i].strip() and not lineas[i][0].isspace():
|
|
101
|
+
break
|
|
102
|
+
coincidencia = re.match(r"^([ \t]*)open_id:.*$", lineas[i])
|
|
103
|
+
if coincidencia:
|
|
104
|
+
idx_open_id = i
|
|
105
|
+
break
|
|
106
|
+
|
|
107
|
+
if idx_open_id is None:
|
|
108
|
+
raise AccountsInvalido(
|
|
109
|
+
f"the 'open_id:' key was not found within 'tiktok:' in {fichero}; could not save it automatically. Add it manually (see socialctl/plantillas/accounts.yml)."
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
anterior = (self.cuentas.get("tiktok") or {}).get("open_id") or None
|
|
113
|
+
|
|
114
|
+
indentacion = re.match(r"^([ \t]*)", lineas[idx_open_id]).group(1)
|
|
115
|
+
valor_escapado = open_id.replace("\\", "\\\\").replace('"', '\\"')
|
|
116
|
+
lineas[idx_open_id] = f'{indentacion}open_id: "{valor_escapado}"\n'
|
|
117
|
+
|
|
118
|
+
fichero.write_text("".join(lineas), encoding="utf-8")
|
|
119
|
+
|
|
120
|
+
# Mantener en memoria la marca ya cargada en sintonía con lo que se
|
|
121
|
+
# acaba de escribir en disco, por si algo más adelante en el mismo
|
|
122
|
+
# proceso vuelve a leer brand.cuentas sin recargar desde el fichero.
|
|
123
|
+
self.cuentas.setdefault("tiktok", {})["open_id"] = open_id
|
|
124
|
+
|
|
125
|
+
if anterior is not None and anterior != open_id:
|
|
126
|
+
return anterior
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def crear_brand(raiz_social: Path, nombre: str) -> Brand:
|
|
131
|
+
"""Create a new brand directory from templates."""
|
|
132
|
+
raiz = _validar_nombre_de_marca(raiz_social, nombre)
|
|
133
|
+
if raiz.exists():
|
|
134
|
+
raise BrandYaExiste(
|
|
135
|
+
f"a directory already exists for '{nombre}' in {raiz}"
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
for sub in ("media", "posts", ".secrets"):
|
|
139
|
+
(raiz / sub).mkdir(parents=True)
|
|
140
|
+
(raiz / ".secrets").chmod(0o700)
|
|
141
|
+
|
|
142
|
+
shutil.copy(PLANTILLAS / "brand.md", raiz / "brand.md")
|
|
143
|
+
shutil.copy(PLANTILLAS / "accounts.yml", raiz / "accounts.yml")
|
|
144
|
+
shutil.copy(PLANTILLAS / "estrategia.md", raiz / "estrategia.md")
|
|
145
|
+
(raiz / "historial.md").write_text(f"# History of {nombre}\n", encoding="utf-8")
|
|
146
|
+
|
|
147
|
+
return cargar_brand(raiz_social, nombre)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def cargar_brand(raiz_social: Path, nombre: str, *, cleanup: bool = True) -> Brand:
|
|
151
|
+
"""Load an existing explicitly named brand."""
|
|
152
|
+
raiz = _validar_nombre_de_marca(raiz_social, nombre)
|
|
153
|
+
if not raiz.is_dir():
|
|
154
|
+
disponibles = sorted(
|
|
155
|
+
p.name for p in raiz_social.iterdir()
|
|
156
|
+
if p.is_dir() and (p / "brand.md").exists()
|
|
157
|
+
)
|
|
158
|
+
raise BrandNoEncontrada(
|
|
159
|
+
f"brand does not exist: '{nombre}' in {raiz_social}. "
|
|
160
|
+
f"Available brands: {', '.join(disponibles) or 'none'}"
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
fichero_voz = raiz / "brand.md"
|
|
164
|
+
fichero_cuentas = raiz / "accounts.yml"
|
|
165
|
+
|
|
166
|
+
cuentas: dict = {}
|
|
167
|
+
if fichero_cuentas.exists():
|
|
168
|
+
try:
|
|
169
|
+
contenido_cuentas = yaml.safe_load(
|
|
170
|
+
fichero_cuentas.read_text(encoding="utf-8")
|
|
171
|
+
)
|
|
172
|
+
except yaml.YAMLError as exc:
|
|
173
|
+
raise AccountsInvalido(
|
|
174
|
+
f"'{fichero_cuentas}' is not valid YAML: {exc}"
|
|
175
|
+
) from exc
|
|
176
|
+
|
|
177
|
+
if contenido_cuentas is None:
|
|
178
|
+
cuentas = {}
|
|
179
|
+
elif isinstance(contenido_cuentas, dict):
|
|
180
|
+
cuentas = contenido_cuentas
|
|
181
|
+
else:
|
|
182
|
+
raise AccountsInvalido(
|
|
183
|
+
f"'{fichero_cuentas}' must contain an account mapping (key: value); received {type(contenido_cuentas).__name__}"
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
if cleanup:
|
|
187
|
+
from socialctl.youtube_data import enforce_youtube_data
|
|
188
|
+
enforce_youtube_data(raiz)
|
|
189
|
+
return Brand(
|
|
190
|
+
nombre=nombre,
|
|
191
|
+
raiz=raiz,
|
|
192
|
+
voz=fichero_voz.read_text(encoding="utf-8") if fichero_voz.exists() else "",
|
|
193
|
+
cuentas=cuentas,
|
|
194
|
+
)
|