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,331 @@
1
+ 'YouTube Data API v3 resumable video upload.\n\nThe adapter validates one user-supplied video, initializes a resumable upload,\nstreams the original bytes and records the returned resource ID. Upload success\nis distinct from visibility observations. Expected I/O, network and response\nfailures become PostResult errors rather than escaping the adapter boundary.\nKnown credentials are redacted from provider errors.'
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from datetime import datetime, timezone
7
+
8
+ import httpx
9
+
10
+ from socialctl.adapters.base import ADAPTADORES, Adapter
11
+ from socialctl.adapters.errores import mensaje_de_error
12
+ from socialctl.auth import obtener_token
13
+ from socialctl.brands import Brand
14
+ from socialctl.formatter import (
15
+ PLATFORM_SPECS,
16
+ privacidad_efectiva,
17
+ texto_publicado,
18
+ youtube_tags,
19
+ )
20
+ from socialctl.models import Platform, PlatformPost, PostResult, PostStatus
21
+
22
+ URL_UPLOAD = "https://www.googleapis.com/upload/youtube/v3/videos"
23
+ URL_VIDEOS = "https://www.googleapis.com/youtube/v3/videos"
24
+
25
+ # MIME type declarado en la subida resumible. Este proyecto no detecta el
26
+ # tipo MIME real de cada fichero (los adaptadores no llevan una tabla de
27
+ # extensión→MIME); "video/*" es el valor de comodín que la propia
28
+ # documentación de Google da como ejemplo válido para
29
+ # X-Upload-Content-Type ("The MIME type of the file (e.g., 'video/*')",
30
+ # developers.google.com/youtube/v3/guides/using_resumable_upload_protocol),
31
+ # así que sirve sin necesidad de esa tabla. Se usa el mismo valor en el
32
+ # POST inicial y en el PUT final porque la documentación exige que
33
+ # coincidan ("both of which must match the values provided in the initial
34
+ # request").
35
+ _TIPO_MIME_VIDEO = "video/*"
36
+
37
+
38
+ class YouTubeAdapter(Adapter):
39
+ 'Upload one video through the YouTube Data API v3 resumable protocol.'
40
+
41
+ platform = Platform.YOUTUBE
42
+
43
+ def publish(self, post: PlatformPost, brand: Brand, client: httpx.Client) -> PostResult:
44
+ 'Publish a validated post; convert every failure to an error PostResult.'
45
+ spec = PLATFORM_SPECS[self.platform]
46
+ if len(post.media) > spec.max_media:
47
+ return self._error(
48
+ f"this endpoint supports at most {spec.max_media} file; "
49
+ 'it does not publish carousels'
50
+ )
51
+
52
+ try:
53
+ return self._publicar(post, brand, client)
54
+ except Exception as exc:
55
+ return self._error(
56
+ f"an unexpected YouTube error occurred "
57
+ f"({type(exc).__name__}): {exc}"
58
+ )
59
+
60
+ def _publicar(self, post: PlatformPost, brand: Brand, client: httpx.Client) -> PostResult:
61
+ 'Execute publication with explicit handling of expected failures.'
62
+ if not post.media:
63
+ return self._error(
64
+ 'no video to upload: the post has no media attached'
65
+ )
66
+
67
+ video = post.media[0]
68
+
69
+ try:
70
+ token = obtener_token(brand, self.platform, client)
71
+ except Exception as exc:
72
+ return self._error(str(exc))
73
+
74
+ # El tamaño real del fichero hace falta ya para el POST inicial (la
75
+ # cabecera X-Upload-Content-Length, ver más abajo), no solo para el
76
+ # PUT: por eso se calcula aquí, antes de tocar la red, con el mismo
77
+ # manejo de errores de E/S que antes solo cubría el PUT. Como
78
+ # beneficio adicional, un fichero ya borrado o ilegible se detecta
79
+ # antes de gastar una petición de red en abrir una sesión de subida
80
+ # que no se podría completar.
81
+ try:
82
+ tamano = video.path.stat().st_size
83
+ except FileNotFoundError:
84
+ return self._error(
85
+ f"the video file no longer exists on disk: {video.path}"
86
+ )
87
+ except IsADirectoryError:
88
+ return self._error(
89
+ f"the video path points to a directory, not a file: {video.path}"
90
+ )
91
+ except PermissionError:
92
+ return self._error(
93
+ f"the video file is not readable: {video.path}"
94
+ )
95
+ except OSError as exc:
96
+ return self._error(
97
+ f"could not read the video file from disk ({video.path}): {exc}"
98
+ )
99
+
100
+ # privacidad_efectiva() (socialctl/formatter.py) ya resuelve el valor
101
+ # por defecto ("private") cuando post.privacy no se ha indicado: por
102
+ # decisión explícita del proyecto, ningún vídeo sale público sin que
103
+ # el usuario lo pida (el canal tiene ~5000 suscriptores). "public",
104
+ # "private" y "unlisted" son los tres valores que admite
105
+ # status.privacyStatus (confirmado con Context7, ver
106
+ # PLATFORM_SPECS[Platform.YOUTUBE].valores_privacidad).
107
+ requested_privacy = privacidad_efectiva(post)
108
+ metadatos = {
109
+ "snippet": {
110
+ "title": post.title,
111
+ "description": texto_publicado(post),
112
+ "tags": youtube_tags(post),
113
+ },
114
+ "status": {"privacyStatus": requested_privacy},
115
+ }
116
+
117
+ try:
118
+ inicio = client.post(
119
+ URL_UPLOAD,
120
+ params={"uploadType": "resumable", "part": "snippet,status"},
121
+ headers={
122
+ "Authorization": f"Bearer {token}",
123
+ # Ambas son "Required" en la tabla de cabeceras del paso 1
124
+ # del flujo resumible (Step 1 - Start a resumable
125
+ # session): el tamaño total del fichero y su MIME type.
126
+ "X-Upload-Content-Length": str(tamano),
127
+ "X-Upload-Content-Type": _TIPO_MIME_VIDEO,
128
+ },
129
+ json=metadatos,
130
+ )
131
+ except httpx.TimeoutException:
132
+ return self._error(
133
+ 'timed out contacting YouTube to '
134
+ 'initialize the upload'
135
+ )
136
+ except httpx.HTTPError:
137
+ return self._error(
138
+ 'could not connect to YouTube to initialize the upload'
139
+ )
140
+
141
+ if inicio.status_code != 200:
142
+ return self._error(mensaje_de_error(inicio, token))
143
+
144
+ destino = inicio.headers.get("Location")
145
+ if not destino:
146
+ return self._error('YouTube did not return an upload URL')
147
+
148
+ try:
149
+ with video.path.open("rb") as fichero:
150
+ subida = client.put(
151
+ destino,
152
+ headers={
153
+ "Authorization": f"Bearer {token}",
154
+ "Content-Length": str(tamano),
155
+ # "Required" en el paso 3 del flujo resumible (Step 3
156
+ # - Upload the video file), y debe coincidir con el
157
+ # X-Upload-Content-Type enviado en el POST inicial.
158
+ "Content-Type": _TIPO_MIME_VIDEO,
159
+ },
160
+ content=fichero,
161
+ timeout=None,
162
+ )
163
+ except FileNotFoundError:
164
+ return self._error(
165
+ f"the video file no longer exists on disk: {video.path}"
166
+ )
167
+ except IsADirectoryError:
168
+ return self._error(
169
+ f"the video path points to a directory, not a file: {video.path}"
170
+ )
171
+ except PermissionError:
172
+ return self._error(
173
+ f"the video file is not readable: {video.path}"
174
+ )
175
+ except OSError as exc:
176
+ # Cualquier otro fallo de E/S al leer el fichero (disco dañado,
177
+ # demasiados descriptores abiertos, etc.): no es ninguno de los
178
+ # casos anteriores, pero tampoco debe escapar como excepción.
179
+ return self._error(
180
+ f"could not read the video file from disk ({video.path}): {exc}"
181
+ )
182
+ except httpx.InvalidURL as exc:
183
+ # La URL de subida (`destino`) la devuelve YouTube en la cabecera
184
+ # `Location` de la respuesta al POST inicial: no es un literal
185
+ # nuestro, así que puede llegar mal formada (puerto no numérico,
186
+ # longitud desmesurada, etc.) y `httpx` la rechaza al construir la
187
+ # petición, antes de llegar a la red.
188
+ return self._error(
189
+ f"YouTube returned an invalid upload URL: {exc}"
190
+ )
191
+ except httpx.TimeoutException:
192
+ return self._error(
193
+ 'timed out uploading the video to YouTube'
194
+ )
195
+ except httpx.HTTPError:
196
+ return self._error(
197
+ 'could not connect to YouTube to upload the video'
198
+ )
199
+
200
+ if subida.status_code not in (200, 201):
201
+ return self._error(mensaje_de_error(subida, token))
202
+
203
+ try:
204
+ recurso_subido = subida.json()
205
+ video_id = recurso_subido["id"]
206
+ except json.JSONDecodeError:
207
+ return self._error(
208
+ 'YouTube returned an invalid JSON response after '
209
+ 'uploading the video'
210
+ )
211
+ except KeyError:
212
+ return self._error(
213
+ 'YouTube returned no video ID after the upload'
214
+ )
215
+
216
+ if not isinstance(video_id, str) or not video_id:
217
+ return self._error('YouTube returned an invalid video ID after the upload')
218
+
219
+ result = PostResult(
220
+ platform=self.platform,
221
+ status=PostStatus.PUBLICADO,
222
+ platform_id=video_id,
223
+ url=f"https://www.youtube.com/watch?v={video_id}",
224
+ requested_privacy=requested_privacy,
225
+ )
226
+ try:
227
+ self._apply_observation(result, recurso_subido)
228
+ except Exception:
229
+ # The ID is already authoritative. Optional fields in the upload
230
+ # response can be malformed, but can never turn that confirmed
231
+ # upload back into a retryable error.
232
+ result.warnings.append(
233
+ 'The upload was confirmed, but its '
234
+ 'initial visibility could not be interpreted.'
235
+ )
236
+ self._readback(result, token, client)
237
+ return result
238
+
239
+ @staticmethod
240
+ def _observation(
241
+ resource: object,
242
+ ) -> tuple[str | None, str | None, str | None, bool] | None:
243
+ if not isinstance(resource, dict):
244
+ return None
245
+ status = resource.get("status")
246
+ processing = resource.get("processingDetails")
247
+ status = status if isinstance(status, dict) else {}
248
+ processing = processing if isinstance(processing, dict) else {}
249
+ privacy = status.get("privacyStatus")
250
+ publish_at = status.get("publishAt")
251
+ processing_status = processing.get("processingStatus")
252
+ privacy = privacy if isinstance(privacy, str) and privacy else None
253
+ valid_visibility = privacy is not None
254
+ if isinstance(publish_at, str) and publish_at:
255
+ try:
256
+ parsed_publish_at = datetime.fromisoformat(
257
+ publish_at.replace("Z", "+00:00")
258
+ )
259
+ except ValueError:
260
+ publish_at = None
261
+ else:
262
+ if parsed_publish_at.utcoffset() is None:
263
+ publish_at = None
264
+ elif publish_at is not None:
265
+ publish_at = None
266
+ else:
267
+ publish_at = None
268
+ processing_status = (
269
+ processing_status
270
+ if isinstance(processing_status, str) and processing_status
271
+ else None
272
+ )
273
+ if not valid_visibility and processing_status is None:
274
+ return None
275
+ return privacy, publish_at, processing_status, valid_visibility
276
+
277
+ @classmethod
278
+ def _apply_observation(cls, result: PostResult, resource: object) -> bool:
279
+ observation = cls._observation(resource)
280
+ if observation is None:
281
+ return False
282
+ privacy, publish_at, processing_status, valid_visibility = observation
283
+ if valid_visibility:
284
+ # `privacyStatus` anchors a complete visibility snapshot. Its
285
+ # absence in a processing-only readback must not erase a status
286
+ # returned by the upload or move that older status to a new time.
287
+ result.observed_privacy = privacy
288
+ result.observed_publish_at = publish_at
289
+ result.visibility_observed_at = datetime.now(timezone.utc).isoformat()
290
+ if processing_status is not None:
291
+ # Processing has no separate timestamp in PostResult. Preserve
292
+ # the existing visibility timestamp when this is the only field
293
+ # returned, rather than attributing old privacy data to this GET.
294
+ result.observed_processing_status = processing_status
295
+ return True
296
+
297
+ def _readback(self, result: PostResult, token: str, client: httpx.Client) -> None:
298
+ """Read one resource snapshot; upload success remains authoritative."""
299
+ try:
300
+ response = client.get(
301
+ URL_VIDEOS,
302
+ params={
303
+ "id": result.platform_id,
304
+ "part": "status,processingDetails",
305
+ },
306
+ headers={"Authorization": f"Bearer {token}"},
307
+ )
308
+ if response.status_code != 200:
309
+ result.warnings.append(
310
+ 'The upload was confirmed, but its '
311
+ f"visibility could not be verified (YouTube returned HTTP {response.status_code})."
312
+ )
313
+ return
314
+ payload = response.json()
315
+ items = payload.get("items") if isinstance(payload, dict) else None
316
+ resource = items[0] if isinstance(items, list) and items else None
317
+ if resource is None or not self._apply_observation(result, resource):
318
+ result.warnings.append(
319
+ 'The upload was confirmed, but YouTube did not return a '
320
+ 'usable visibility observation.'
321
+ )
322
+ except Exception:
323
+ result.warnings.append(
324
+ 'The upload was confirmed, but its visibility could not be verified.'
325
+ )
326
+
327
+ def _error(self, mensaje: str) -> PostResult:
328
+ return PostResult(platform=self.platform, status=PostStatus.ERROR, error=mensaje)
329
+
330
+
331
+ ADAPTADORES[Platform.YOUTUBE] = YouTubeAdapter
@@ -0,0 +1,176 @@
1
+ """Conservative next-action hints from typed failures, never string guessing."""
2
+ from socialctl.connections.saas_client import MESSAGES, ServiceRejected, TransportFailure
3
+
4
+ _ACTIONS = {
5
+ 'privacy_acceptance_required': 'user_action',
6
+ 'assistant_consent_required': 'user_action',
7
+ 'platform_not_allowed': 'user_action',
8
+ 'installation_permissions_invalid': 'user_action',
9
+ 'provider_authorization_expired': 'reconnect',
10
+ 'provider_permissions_changed': 'reconnect',
11
+ 'renewal_uncertain': 'reconcile',
12
+ 'renewal_pending': 'retry_later',
13
+ 'renewal_retryable': 'retry_later',
14
+ 'renewal_required': 'user_action',
15
+ 'provider_configuration': 'contact_support',
16
+ 'installation_revoked': 'sign_in',
17
+ 'installation_expired': 'sign_in',
18
+ 'activation_required': 'recover',
19
+ 'subscription_required': 'user_action',
20
+ 'brand_limit': 'user_action',
21
+ 'connection_inactive': 'user_action',
22
+ 'connection_unavailable': 'user_action',
23
+ 'connection_replaced': 'user_action',
24
+ 'connection_changed': 'user_action',
25
+ 'user_authorization_required': 'user_action',
26
+ 'operation_conflict': 'user_action',
27
+ 'operation_rejected': 'inspect',
28
+ 'operation_uncertain': 'reconcile',
29
+ }
30
+
31
+
32
+ # Only fixed internal codes acquire action semantics. Provider text is diagnostic,
33
+ # never a command, an expiry signal or evidence that a write was not accepted.
34
+ _CATALOG = {
35
+ 'approval_mismatch': ('user_action', 'The supplied approval does not match this exact preview. Obtain the owner approval before applying.'),
36
+ 'approval_required': ('user_action', 'Show the complete preview and obtain explicit approval of its exact fingerprint.'),
37
+ 'operation_cancelled': ('inspect', 'Approval was cancelled. No write was started by this command.'),
38
+ 'publication_pending': ('user_action', 'Complete or verify this existing submission in the platform; do not upload it again.'),
39
+ 'comment_incomplete': ('reconcile', 'Inspect the existing first-comment attempt and its ChangeSet. Do not upload the media again.'),
40
+ 'validation_failed': ('correct_input', 'Review every blocking validation item in the complete preview before approval.'),
41
+ 'reporting_incomplete': ('inspect', 'Some report observations could not be collected. Inspect the durable result and journal before further action; do not replay a write.'),
42
+ 'native_wait': ('wait', 'Keep this approved job and wait for its delivery window; do not create a duplicate.'),
43
+ 'native_dispatch': ('inspect', 'Inspect the approved route and delivery readiness before dispatching this existing job.'),
44
+ 'native_ui_required': ('user_action', 'Continue the exact approved handoff in the official platform UI. A submitted boundary permits readback only.'),
45
+ 'native_verify': ('reconcile', 'Collect fresh official evidence for this existing job. Scheduling alone does not prove publication.'),
46
+ 'operation_failed': ('inspect', 'Inspect the current state before taking another action.'),
47
+ 'service_rejected': ('inspect', 'Review account status and service availability.'),
48
+ 'local_configuration': ('user_action', 'Check local URL, proxy and CA configuration. No server rejection was received; keep credentials unchanged.'),
49
+ 'invalid_local_state': ('user_action', 'Review arguments, local account state and protected credential storage.'),
50
+ 'read_partial': ('inspect', 'Keep verified observations and inspect coverage before another read.'),
51
+ 'read_deadline': ('adjust_read', 'Keep verified observations; reduce read scope or increase the read budget.'),
52
+ 'response_too_large': ('adjust_read', 'Reduce read scope or page size; do not repeat the same oversized response.'),
53
+ 'read_interrupted': ('inspect', 'Keep verified observations. Resume only if the read is still requested.'),
54
+ 'not_attempted': ('inspect', 'This read was not attempted; inspect completed coverage before continuing.'),
55
+ 'transport_failure': ('retry_later', 'Wait before a bounded read retry. Keep credentials and all verified observations.'),
56
+ 'provider_unavailable': ('retry_later', 'Wait before a bounded read retry; the provider is unavailable.'),
57
+ 'rate_limited': ('retry_later', 'Wait for the provider rate limit before a bounded read retry.'),
58
+ 'authorization_unavailable': ('user_action', 'Review the account authorization; this failure alone does not prove token expiry.'),
59
+ 'permissions_required': ('user_action', 'Review granted permissions and account eligibility with the owner.'),
60
+ 'comments_disabled': ('inspect', 'Comments are disabled on this video. Reconnecting does not enable them; do not change video settings without owner approval.'),
61
+ 'target_unavailable': ('correct_input', 'Verify the requested target and account before another read.'),
62
+ 'request_rejected': ('correct_input', 'Review supported arguments and target before another request.'),
63
+ 'operation_rejected': ('inspect', 'Inspect the target, stored state and approval before another operation.'),
64
+ 'operation_uncertain': ('reconcile', 'Reconcile the existing operation. Do not replay its write.'),
65
+ 'operation_already_verified': ('inspect', 'An identical operation was already verified. No new write was started. Inspect the existing receipt; do not duplicate it.'),
66
+ 'operation_accepted_unverified': ('inspect', 'The provider accepted the operation. Independent verification is unavailable; this is not a rejection. Preserve the receipt and do not replay the write.'),
67
+ 'provider_response_invalid': ('inspect', 'Preserve verified observations and inspect the incomplete provider response.'),
68
+ 'storage_failure': ('repair_storage', 'Inspect local storage and preserve saved observations before recovery.'),
69
+ 'local_data_busy': ('retry_later', 'Another SocialCli operation is using local data. Wait for it to finish, then retry. Do not delete or repair the files.'),
70
+ 'persistence_failed': ('repair_storage', 'Preserve verified observations and repair local persistence before collecting again.'),
71
+ 'unsupported_operation': ('unsupported', 'This integration does not support the requested operation.'),
72
+ 'account_not_configured': ('user_action', 'Ask the owner to select and authorize the intended account; never infer one.'),
73
+ 'invalid_input': ('correct_input', 'Review the supported arguments and explicit workspace.'),
74
+ 'installation_access_required': ('sign_in', 'Ask the owner to sign in to SocialCli again.'),
75
+ 'invalid_report_options': ('correct_input', 'Review the report date, window, item limit and platform.'),
76
+ 'report_workspace_unavailable': ('inspect_workspace', 'Inspect the explicitly selected report workspace.'),
77
+ 'snapshot_identity_mismatch': ('inspect_snapshot', 'Preserve the snapshot and inspect its brand/date binding.'),
78
+ 'snapshot_unreadable': ('inspect_snapshot', 'Preserve the unreadable snapshot before recovery.'),
79
+ 'snapshots_unavailable': ('collect_metrics', 'Collect authorized metrics before requesting a saved-snapshot report.'),
80
+ 'platform_read_unavailable': ('collect_metrics', 'Collect authorized metrics for this platform; retain other verified observations.'),
81
+ }
82
+
83
+
84
+ def error_for_code(code: str, *, read_only: bool = True) -> dict:
85
+ if isinstance(code, str) and code in MESSAGES:
86
+ action, message = _ACTIONS.get(code, 'inspect'), MESSAGES[code]
87
+ else:
88
+ code = code if isinstance(code, str) and code in _CATALOG else 'operation_failed'
89
+ action, message = _CATALOG[code]
90
+ if not read_only and (action == 'retry_later' or code in {'read_deadline', 'response_too_large'}):
91
+ code = 'operation_uncertain'
92
+ action, message = 'reconcile', 'Verify the existing operation before another write; its outcome may be uncertain.'
93
+ return {'code':code, 'action':action,
94
+ 'retryable':bool(read_only and action == 'retry_later'),
95
+ 'requires_user_action':action in {'reconnect','sign_in','user_action','contact_support'},
96
+ 'message':message}
97
+
98
+
99
+ def error_document(exc: BaseException, *, read_only: bool) -> dict:
100
+ import httpx
101
+ from socialctl.read_budget import LocalConfigurationError
102
+ from socialctl.auth import AuthError
103
+ from socialctl.read_budget import ReadDeadlineExceeded, ReadResponseTooLarge
104
+ from socialctl.management.changes import ChangeError
105
+ from socialctl.management.youtube_resources import ResourceError, ResourceUncertain
106
+ from socialctl.management.youtube import YouTubeManagementError, YouTubeUpdateUncertain
107
+ from socialctl.management.meta_comments import CommentError, CommentUncertain
108
+ from socialctl.management.meta_schema import MetaError, MetaUncertain
109
+ from socialctl.metricas.base import SinPermiso
110
+ from socialctl.youtube_data import YouTubeDataBusy, YouTubeDataCleanupError
111
+
112
+ # A read flow may refresh a token via POST. A lost response to that operation
113
+ # cannot be classified as a harmless GET retry merely from the CLI command.
114
+ if isinstance(exc, TransportFailure) and exc.request_method not in {None,'GET','HEAD','OPTIONS'}:
115
+ read_only = False
116
+ if isinstance(exc, httpx.RequestError):
117
+ try: request = exc.request
118
+ except RuntimeError: request = None
119
+ if request is not None and request.method not in {'GET','HEAD','OPTIONS'}:
120
+ read_only = False
121
+ if isinstance(exc, ServiceRejected):
122
+ code = exc.code if exc.code in MESSAGES else {
123
+ 401:'installation_access_required',402:'subscription_required',
124
+ 429:'rate_limited',503:'provider_unavailable'}.get(exc.status,'service_rejected')
125
+ elif isinstance(exc, (ResourceUncertain, YouTubeUpdateUncertain, CommentUncertain, MetaUncertain)):
126
+ code = 'operation_uncertain'
127
+ elif isinstance(exc, (AuthError, YouTubeManagementError, ResourceError, CommentError, MetaError)) and getattr(exc, '_socialcli_error_code', None) is not None:
128
+ code = exc._socialcli_error_code
129
+ elif isinstance(exc, ReadDeadlineExceeded): code = 'read_deadline'
130
+ elif isinstance(exc, ReadResponseTooLarge): code = 'response_too_large'
131
+ elif isinstance(exc, KeyboardInterrupt): code = 'read_interrupted'
132
+ elif isinstance(exc, AuthError): code = 'authorization_unavailable'
133
+ elif isinstance(exc, SinPermiso): code = 'permissions_required'
134
+ elif isinstance(exc, (LocalConfigurationError,httpx.InvalidURL,httpx.LocalProtocolError,httpx.UnsupportedProtocol)):
135
+ code = 'local_configuration'
136
+ elif isinstance(exc, (TransportFailure,httpx.TransportError)): code = 'transport_failure'
137
+ elif isinstance(exc, ResourceError) and exc.http_status == 403 and exc.provider_reason in {'commentsDisabled', 'quotaExceeded'}:
138
+ code = 'comments_disabled' if exc.provider_reason == 'commentsDisabled' else 'rate_limited'
139
+ elif isinstance(exc, httpx.HTTPStatusError) or isinstance(exc, ResourceError) and exc.http_status:
140
+ status = exc.response.status_code if isinstance(exc,httpx.HTTPStatusError) else exc.http_status
141
+ if isinstance(exc,httpx.HTTPStatusError) and exc.request.method not in {'GET','HEAD','OPTIONS'}:
142
+ read_only = False
143
+ code = ({401:'authorization_unavailable',403:'permissions_required',404:'target_unavailable',
144
+ 429:'rate_limited'}.get(status,'provider_unavailable' if status>=500 else 'request_rejected')
145
+ if type(status) is int else 'operation_rejected')
146
+ elif isinstance(exc, httpx.RequestError): code = 'provider_response_invalid'
147
+ elif isinstance(exc, ChangeError): code = 'operation_rejected'
148
+ elif isinstance(exc, YouTubeDataBusy): code = 'local_data_busy'
149
+ elif isinstance(exc, (OSError, YouTubeDataCleanupError)): code = 'storage_failure'
150
+ elif isinstance(exc, ValueError): code = 'invalid_local_state'
151
+ else: code = 'operation_failed'
152
+ return error_for_code(code,read_only=read_only)
153
+
154
+
155
+ def with_error_code(wrapper: Exception, cause: BaseException, *, read_only: bool,
156
+ fallback: str | None = None) -> Exception:
157
+ """Keep only a fixed code across a sanitized wrapper, never raw exception data."""
158
+ code = error_document(cause, read_only=read_only)['code']
159
+ if code == 'operation_failed' and fallback is not None:
160
+ code = error_for_code(fallback, read_only=read_only)['code']
161
+ wrapper._socialcli_error_code = code
162
+ return wrapper
163
+
164
+
165
+ def with_http_error_code(wrapper: Exception, response, *, read_only: bool) -> Exception:
166
+ import httpx
167
+ cause = httpx.HTTPStatusError('Provider request rejected', request=response.request,
168
+ response=response)
169
+ return with_error_code(wrapper, cause, read_only=read_only)
170
+
171
+
172
+ def emit_error(exc: Exception, *, read_only: bool) -> None:
173
+ import json
174
+ import typer
175
+ typer.echo(json.dumps({'version': 1, 'status': 'error',
176
+ 'error': error_document(exc, read_only=read_only)}))
@@ -0,0 +1,23 @@
1
+ """Topic selection keeps advanced contracts available without bloating bootstrap."""
2
+ from importlib.resources import files
3
+ import re
4
+
5
+ TOPICS={
6
+ 'setup':('Install the reviewed candidate','Authorize the installation, then the social account','Persist and recover','Publication gate','Explicit approval and preflight'),
7
+ 'publish':('Publication gate','Temporary Instagram media','Canonical publishing workflow','Check supplied media locally','Explicit approval and preflight'),
8
+ 'schedule':('Publication gate','Temporary Instagram media','Scheduling and temporary media','Native scheduling input examples (offline)','Explicit approval and preflight'),
9
+ 'inbox':('Publication gate','Read metrics and prepare engagement','Read diagnostics','Comment API corrections','Explicit approval and preflight','Resumable batches and reply context'),
10
+ 'errors':('Persist and recover','Recovery and error actions','Read diagnostics','Comment API corrections','Explicit approval and preflight','Resumable batches and reply context'),
11
+ 'permissions':('Installation connection access',),
12
+ }
13
+
14
+
15
+ def guide(topic):
16
+ if topic not in TOPICS and topic!='all':
17
+ raise ValueError('Choose a guide topic: '+', '.join(TOPICS)+', all.')
18
+ full=files('socialctl.ui').joinpath('assets/agent-manual.md').read_text(encoding='utf-8')
19
+ if topic=='all':return full
20
+ sections=re.split(r'(?=^## )',full,flags=re.MULTILINE)
21
+ chosen=[section for section in sections if any(section.startswith('## '+heading) for heading in TOPICS[topic])]
22
+ return ('# SocialCli agent guide: '+topic+'\n\n'+''.join(chosen)+
23
+ '\nNext: socialcli agent-guide publish; socialcli agent-guide schedule; socialcli agent-guide inbox; socialcli agent-guide errors.\n')
@@ -0,0 +1,27 @@
1
+ """Explicit approval transport shared by terminal and noninteractive commands."""
2
+ import sys
3
+
4
+ import typer
5
+
6
+
7
+ class ApprovalRequired(ValueError):
8
+ """The caller supplied no explicit noninteractive approval."""
9
+
10
+
11
+ def read_approval(value=None, *, approval_stdin=False, prompt='Enter the exact fingerprint to approve'):
12
+ """Read one explicitly authorized source; an open pipe is never implicit consent."""
13
+ if value is not None and approval_stdin:
14
+ raise ValueError('Choose --approval-fingerprint OR --approval-stdin, not both.')
15
+ if value is not None:
16
+ return value
17
+ if approval_stdin:
18
+ typer.echo(f"{prompt} (--approval-stdin):")
19
+ value = sys.stdin.readline(4097)
20
+ if not value:
21
+ raise typer.Abort()
22
+ if len(value) > 4096 or not value.strip():
23
+ raise ValueError('Exact approval missing or invalid on stdin.')
24
+ return value.strip()
25
+ if not sys.stdin.isatty():
26
+ raise ApprovalRequired('Noninteractive approval required: show the complete preview, then pass --approval-fingerprint DIGEST or explicitly pipe one approval line with --approval-stdin. No input was read.')
27
+ return typer.prompt(prompt)