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
@@ -0,0 +1,180 @@
1
+ 'Publish creator-owned content to a Facebook Page through Graph API v26.0.\n\nThe content type selects the feed, photo or video endpoint. Missing account\nconfiguration and expected I/O/network failures produce actionable results.\nMalformed responses after acceptance carry duplicate risk: check the remote\naccount before retrying. Never interpolate request headers or credentials into\nerrors; known secrets are redacted from provider response text.'
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ import httpx
8
+
9
+ from socialctl.adapters.base import ADAPTADORES, Adapter
10
+ from socialctl.adapters.errores import mensaje_de_error
11
+ from socialctl.auth import obtener_token
12
+ from socialctl.brands import Brand
13
+ from socialctl.formatter import PLATFORM_SPECS, componer_caption
14
+ from socialctl.models import (
15
+ MediaAsset,
16
+ MediaKind,
17
+ Platform,
18
+ PlatformPost,
19
+ PostResult,
20
+ PostStatus,
21
+ ValidationError,
22
+ )
23
+
24
+ GRAFO = "https://graph.facebook.com/v26.0"
25
+ # v21.0 (versión anterior) caduca el 21 de enero de 2027 ("API Version
26
+ # Deprecations", developers.facebook.com/docs/graph-api/changelog/version26.0,
27
+ # confirmado con Context7); v26.0 es la vigente (introducida el 29 de julio
28
+ # de 2026, según el mismo changelog). Los endpoints y campos que usa este
29
+ # módulo (POST /{page_id}/feed con message, POST /{page_id}/photos con
30
+ # caption+source, POST /{page_id}/videos con description+source) siguen
31
+ # documentados igual en v26.0 (confirmado contra los ejemplos vigentes de
32
+ # video-uploads y event/videos, que ya usan v26.0 en su propia URL de
33
+ # ejemplo): no hay cambios de forma, solo de número de versión en la URL.
34
+
35
+
36
+ class FacebookAdapter(Adapter):
37
+ 'Publish to a Facebook Page using the endpoint matching the content type.'
38
+
39
+ platform = Platform.FACEBOOK
40
+
41
+ def validate(self, post: PlatformPost, brand: Brand) -> list[ValidationError]:
42
+ 'Add the required Page ID check to platform validation.'
43
+ errores = super().validate(post, brand)
44
+ page_id = (brand.cuentas.get("facebook") or {}).get("page_id")
45
+ if not page_id:
46
+ errores.append(
47
+ ValidationError(
48
+ platform=self.platform,
49
+ campo="cuenta",
50
+ motivo=f"missing page_id in {brand.raiz / 'accounts.yml'}",
51
+ )
52
+ )
53
+ return errores
54
+
55
+ def publish(self, post: PlatformPost, brand: Brand, client: httpx.Client) -> PostResult:
56
+ 'Publish a validated post; convert every failure to an error PostResult.'
57
+ spec = PLATFORM_SPECS[self.platform]
58
+ if len(post.media) > spec.max_media:
59
+ return self._error(
60
+ f"this endpoint supports at most {spec.max_media} file; "
61
+ 'it does not publish carousels'
62
+ )
63
+
64
+ try:
65
+ return self._publicar(post, brand, client)
66
+ except Exception as exc:
67
+ return self._error(
68
+ f"an unexpected Facebook error occurred ({type(exc).__name__})",
69
+ riesgo_duplicado=True,
70
+ )
71
+
72
+ def _publicar(self, post: PlatformPost, brand: Brand, client: httpx.Client) -> PostResult:
73
+ 'Execute publication with explicit handling of expected failures.'
74
+ page_id = (brand.cuentas.get("facebook") or {}).get("page_id")
75
+ if not page_id:
76
+ return self._error(f"missing page_id in {brand.raiz / 'accounts.yml'}")
77
+
78
+ try:
79
+ token = obtener_token(brand, self.platform, client)
80
+ except Exception as exc:
81
+ return self._error(str(exc))
82
+
83
+ texto = componer_caption(post.body, post.hashtags)
84
+ if post.link:
85
+ texto = f"{texto}\n\n{post.link}"
86
+
87
+ asset: MediaAsset | None = post.media[0] if post.media else None
88
+
89
+ try:
90
+ if asset is None:
91
+ respuesta = client.post(
92
+ f"{GRAFO}/{page_id}/feed",
93
+ data={"message": texto, "access_token": token},
94
+ )
95
+ else:
96
+ es_video = asset.kind is MediaKind.VIDEO
97
+ endpoint = "videos" if es_video else "photos"
98
+ campo_texto = "description" if es_video else "caption"
99
+ with asset.path.open("rb") as fichero:
100
+ respuesta = client.post(
101
+ f"{GRAFO}/{page_id}/{endpoint}",
102
+ data={campo_texto: texto, "access_token": token},
103
+ files={"source": fichero},
104
+ timeout=None,
105
+ )
106
+ except FileNotFoundError:
107
+ return self._error(f"the file no longer exists on disk: {asset.path}")
108
+ except IsADirectoryError:
109
+ return self._error(
110
+ f"the file path points to a directory, not a file: {asset.path}"
111
+ )
112
+ except PermissionError:
113
+ return self._error(f"the file is not readable: {asset.path}")
114
+ except OSError as exc:
115
+ # Cualquier otro fallo de E/S al leer el fichero (disco dañado,
116
+ # demasiados descriptores abiertos, etc.): no es ninguno de los
117
+ # casos anteriores, pero tampoco debe escapar como excepción.
118
+ return self._error(f"could not read the file from disk ({asset.path}): {exc}")
119
+ except httpx.InvalidURL:
120
+ # La URL se construye con el page_id de accounts.yml: si contiene
121
+ # un carácter no válido (p. ej. un salto de línea colado por un
122
+ # error de copia/pega), httpx la rechaza al construir la petición,
123
+ # antes de llegar a la red.
124
+ return self._error(
125
+ f"the configured page_id ({page_id!r}) produces an invalid URL"
126
+ )
127
+ except httpx.TimeoutException:
128
+ # Subclase de httpx.HTTPError: debe ir antes que ese except para
129
+ # no quedar inalcanzable.
130
+ return self._error(
131
+ 'timed out contacting Facebook',
132
+ riesgo_duplicado=True,
133
+ )
134
+ except httpx.HTTPError:
135
+ return self._error(
136
+ 'could not connect to Facebook to publish',
137
+ riesgo_duplicado=True,
138
+ )
139
+
140
+ if respuesta.status_code != 200:
141
+ return self._error(mensaje_de_error(respuesta, token))
142
+
143
+ try:
144
+ post_id = respuesta.json()["id"]
145
+ except json.JSONDecodeError:
146
+ return self._error(
147
+ 'Facebook returned an invalid JSON response after publishing',
148
+ riesgo_duplicado=True,
149
+ )
150
+ except KeyError:
151
+ return self._error(
152
+ 'Facebook returned no post ID',
153
+ riesgo_duplicado=True,
154
+ )
155
+ if not isinstance(post_id, str) or not post_id.strip():
156
+ return self._error(
157
+ 'Facebook returned an unexpected post ID '
158
+ '(expected a nonempty string)',
159
+ riesgo_duplicado=True,
160
+ )
161
+
162
+ # The publisher durably saves this media ID before the independent
163
+ # first-comment step. Adapters must never perform that second mutation.
164
+ return PostResult(
165
+ platform=self.platform,
166
+ status=PostStatus.PUBLICADO,
167
+ platform_id=post_id,
168
+ url=f"https://www.facebook.com/{post_id}",
169
+ )
170
+
171
+ def _error(self, mensaje: str, *, riesgo_duplicado: bool = False) -> PostResult:
172
+ return PostResult(
173
+ platform=self.platform,
174
+ status=PostStatus.ERROR,
175
+ error=mensaje,
176
+ riesgo_duplicado=riesgo_duplicado,
177
+ )
178
+
179
+
180
+ ADAPTADORES[Platform.FACEBOOK] = FacebookAdapter
@@ -0,0 +1,442 @@
1
+ 'Publish Instagram media through Graph API v26.0 container creation and delivery.\n\nThe provider fetches user-supplied files from a configured public HTTPS host.\nCreate a container, poll processing, then publish it and read its permalink.\nFailures after a potentially accepted publication report duplicate risk rather\nthan silently retrying. Unexpected IDs are rejected. Known access tokens are\nredacted even when a proxy reflects the failed request in a response.'
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import time
7
+ from urllib.parse import quote
8
+
9
+ import httpx
10
+
11
+ from socialctl.adapters.base import ADAPTADORES, Adapter
12
+ from socialctl.adapters.errores import mensaje_de_error
13
+ from socialctl.auth import obtener_token
14
+ from socialctl.brands import Brand
15
+ from socialctl.formatter import PLATFORM_SPECS, componer_caption
16
+ from socialctl.media import ruta_relativa_efectiva
17
+ from socialctl.models import (
18
+ MediaKind,
19
+ Platform,
20
+ PlatformPost,
21
+ PostResult,
22
+ PostStatus,
23
+ ValidationError,
24
+ )
25
+
26
+ GRAFO = "https://graph.facebook.com/v26.0"
27
+ # v21.0 (versión anterior) caduca el 21 de enero de 2027 ("API Version
28
+ # Deprecations", developers.facebook.com/docs/graph-api/changelog/version26.0,
29
+ # confirmado con Context7); v26.0 es la vigente (introducida el 29 de julio
30
+ # de 2026, según el mismo changelog). Los endpoints y campos que usa este
31
+ # módulo (POST /{ig_user_id}/media, GET /{id}?fields=status_code, POST
32
+ # /{ig_user_id}/media_publish, GET /{id}?fields=permalink) siguen
33
+ # documentados igual en v26.0: no hay cambios de forma, solo de número de
34
+ # versión en la URL.
35
+
36
+
37
+ class InstagramAdapter(Adapter):
38
+ 'Publish Instagram media using the Graph API container workflow.'
39
+
40
+ platform = Platform.INSTAGRAM
41
+
42
+ def __init__(self, espera_s: float = 60.0, intentos: int = 5) -> None:
43
+ self.espera_s = espera_s
44
+ self.intentos = intentos
45
+
46
+ def validate(self, post: PlatformPost, brand: Brand) -> list[ValidationError]:
47
+ 'Add Instagram account and public media hosting checks.'
48
+ errores = super().validate(post, brand)
49
+ cuenta = brand.cuentas.get("instagram") or {}
50
+
51
+ if not cuenta.get("ig_user_id"):
52
+ errores.append(
53
+ ValidationError(
54
+ platform=self.platform,
55
+ campo="cuenta",
56
+ motivo=f"missing ig_user_id in {brand.raiz / 'accounts.yml'}",
57
+ )
58
+ )
59
+
60
+ from socialctl.connections.temporary_media import available
61
+ if not cuenta.get("media_url_base") and not available(brand):
62
+ errores.append(
63
+ ValidationError(
64
+ platform=self.platform,
65
+ campo="cuenta",
66
+ motivo=(
67
+ 'missing media_url_base in accounts.yml: Instagram requires '
68
+ 'a public media URL (direct file upload is '
69
+ 'not supported); specify the host serving '
70
+ "your files, for example 'https://cdn.example.com'"
71
+ ),
72
+ )
73
+ )
74
+
75
+ return errores
76
+
77
+ def publish(self, post: PlatformPost, brand: Brand, client: httpx.Client) -> PostResult:
78
+ 'Publish a validated post; convert every failure to an error PostResult.'
79
+ spec = PLATFORM_SPECS[self.platform]
80
+ if len(post.media) > spec.max_media:
81
+ return self._error(
82
+ f"this endpoint supports at most {spec.max_media} file; "
83
+ 'it does not publish carousels'
84
+ )
85
+
86
+ try:
87
+ from socialctl.connections.temporary_media import available,upload
88
+ if not (brand.cuentas.get('instagram') or {}).get('media_url_base') and available(brand) and post.media:
89
+ with upload(brand,post.media[0].path,client) as url:
90
+ return self._publicar(post,brand,client,media_url=url)
91
+ return self._publicar(post, brand, client)
92
+ except Exception as exc:
93
+ return self._error(
94
+ f"an unexpected Instagram error occurred ({type(exc).__name__})"
95
+ )
96
+
97
+ def _publicar(self, post: PlatformPost, brand: Brand, client: httpx.Client, *, media_url=None) -> PostResult:
98
+ 'Execute publication with explicit handling of expected failures.'
99
+ cuenta = brand.cuentas.get("instagram") or {}
100
+
101
+ ig_user_id = cuenta.get("ig_user_id")
102
+ if not ig_user_id:
103
+ return self._error(f"missing ig_user_id in {brand.raiz / 'accounts.yml'}")
104
+
105
+ base = cuenta.get("media_url_base")
106
+ if not base and not media_url:
107
+ return self._error(
108
+ 'missing media_url_base in accounts.yml: Instagram requires a '
109
+ 'public media URL (it does not support direct '
110
+ 'file upload); specify the host serving your files, '
111
+ "for example 'https://cdn.example.com'"
112
+ )
113
+
114
+ if not post.media:
115
+ return self._error(
116
+ 'no image or video to publish: the post has no media attached'
117
+ )
118
+ asset = post.media[0]
119
+
120
+ try:
121
+ token = obtener_token(brand, self.platform, client)
122
+ except Exception as exc:
123
+ return self._error(str(exc))
124
+
125
+ # `ruta_relativa_efectiva` conserva la subcarpeta del archivo (p.
126
+ # ej. "short/S2.mp4", si el usuario guarda sus verticales en
127
+ # <Marca>/media/short/). Se separa por '/' y se codifica cada
128
+ # SEGMENTO por su cuenta con `quote` (espacios, acentos, 'ñ'...);
129
+ # las '/' que separan subcarpetas se reintroducen después de
130
+ # codificar, así que nunca pasan por `quote` y no pueden acabar
131
+ # codificadas como '%2F' -que convertiría "short/S2.mp4" en una URL
132
+ # de un solo segmento que el host del usuario no serviría igual
133
+ # que la carpeta real-.
134
+ segmentos_media = ruta_relativa_efectiva(asset).split("/")
135
+ ruta_codificada = "/".join(quote(segmento) for segmento in segmentos_media)
136
+ url_media = media_url or f"{base.rstrip('/')}/{ruta_codificada}"
137
+
138
+ # Instagram descarga la media desde esta URL en un proceso asíncrono
139
+ # (el sondeo del paso 2): si el archivo aún no está subido al host,
140
+ # el fallo llega minutos después y de forma opaca. Comprobarlo aquí
141
+ # con un HEAD lo convierte en un error inmediato y accionable.
142
+ try:
143
+ sonda = client.head(url_media, follow_redirects=True, timeout=15)
144
+ except httpx.InvalidURL:
145
+ # La URL se construye con media_url_base (de accounts.yml) más
146
+ # el nombre del fichero: si contiene un carácter no válido (p.
147
+ # ej. un salto de línea colado por un error de copia/pega),
148
+ # httpx la rechaza al construir la petición, antes de tocar la
149
+ # red. Esta excepción NO hereda de httpx.HTTPError.
150
+ return self._error(
151
+ f"media_url_base produces an invalid media URL: {url_media!r}"
152
+ )
153
+ except httpx.TimeoutException:
154
+ # Subclase de httpx.HTTPError: debe ir antes que ese except para
155
+ # no quedar inalcanzable.
156
+ return self._error(
157
+ f"timed out checking whether {url_media} "
158
+ 'is accessible'
159
+ )
160
+ except httpx.HTTPError:
161
+ return self._error(
162
+ f"could not check whether {url_media} is accessible: check "
163
+ 'that the media_url_base host is running'
164
+ )
165
+
166
+ if sonda.status_code != 200:
167
+ return self._error(
168
+ f"{url_media} did not return HTTP 200 (returned "
169
+ f"{sonda.status_code}); upload the file to that host before "
170
+ 'publishing to Instagram'
171
+ )
172
+
173
+ campo_url = "video_url" if asset.kind is MediaKind.VIDEO else "image_url"
174
+ datos = {
175
+ campo_url: url_media,
176
+ "caption": componer_caption(post.body, post.hashtags),
177
+ "access_token": token,
178
+ }
179
+ if asset.kind is MediaKind.VIDEO:
180
+ # Sin media_type, la Graph API podría tratar el vídeo como un
181
+ # post de feed clásico en vez de un Reel; se fija de forma
182
+ # explícita para no depender de un valor por defecto que Meta
183
+ # puede cambiar.
184
+ datos["media_type"] = "REELS"
185
+
186
+ try:
187
+ creacion = client.post(f"{GRAFO}/{ig_user_id}/media", data=datos)
188
+ except httpx.InvalidURL:
189
+ # ig_user_id viene de accounts.yml: mismo razonamiento que con
190
+ # media_url_base más arriba, pero aquí construye la URL del
191
+ # propio Grafo.
192
+ return self._error(
193
+ f"the configured ig_user_id ({ig_user_id!r}) produces an invalid URL"
194
+ )
195
+ except httpx.TimeoutException:
196
+ return self._error(
197
+ 'timed out creating the Instagram media container'
198
+ )
199
+ except httpx.HTTPError:
200
+ return self._error(
201
+ 'could not connect to Instagram to create the media container'
202
+ )
203
+
204
+ if creacion.status_code != 200:
205
+ return self._error(mensaje_de_error(creacion, token))
206
+
207
+ try:
208
+ contenedor_id = creacion.json()["id"]
209
+ except json.JSONDecodeError:
210
+ return self._error(
211
+ 'Instagram returned an invalid JSON response while '
212
+ 'creating the media container'
213
+ )
214
+ except KeyError:
215
+ return self._error('Instagram returned no media container ID')
216
+
217
+ # A diferencia del permalink (cosmético: un fallo ahí degrada a
218
+ # `url=None` sin abortar), un `id` de contenedor que no sea una
219
+ # cadena de texto SÍ es un fallo real: sin un id de contenedor
220
+ # válido no hay forma de sondear su estado ni de publicarlo, así
221
+ # que se aborta aquí con un mensaje accionable en vez de dejar que
222
+ # ese valor siga circulando y explote más adelante -por ejemplo, al
223
+ # construir el `PostResult` final con un `platform_id` de tipo
224
+ # equivocado, que pydantic rechazaría con un `ValidationError` opaco
225
+ # que solo atraparía el resguardo genérico de `publish()`-.
226
+ if not isinstance(contenedor_id, str):
227
+ return self._error(
228
+ 'Instagram returned an unexpected media container '
229
+ 'ID (expected a string, received '
230
+ f"{type(contenedor_id).__name__}); cannot continue without "
231
+ 'a valid container ID'
232
+ )
233
+
234
+ error_de_sondeo = self._esperar_procesado(contenedor_id, token, client)
235
+ if error_de_sondeo is not None:
236
+ return error_de_sondeo
237
+
238
+ # Hallazgo de revisión (I1): a partir de aquí, `media_publish` es el
239
+ # punto sin retorno -exactamente como el último chunk en TikTok (ver
240
+ # `socialctl/adapters/tiktok.py`)-. Un timeout o un fallo de conexión
241
+ # AQUÍ no permiten saber si Instagram llegó a aceptar la petición
242
+ # antes de que la respuesta se perdiera: el envío pudo completarse en
243
+ # el servidor aunque el cliente nunca viera la confirmación. Antes,
244
+ # solo el quinto camino posible (un `id` de publicación no textual,
245
+ # más abajo) marcaba `riesgo_duplicado=True`; los otros cuatro -este
246
+ # timeout, este fallo de conexión, y los dos de una respuesta HTTP
247
+ # 200 ilegible que siguen debajo- son la MISMA situación (Instagram
248
+ # ya aceptó el contenido) y se dejaban en `False`, así que `retry`
249
+ # podía volver a llamar a `media_publish` sobre contenido ya
250
+ # publicado y duplicarlo -el timeout, además, es el fallo más
251
+ # probable de los cinco-.
252
+ try:
253
+ publicacion = client.post(
254
+ f"{GRAFO}/{ig_user_id}/media_publish",
255
+ data={"creation_id": contenedor_id, "access_token": token},
256
+ )
257
+ except httpx.TimeoutException:
258
+ return self._error(
259
+ 'timed out publishing to Instagram: cannot '
260
+ 'confirm whether Instagram accepted the post '
261
+ 'before the response was lost. Do NOT retry without '
262
+ 'checking the account first, to avoid duplicating the '
263
+ 'post.',
264
+ riesgo_duplicado=True,
265
+ )
266
+ except httpx.HTTPError:
267
+ return self._error(
268
+ 'could not connect to Instagram to publish: cannot '
269
+ 'confirm whether Instagram accepted the post before '
270
+ 'the connection was lost. Do NOT retry without checking '
271
+ 'the account first, to avoid duplicating the '
272
+ 'post.',
273
+ riesgo_duplicado=True,
274
+ )
275
+
276
+ if publicacion.status_code != 200:
277
+ return self._error(mensaje_de_error(publicacion, token))
278
+
279
+ try:
280
+ post_id = publicacion.json()["id"]
281
+ except json.JSONDecodeError:
282
+ return self._error(
283
+ 'Instagram returned an invalid JSON response after '
284
+ 'publishing: the post was accepted (HTTP 200), but its ID '
285
+ 'cannot be confirmed or reported. Do NOT retry without '
286
+ 'checking the account first, to avoid duplicating the '
287
+ 'post.',
288
+ riesgo_duplicado=True,
289
+ )
290
+ except KeyError:
291
+ return self._error(
292
+ 'Instagram returned no post ID: the '
293
+ 'post was accepted (HTTP 200), but its ID cannot be '
294
+ 'confirmed or reported. Do NOT retry without checking '
295
+ 'the account first, to avoid duplicating the '
296
+ 'post.',
297
+ riesgo_duplicado=True,
298
+ )
299
+
300
+ # Igual que con `contenedor_id` más arriba: un `id` de publicación
301
+ # que no sea una cadena de texto es un fallo real, no cosmético
302
+ # como el permalink. En este punto Instagram YA aceptó la
303
+ # publicación (media_publish devolvió 200), así que no hay nada que
304
+ # reintentar; pero sin un id de tipo correcto no se puede confirmar
305
+ # ni reportar esa publicación, así que se aborta con un mensaje
306
+ # accionable en vez de dejar que el valor llegue a `PostResult(
307
+ # platform_id=post_id)` y reviente ahí con un `ValidationError` de
308
+ # pydantic que el resguardo genérico de `publish()` convertiría en
309
+ # el mensaje opaco "ha ocurrido un error inesperado en Instagram" -
310
+ # perdiendo, además, el propio id que sí llegó a confirmarse.
311
+ #
312
+ # A diferencia de cualquier otro `ERROR` de este módulo, este caso
313
+ # concreto NO es seguro de reintentar: la publicación ya existe en
314
+ # Instagram (media_publish ya devolvió 200), el id solo hacía falta
315
+ # para confirmarla y reportarla. `retry` (en `socialctl/cli.py`)
316
+ # vuelve a publicar automáticamente todo lo que haya quedado en
317
+ # `ERROR`; si llamara de nuevo a `media_publish` sobre contenido ya
318
+ # publicado, crearía una segunda publicación duplicada y visible en
319
+ # la cuenta. Por eso, además de marcar `riesgo_duplicado=True` -la
320
+ # señal que `retry` de verdad consulta para negarse a reintentar
321
+ # esta red en automático-, el mensaje no se limita a describir el
322
+ # tipo inesperado: advierte de forma explícita de que la
323
+ # publicación probablemente ya está hecha y de que reintentar puede
324
+ # duplicarla, con una acción concreta (comprobar la cuenta antes de
325
+ # volver a publicar) para quien lo lea.
326
+ if not isinstance(post_id, str):
327
+ return self._error(
328
+ 'Instagram returned an unexpected post '
329
+ 'ID (expected a string, received '
330
+ f"{type(post_id).__name__}); the post was probably "
331
+ 'published on Instagram: media_publish accepted it and '
332
+ 'only its returned ID failed validation. Therefore '
333
+ 'do NOT retry: another publication could duplicate it. '
334
+ 'Check the account before publishing again.',
335
+ riesgo_duplicado=True,
336
+ )
337
+
338
+ # La publicación ya ha tenido éxito en este punto (media_publish
339
+ # devolvió 200 con un id): un fallo al pedir el permalink jamás
340
+ # puede convertir este resultado en ERROR, así que `_obtener_permalink`
341
+ # nunca lanza y, ante cualquier problema, devuelve `None` en vez de
342
+ # propagar o de inventar una URL a mano.
343
+ return PostResult(
344
+ platform=self.platform,
345
+ status=PostStatus.PUBLICADO,
346
+ platform_id=post_id,
347
+ url=self._obtener_permalink(post_id, token, client),
348
+ )
349
+
350
+ def _obtener_permalink(
351
+ self, media_id: str, token: str, client: httpx.Client
352
+ ) -> str | None:
353
+ 'Read the public permalink for published media, falling back to the ID URL.'
354
+ try:
355
+ respuesta = client.get(
356
+ f"{GRAFO}/{media_id}",
357
+ params={"fields": "permalink", "access_token": token},
358
+ )
359
+ if respuesta.status_code != 200:
360
+ return None
361
+ permalink = respuesta.json()["permalink"]
362
+ return permalink if isinstance(permalink, str) else None
363
+ except Exception:
364
+ return None
365
+
366
+ def _esperar_procesado(
367
+ self, contenedor_id: str, token: str, client: httpx.Client
368
+ ) -> PostResult | None:
369
+ 'Poll the media container until ready, failed, expired or timed out.\n\nFINISHED permits publication. ERROR and EXPIRED terminate with actionable\nerrors. Unknown or in-progress statuses keep polling up to the configured limit.\nNetwork and malformed-response failures return error text without throwing.'
370
+ for intento in range(self.intentos):
371
+ try:
372
+ estado = client.get(
373
+ f"{GRAFO}/{contenedor_id}",
374
+ params={"fields": "status_code", "access_token": token},
375
+ )
376
+ except httpx.TimeoutException:
377
+ return self._error(
378
+ 'timed out checking the status of the '
379
+ 'Instagram media container'
380
+ )
381
+ except httpx.HTTPError:
382
+ return self._error(
383
+ 'could not connect to Instagram to check the '
384
+ 'media container status'
385
+ )
386
+
387
+ if estado.status_code != 200:
388
+ return self._error(mensaje_de_error(estado, token))
389
+
390
+ try:
391
+ codigo = estado.json()["status_code"]
392
+ except json.JSONDecodeError:
393
+ return self._error(
394
+ 'Instagram returned an invalid JSON response '
395
+ 'when checking the media container'
396
+ )
397
+ except KeyError:
398
+ return self._error(
399
+ 'Instagram returned no status_code when checking the '
400
+ 'media container'
401
+ )
402
+
403
+ if codigo == "FINISHED":
404
+ return None
405
+ if codigo == "ERROR":
406
+ return self._error(
407
+ 'Instagram could not process the media (the '
408
+ 'container is in ERROR status)'
409
+ )
410
+ if codigo == "EXPIRED":
411
+ # Terminal, igual que ERROR (ver docstring de este método):
412
+ # el contenedor caduca a las 24h sin publicar y nunca va a
413
+ # llegar a FINISHED, así que seguir sondeando solo agotaría
414
+ # self.intentos sin motivo antes de fallar con un mensaje
415
+ # genérico de timeout que ocultaría la causa real.
416
+ return self._error(
417
+ 'the Instagram media container expired '
418
+ '(status_code=EXPIRED) before publication: it was not '
419
+ 'published within 24 hours; create a new container '
420
+ 'to try again'
421
+ )
422
+
423
+ # No dormir tras el último intento agotado: no serviría de nada
424
+ # y solo ralentizaría cada publicación que acaba fallando.
425
+ if intento < self.intentos - 1:
426
+ time.sleep(self.espera_s)
427
+
428
+ return self._error(
429
+ f"the Instagram container did not finish processing in time "
430
+ f"after {self.intentos} attempts"
431
+ )
432
+
433
+ def _error(self, mensaje: str, *, riesgo_duplicado: bool = False) -> PostResult:
434
+ return PostResult(
435
+ platform=self.platform,
436
+ status=PostStatus.ERROR,
437
+ error=mensaje,
438
+ riesgo_duplicado=riesgo_duplicado,
439
+ )
440
+
441
+
442
+ ADAPTADORES[Platform.INSTAGRAM] = InstagramAdapter