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.

Files changed (224) hide show
  1. socialcli-0.3.0.dist-info/METADATA +168 -0
  2. socialcli-0.3.0.dist-info/RECORD +224 -0
  3. socialcli-0.3.0.dist-info/WHEEL +4 -0
  4. socialcli-0.3.0.dist-info/entry_points.txt +3 -0
  5. socialcli-0.3.0.dist-info/licenses/LICENSE +21 -0
  6. socialctl/__init__.py +0 -0
  7. socialctl/__main__.py +6 -0
  8. socialctl/adapters/__init__.py +6 -0
  9. socialctl/adapters/base.py +68 -0
  10. socialctl/adapters/errores.py +56 -0
  11. socialctl/adapters/facebook.py +180 -0
  12. socialctl/adapters/instagram.py +442 -0
  13. socialctl/adapters/tiktok.py +535 -0
  14. socialctl/adapters/youtube.py +331 -0
  15. socialctl/agent_errors.py +176 -0
  16. socialctl/agent_guide.py +23 -0
  17. socialctl/approval_cli.py +27 -0
  18. socialctl/auth.py +139 -0
  19. socialctl/authflow.py +344 -0
  20. socialctl/brands.py +194 -0
  21. socialctl/capabilities.py +57 -0
  22. socialctl/capabilities.yml +5945 -0
  23. socialctl/cli.py +1766 -0
  24. socialctl/connection_service/__init__.py +0 -0
  25. socialctl/connection_service/__main__.py +33 -0
  26. socialctl/connection_service/app.py +496 -0
  27. socialctl/connection_service/assets/PLATFORM-LOGOS-LICENSE.txt +50 -0
  28. socialctl/connection_service/assets/connection.css +75 -0
  29. socialctl/connection_service/assets/facebook.webp +0 -0
  30. socialctl/connection_service/assets/instagram.svg +194 -0
  31. socialctl/connection_service/assets/socialcli-logo.png +0 -0
  32. socialctl/connection_service/assets/tiktok.png +0 -0
  33. socialctl/connection_service/assets/youtube.svg +1 -0
  34. socialctl/connection_service/landing.py +146 -0
  35. socialctl/connection_service/legacy.py +48 -0
  36. socialctl/connection_service/peer.py +13 -0
  37. socialctl/connection_service/providers.py +324 -0
  38. socialctl/connection_service/settings.py +76 -0
  39. socialctl/connection_service/store.py +124 -0
  40. socialctl/connections/__init__.py +0 -0
  41. socialctl/connections/account_cli.py +438 -0
  42. socialctl/connections/cli.py +379 -0
  43. socialctl/connections/client.py +126 -0
  44. socialctl/connections/credential_cli.py +224 -0
  45. socialctl/connections/credential_store.py +49 -0
  46. socialctl/connections/file_store.py +193 -0
  47. socialctl/connections/keychain.py +78 -0
  48. socialctl/connections/meta_user.py +81 -0
  49. socialctl/connections/rotation_cli.py +116 -0
  50. socialctl/connections/saas_client.py +91 -0
  51. socialctl/connections/temporary_media.py +62 -0
  52. socialctl/delivery_options.py +70 -0
  53. socialctl/engagement.py +200 -0
  54. socialctl/engagement_cli.py +50 -0
  55. socialctl/executor.py +135 -0
  56. socialctl/formatter.py +291 -0
  57. socialctl/hosted_media.py +152 -0
  58. socialctl/identity.py +200 -0
  59. socialctl/inbox.py +298 -0
  60. socialctl/inbox_cli.py +46 -0
  61. socialctl/inventory.py +155 -0
  62. socialctl/inventory_cli.py +171 -0
  63. socialctl/inventory_sources.py +101 -0
  64. socialctl/management/__init__.py +1 -0
  65. socialctl/management/batch_cli.py +115 -0
  66. socialctl/management/batches.py +325 -0
  67. socialctl/management/changes.py +485 -0
  68. socialctl/management/cli.py +227 -0
  69. socialctl/management/comments_cli.py +352 -0
  70. socialctl/management/community_batch_cli.py +63 -0
  71. socialctl/management/community_batches.py +294 -0
  72. socialctl/management/community_changes.py +370 -0
  73. socialctl/management/community_cli.py +142 -0
  74. socialctl/management/community_schema.py +119 -0
  75. socialctl/management/facebook_groups.py +1118 -0
  76. socialctl/management/groups_cli.py +274 -0
  77. socialctl/management/inbox_readers.py +87 -0
  78. socialctl/management/meta_changes.py +197 -0
  79. socialctl/management/meta_cli.py +342 -0
  80. socialctl/management/meta_client.py +631 -0
  81. socialctl/management/meta_comment_inbox.py +565 -0
  82. socialctl/management/meta_comments.py +587 -0
  83. socialctl/management/meta_errors.py +59 -0
  84. socialctl/management/meta_pagination.py +52 -0
  85. socialctl/management/meta_schema.py +128 -0
  86. socialctl/management/meta_webhooks.py +147 -0
  87. socialctl/management/metadata_parts.py +144 -0
  88. socialctl/management/owned_assets.py +85 -0
  89. socialctl/management/owned_changes.py +470 -0
  90. socialctl/management/owned_cli.py +128 -0
  91. socialctl/management/owned_reorder.py +62 -0
  92. socialctl/management/owned_schema.py +368 -0
  93. socialctl/management/reply_context.py +24 -0
  94. socialctl/management/reply_evidence.py +80 -0
  95. socialctl/management/resource_changes.py +407 -0
  96. socialctl/management/resource_cli.py +136 -0
  97. socialctl/management/snippet_readback.py +26 -0
  98. socialctl/management/stories.py +713 -0
  99. socialctl/management/stories_cli.py +172 -0
  100. socialctl/management/youtube.py +323 -0
  101. socialctl/management/youtube_community.py +326 -0
  102. socialctl/management/youtube_metadata.py +122 -0
  103. socialctl/management/youtube_owned.py +259 -0
  104. socialctl/management/youtube_resources.py +305 -0
  105. socialctl/media.py +269 -0
  106. socialctl/media_cli.py +183 -0
  107. socialctl/media_ingress.py +211 -0
  108. socialctl/media_preflight.py +114 -0
  109. socialctl/media_registry.py +301 -0
  110. socialctl/media_requirements.py +52 -0
  111. socialctl/metricas/__init__.py +6 -0
  112. socialctl/metricas/almacen.py +133 -0
  113. socialctl/metricas/analytics_changes.py +295 -0
  114. socialctl/metricas/analytics_cli.py +191 -0
  115. socialctl/metricas/analytics_client.py +304 -0
  116. socialctl/metricas/analytics_ingest.py +260 -0
  117. socialctl/metricas/analytics_schema.py +174 -0
  118. socialctl/metricas/base.py +147 -0
  119. socialctl/metricas/facebook.py +418 -0
  120. socialctl/metricas/insights.py +227 -0
  121. socialctl/metricas/instagram.py +133 -0
  122. socialctl/metricas/modelos.py +87 -0
  123. socialctl/metricas/piezas.py +134 -0
  124. socialctl/metricas/reach.py +94 -0
  125. socialctl/metricas/reporting_client.py +112 -0
  126. socialctl/metricas/resumen.py +251 -0
  127. socialctl/metricas/slugs.py +89 -0
  128. socialctl/metricas/tiktok.py +185 -0
  129. socialctl/metricas/youtube.py +403 -0
  130. socialctl/migration_cli.py +60 -0
  131. socialctl/migration_files.py +68 -0
  132. socialctl/models.py +150 -0
  133. socialctl/native_schedule/__init__.py +13 -0
  134. socialctl/native_schedule/api_delivery.py +15 -0
  135. socialctl/native_schedule/api_reconcile.py +40 -0
  136. socialctl/native_schedule/approval.py +160 -0
  137. socialctl/native_schedule/cadence.py +73 -0
  138. socialctl/native_schedule/cli.py +510 -0
  139. socialctl/native_schedule/dispatcher.py +120 -0
  140. socialctl/native_schedule/facebook.py +112 -0
  141. socialctl/native_schedule/facebook_reels.py +140 -0
  142. socialctl/native_schedule/first_comment.py +181 -0
  143. socialctl/native_schedule/handoff.py +193 -0
  144. socialctl/native_schedule/instagram_delivery.py +224 -0
  145. socialctl/native_schedule/instagram_runtime.py +66 -0
  146. socialctl/native_schedule/inventory.py +70 -0
  147. socialctl/native_schedule/migration.py +310 -0
  148. socialctl/native_schedule/models.py +103 -0
  149. socialctl/native_schedule/reconcile.py +83 -0
  150. socialctl/native_schedule/remote_data.py +177 -0
  151. socialctl/native_schedule/report.py +151 -0
  152. socialctl/native_schedule/store.py +560 -0
  153. socialctl/native_schedule/verification.py +32 -0
  154. socialctl/native_schedule/youtube.py +339 -0
  155. socialctl/operation_output.py +87 -0
  156. socialctl/plantillas/accounts.yml +33 -0
  157. socialctl/plantillas/brand.md +95 -0
  158. socialctl/plantillas/estrategia.md +64 -0
  159. socialctl/postfile.py +240 -0
  160. socialctl/preflight.py +73 -0
  161. socialctl/publication_policy.py +34 -0
  162. socialctl/publication_status.py +75 -0
  163. socialctl/publication_steps.py +398 -0
  164. socialctl/publish_output.py +96 -0
  165. socialctl/publisher.py +445 -0
  166. socialctl/queue_migration.py +302 -0
  167. socialctl/read_budget.py +224 -0
  168. socialctl/read_reports.py +91 -0
  169. socialctl/rutas.py +103 -0
  170. socialctl/saas/__init__.py +1 -0
  171. socialctl/saas/access.py +75 -0
  172. socialctl/saas/auth.py +213 -0
  173. socialctl/saas/backup.py +124 -0
  174. socialctl/saas/billing.py +327 -0
  175. socialctl/saas/billing_enrollment.py +1237 -0
  176. socialctl/saas/billing_enrollment_schema.py +519 -0
  177. socialctl/saas/billing_inventory.py +775 -0
  178. socialctl/saas/billing_reconcile.py +127 -0
  179. socialctl/saas/billing_routes.py +27 -0
  180. socialctl/saas/budget.py +66 -0
  181. socialctl/saas/connections.py +374 -0
  182. socialctl/saas/consent.py +52 -0
  183. socialctl/saas/lifecycle.py +341 -0
  184. socialctl/saas/mail.py +89 -0
  185. socialctl/saas/maintenance.py +68 -0
  186. socialctl/saas/meta_user.py +298 -0
  187. socialctl/saas/meta_webhooks.py +358 -0
  188. socialctl/saas/migration.py +107 -0
  189. socialctl/saas/models.py +8 -0
  190. socialctl/saas/operations.py +102 -0
  191. socialctl/saas/pages.py +126 -0
  192. socialctl/saas/pairing.py +158 -0
  193. socialctl/saas/permissions.py +57 -0
  194. socialctl/saas/reminders.py +128 -0
  195. socialctl/saas/renewal.py +74 -0
  196. socialctl/saas/restore_guard.py +229 -0
  197. socialctl/saas/revocation.py +97 -0
  198. socialctl/saas/rotation.py +114 -0
  199. socialctl/saas/routes.py +221 -0
  200. socialctl/saas/settings.py +34 -0
  201. socialctl/saas/store.py +106 -0
  202. socialctl/saas/temporary_media.py +245 -0
  203. socialctl/saas/webhooks.py +76 -0
  204. socialctl/schedule_remote.py +262 -0
  205. socialctl/schedule_sqlite.py +44 -0
  206. socialctl/scheduler.py +581 -0
  207. socialctl/scheduler_launchd.py +319 -0
  208. socialctl/scheduler_systemd.py +167 -0
  209. socialctl/source_links.py +119 -0
  210. socialctl/ui/__init__.py +1 -0
  211. socialctl/ui/assets/agent-manual.md +850 -0
  212. socialctl/ui/assets/agents.md +27 -0
  213. socialctl/ui/assets/components.css +20 -0
  214. socialctl/ui/assets/connection-layout.css +53 -0
  215. socialctl/ui/assets/otp.template.html +10 -0
  216. socialctl/ui/assets/tokens.css +1 -0
  217. socialctl/ui/assets/trial.template.html +11 -0
  218. socialctl/ui/assets/web-layout.css +38 -0
  219. socialctl/ui/email/otp.html +10 -0
  220. socialctl/ui/email/trial.html +11 -0
  221. socialctl/upload_consent.py +13 -0
  222. socialctl/workspace.py +8 -0
  223. socialctl/youtube_data.py +471 -0
  224. 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
+ )