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,535 @@
1
+ """TikTok Content Posting API v2 creator-reviewed inbox upload.
2
+
3
+ Fresh Direct Post is disabled and excluded regardless of legacy audit settings.
4
+ Retained direct-mode helpers do not enable that route. Inbox success remains
5
+ pending creator confirmation. Upload response loss retains duplicate risk and
6
+ requires remote verification before retrying; credentials stay out of diagnostics.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from pathlib import Path
13
+ from typing import NamedTuple
14
+
15
+ import httpx
16
+
17
+ from socialctl.adapters.base import ADAPTADORES, Adapter
18
+ from socialctl.adapters.errores import mensaje_de_error
19
+ from socialctl.auth import obtener_token
20
+ from socialctl.brands import Brand
21
+ from socialctl.formatter import PLATFORM_SPECS, componer_caption
22
+ from socialctl.models import (
23
+ MediaAsset,
24
+ Platform,
25
+ PlatformPost,
26
+ PostResult,
27
+ PostStatus,
28
+ ValidationError,
29
+ )
30
+
31
+ API = "https://open.tiktokapis.com/v2/post/publish"
32
+
33
+ # --- Reglas de chunking del Media Transfer Guide de TikTok: protocolo de
34
+ # subida, no límite de contenido (ver docstring del módulo).
35
+ CHUNK_MIN_BYTES = 5 * 1024 * 1024
36
+ CHUNK_MAX_BYTES = 64 * 1024 * 1024
37
+ CHUNK_MAX_FINAL_BYTES = 128 * 1024 * 1024
38
+ TOTAL_CHUNKS_MAXIMO = 1000
39
+
40
+ # El nivel de privacidad que este proyecto espera al publicar en modo
41
+ # direct: contenido público. Ver docstring de
42
+ # TikTokAdapter._resolver_privacidad_y_duracion sobre por qué es un valor
43
+ # único hoy (no configurable por marca) y qué pasa si la cuenta no lo
44
+ # admite.
45
+ _PRIVACY_LEVEL_DESEADO = "PUBLIC_TO_EVERYONE"
46
+
47
+
48
+ _VALOR_AUDITADA_VERDADERO = "true"
49
+ _VALOR_AUDITADA_FALSO = "false"
50
+
51
+
52
+ def _interpretar_auditada(valor: object) -> tuple[bool, bool]:
53
+ 'Accept only bool True or the canonical text true as evidence of configured audit.\n\nFalse, None, empty text and canonical false are unaudited. Unknown values such\nas numeric 1 or noncanonical strings return None so callers can fail closed.\nThe configuration value never establishes actual provider approval by itself.'
54
+ if valor is True:
55
+ return True, True
56
+ if valor is False or valor is None:
57
+ return False, True
58
+ if isinstance(valor, str):
59
+ normalizado = valor.strip().lower()
60
+ if normalizado == _VALOR_AUDITADA_VERDADERO:
61
+ return True, True
62
+ if normalizado == _VALOR_AUDITADA_FALSO:
63
+ return False, True
64
+ return False, False
65
+ # Cualquier otro tipo (int, float, list, dict...) -incluidos 1 y 0- no
66
+ # es una forma reconocida.
67
+ return False, False
68
+
69
+
70
+ class AuditoriaRequerida(Exception):
71
+ 'Direct mode requires an approved TikTok application audit.'
72
+
73
+
74
+ class ChunkingImposible(Exception):
75
+ 'The video exceeds the 1000-chunk maximum supported by TikTok uploads.'
76
+
77
+
78
+ def _calcular_chunking(tamano_bytes: int) -> tuple[int, int]:
79
+ 'Return chunk_size and total_chunk_count for a file of tamano_bytes.\n\nUse one chunk for up to 64 MB; use sequential 64 MB chunks for larger files.\nEnforce TikTok minimum, maximum final-chunk size and 1000-chunk limit. The return\nvalue describes transfer protocol only, not content validation.'
80
+ if tamano_bytes <= CHUNK_MAX_BYTES:
81
+ # Cubre tanto "menos de 5 MB" como "entre 5 y 64 MB": en ambos
82
+ # casos un único chunk que mida el fichero entero ya respeta las
83
+ # reglas (es, como mucho, de 64 MB, y como pieza única no está
84
+ # sujeto al mínimo de 5 MB que sí aplica a un chunk regular).
85
+ return tamano_bytes, 1
86
+
87
+ # Ceiling division entera (sin pasar por floats, para no arriesgar
88
+ # ninguna imprecisión de coma flotante con ficheros de decenas de GB).
89
+ total_chunk_count = -(-tamano_bytes // CHUNK_MAX_BYTES)
90
+
91
+ if total_chunk_count > TOTAL_CHUNKS_MAXIMO:
92
+ raise ChunkingImposible(
93
+ f"the video is {tamano_bytes / 1024**3:.2f} GB and exceeds the "
94
+ f"maximum of {TOTAL_CHUNKS_MAXIMO} chunks supported by "
95
+ f"TikTok uploads (would require {total_chunk_count}); cannot upload "
96
+ 'using this mechanism.'
97
+ )
98
+
99
+ chunk_size = -(-tamano_bytes // total_chunk_count)
100
+
101
+ return chunk_size, total_chunk_count
102
+
103
+
104
+ class _ErrorDeSubida(NamedTuple):
105
+ 'A file or chunk upload failed, with actionable text and duplicate-risk state.'
106
+
107
+ mensaje: str
108
+ riesgo_duplicado: bool = False
109
+
110
+
111
+ def _subir_chunks(
112
+ client: httpx.Client,
113
+ upload_url: str,
114
+ path: Path,
115
+ tamano_bytes: int,
116
+ chunk_size: int,
117
+ total_chunk_count: int,
118
+ ) -> _ErrorDeSubida | None:
119
+ 'Stream original file bytes to upload_url in sequential chunks.\n\nDetect changes in file size, I/O failures, invalid URLs and provider rejection.\nLost responses to the final chunk carry duplicate risk: TikTok may have received\nthe complete file. Return _ErrorDeSubida without exposing credentials.'
120
+ try:
121
+ with path.open("rb") as fichero:
122
+ inicio = 0
123
+ for numero in range(total_chunk_count):
124
+ es_ultimo = numero == total_chunk_count - 1
125
+ fin = (tamano_bytes - 1) if es_ultimo else (inicio + chunk_size - 1)
126
+ longitud = fin - inicio + 1
127
+
128
+ fichero.seek(inicio)
129
+ trozo = fichero.read(longitud)
130
+ if len(trozo) != longitud:
131
+ return _ErrorDeSubida(
132
+ 'the video file changed size during the upload '
133
+ f"(chunk {numero + 1}/{total_chunk_count} expected "
134
+ f"{longitud} bytes, but only read {len(trozo)})"
135
+ )
136
+
137
+ try:
138
+ respuesta = client.put(
139
+ upload_url,
140
+ content=trozo,
141
+ headers={
142
+ "Content-Type": "video/mp4",
143
+ "Content-Length": str(longitud),
144
+ "Content-Range": f"bytes {inicio}-{fin}/{tamano_bytes}",
145
+ },
146
+ timeout=None,
147
+ )
148
+ except httpx.InvalidURL:
149
+ # upload_url la devuelve TikTok en la respuesta de init:
150
+ # si viniera mal formada, httpx la rechaza antes de
151
+ # tocar la red. No hereda de httpx.HTTPError.
152
+ return _ErrorDeSubida('TikTok returned an invalid upload URL')
153
+ except httpx.TimeoutException:
154
+ # Subclase de httpx.HTTPError: debe ir antes que ese
155
+ # except para no quedar inalcanzable.
156
+ if es_ultimo:
157
+ return _ErrorDeSubida(
158
+ 'timed out uploading the final '
159
+ f"chunk ({numero + 1}/{total_chunk_count}) to "
160
+ 'TikTok: cannot confirm whether TikTok '
161
+ 'received it and started publishing the video. Do NOT '
162
+ 'retry the upload without checking the '
163
+ 'account first, to avoid duplicating the '
164
+ 'post.',
165
+ riesgo_duplicado=True,
166
+ )
167
+ return _ErrorDeSubida(
168
+ 'timed out uploading chunk '
169
+ f"{numero + 1}/{total_chunk_count} to TikTok"
170
+ )
171
+ except httpx.HTTPError:
172
+ if es_ultimo:
173
+ return _ErrorDeSubida(
174
+ 'could not connect to TikTok when uploading the '
175
+ f"final chunk ({numero + 1}/{total_chunk_count}): "
176
+ 'cannot confirm whether TikTok '
177
+ 'received it and started publishing the video. Do NOT '
178
+ 'retry the upload without checking the '
179
+ 'account first, to avoid duplicating the '
180
+ 'post.',
181
+ riesgo_duplicado=True,
182
+ )
183
+ return _ErrorDeSubida(
184
+ 'could not connect to TikTok to upload chunk '
185
+ f"{numero + 1}/{total_chunk_count}"
186
+ )
187
+
188
+ if respuesta.status_code not in (200, 201, 206):
189
+ return _ErrorDeSubida(
190
+ f"TikTok rejected chunk {numero + 1}/{total_chunk_count} "
191
+ f"of the upload (HTTP {respuesta.status_code})"
192
+ )
193
+
194
+ inicio = fin + 1
195
+ except FileNotFoundError:
196
+ return _ErrorDeSubida(f"the video file no longer exists on disk: {path}")
197
+ except IsADirectoryError:
198
+ return _ErrorDeSubida(
199
+ f"the video path points to a directory, not a file: {path}"
200
+ )
201
+ except PermissionError:
202
+ return _ErrorDeSubida(f"the video file is not readable: {path}")
203
+ except OSError as exc:
204
+ # Cualquier otro fallo de E/S al leer el fichero (disco dañado,
205
+ # demasiados descriptores abiertos, etc.).
206
+ return _ErrorDeSubida(f"could not read the video file from disk ({path}): {exc}")
207
+
208
+ return None
209
+
210
+
211
+ def _consultar_creator_info(
212
+ client: httpx.Client, token: str
213
+ ) -> tuple[dict | None, str | None]:
214
+ 'Query /v2/post/publish/creator_info/query/ with the authenticated token.\n\nReturn creator data or a diagnostic error; this helper performs no publication.\nCallers validate privacy_level_options and max_video_post_duration_sec.'
215
+ try:
216
+ respuesta = client.post(
217
+ f"{API}/creator_info/query/",
218
+ headers={
219
+ "Authorization": f"Bearer {token}",
220
+ "Content-Type": "application/json; charset=UTF-8",
221
+ },
222
+ )
223
+ except httpx.TimeoutException:
224
+ return None, 'timed out querying creator_info on TikTok'
225
+ except httpx.HTTPError:
226
+ return None, 'could not connect to TikTok to query creator_info'
227
+
228
+ if respuesta.status_code != 200:
229
+ return None, mensaje_de_error(respuesta, token)
230
+
231
+ try:
232
+ cuerpo = respuesta.json()
233
+ except json.JSONDecodeError:
234
+ return None, 'TikTok returned an invalid JSON response'
235
+
236
+ datos = cuerpo.get("data") if isinstance(cuerpo, dict) else None
237
+ if not isinstance(datos, dict):
238
+ return None, "TikTok returned no creator_info data (missing 'data' field)"
239
+
240
+ return datos, None
241
+
242
+
243
+ class TikTokAdapter(Adapter):
244
+ 'Upload creator-reviewed TikTok inbox drafts; fresh Direct Post is excluded.'
245
+
246
+ platform = Platform.TIKTOK
247
+
248
+ def validate(self, post: PlatformPost, brand: Brand) -> list[ValidationError]:
249
+ 'Validate TikTok account, upload mode and stable scope without network access.'
250
+ errores = super().validate(post, brand)
251
+ cuenta = brand.cuentas.get("tiktok") or {}
252
+ open_id = cuenta.get("open_id")
253
+ modo = cuenta.get("mode", "inbox")
254
+ # No se convierte con bool(): ver docstring de _interpretar_auditada.
255
+ valor_auditada = cuenta.get("auditada")
256
+
257
+ if not open_id:
258
+ errores.append(
259
+ ValidationError(
260
+ platform=self.platform,
261
+ campo="cuenta",
262
+ motivo=f"missing open_id in {brand.raiz / 'accounts.yml'}",
263
+ )
264
+ )
265
+
266
+ if modo not in ("inbox", "direct"):
267
+ errores.append(
268
+ ValidationError(
269
+ platform=self.platform,
270
+ campo="cuenta",
271
+ motivo=(
272
+ f"unknown TikTok mode: '{modo}' in "
273
+ f"{brand.raiz / 'accounts.yml'}. Valid values: "
274
+ "inbox, direct"
275
+ ),
276
+ )
277
+ )
278
+
279
+ try:
280
+ self._comprobar_auditoria(brand, modo, valor_auditada)
281
+ except AuditoriaRequerida as exc:
282
+ errores.append(
283
+ ValidationError(
284
+ platform=self.platform, campo="auditoria", motivo=str(exc)
285
+ )
286
+ )
287
+
288
+ return errores
289
+
290
+ def publish(self, post: PlatformPost, brand: Brand, client: httpx.Client) -> PostResult:
291
+ 'Publish a validated post; convert every failure to an error PostResult.'
292
+ spec = PLATFORM_SPECS[self.platform]
293
+ if len(post.media) > spec.max_media:
294
+ return self._error(
295
+ f"this endpoint supports at most {spec.max_media} file; "
296
+ 'it does not publish carousels'
297
+ )
298
+
299
+ try:
300
+ return self._publicar(post, brand, client)
301
+ except Exception as exc:
302
+ return self._error(
303
+ f"an unexpected TikTok error occurred ({type(exc).__name__})"
304
+ )
305
+
306
+ def _publicar(self, post: PlatformPost, brand: Brand, client: httpx.Client) -> PostResult:
307
+ 'Execute publication with explicit handling of expected failures.'
308
+ cuenta = brand.cuentas.get("tiktok") or {}
309
+ open_id = cuenta.get("open_id")
310
+ modo = cuenta.get("mode", "inbox")
311
+ # No se convierte con bool(): ver docstring de _interpretar_auditada.
312
+ valor_auditada = cuenta.get("auditada")
313
+
314
+ # La Content Posting API v2 no exige open_id en el cuerpo de
315
+ # init/subida (el access_token ya identifica a la cuenta), pero su
316
+ # ausencia en accounts.yml es señal de una cuenta a medio
317
+ # configurar -igual que page_id en facebook.py o ig_user_id en
318
+ # instagram.py-, así que se valida aquí aunque no viaje en ninguna
319
+ # petición de este módulo.
320
+ if not open_id:
321
+ return self._error(f"missing open_id in {brand.raiz / 'accounts.yml'}")
322
+
323
+ if modo not in ("inbox", "direct"):
324
+ return self._error(
325
+ f"unknown TikTok mode: '{modo}' in {brand.raiz / 'accounts.yml'}. "
326
+ 'Valid values: inbox, direct'
327
+ )
328
+
329
+ try:
330
+ self._comprobar_auditoria(brand, modo, valor_auditada)
331
+ except AuditoriaRequerida as exc:
332
+ return self._error(str(exc))
333
+
334
+ if not post.media:
335
+ return self._error(
336
+ 'no video to publish: the post has no media attached'
337
+ )
338
+ asset = post.media[0]
339
+
340
+ try:
341
+ token = obtener_token(brand, self.platform, client)
342
+ except Exception as exc:
343
+ return self._error(str(exc))
344
+
345
+ # En modo direct, privacy_level debe coincidir con una opción que
346
+ # ESTA cuenta admita de verdad (ver _resolver_privacidad_y_duracion).
347
+ # La llamada queda DESPUÉS de la salvaguarda de auditoría (ya
348
+ # aplicada más arriba) y ANTES de video/init/: si la cuenta no
349
+ # admite el nivel que este proyecto espera, se aborta aquí, sin
350
+ # haber gastado ninguna llamada de subida.
351
+ privacy_level = None
352
+ if modo == "direct":
353
+ privacy_level, error_creador = self._resolver_privacidad_y_duracion(
354
+ token, asset, client
355
+ )
356
+ if error_creador is not None:
357
+ return error_creador
358
+
359
+ try:
360
+ tamano_bytes = asset.path.stat().st_size
361
+ except FileNotFoundError:
362
+ return self._error(f"the video file no longer exists on disk: {asset.path}")
363
+ except OSError as exc:
364
+ return self._error(
365
+ f"could not read the video file from disk ({asset.path}): {exc}"
366
+ )
367
+
368
+ try:
369
+ chunk_size, total_chunk_count = _calcular_chunking(tamano_bytes)
370
+ except ChunkingImposible as exc:
371
+ return self._error(str(exc))
372
+
373
+ titulo = componer_caption(post.body, post.hashtags)
374
+
375
+ cuerpo: dict = {
376
+ "source_info": {
377
+ "source": "FILE_UPLOAD",
378
+ "video_size": tamano_bytes,
379
+ "chunk_size": chunk_size,
380
+ "total_chunk_count": total_chunk_count,
381
+ }
382
+ }
383
+ if modo == "direct":
384
+ endpoint = f"{API}/video/init/"
385
+ cuerpo["post_info"] = {
386
+ "title": titulo,
387
+ # Confirmado más arriba contra creator_info/query/: no un
388
+ # valor fijo sin consultar (ver _resolver_privacidad_y_duracion).
389
+ "privacy_level": privacy_level,
390
+ }
391
+ else:
392
+ endpoint = f"{API}/inbox/video/init/"
393
+
394
+ try:
395
+ inicio = client.post(
396
+ endpoint,
397
+ headers={"Authorization": f"Bearer {token}"},
398
+ json=cuerpo,
399
+ )
400
+ except httpx.TimeoutException:
401
+ return self._error(
402
+ 'timed out contacting TikTok to '
403
+ 'initialize the upload'
404
+ )
405
+ except httpx.HTTPError:
406
+ return self._error('could not connect to TikTok to initialize the upload')
407
+
408
+ if inicio.status_code != 200:
409
+ return self._error(mensaje_de_error(inicio, token))
410
+
411
+ try:
412
+ cuerpo_respuesta = inicio.json()
413
+ except json.JSONDecodeError:
414
+ return self._error(
415
+ 'TikTok returned an invalid JSON response when attempting to '
416
+ 'initialize the upload'
417
+ )
418
+
419
+ datos = cuerpo_respuesta.get("data") if isinstance(cuerpo_respuesta, dict) else None
420
+ if not isinstance(datos, dict):
421
+ return self._error(
422
+ "TikTok returned no upload data (missing 'data' field)"
423
+ )
424
+
425
+ upload_url = datos.get("upload_url")
426
+ if not isinstance(upload_url, str) or not upload_url:
427
+ return self._error('TikTok did not return a valid upload URL')
428
+
429
+ publish_id = datos.get("publish_id")
430
+ # Se valida el tipo AQUÍ, antes de subir ningún byte: en este punto
431
+ # TikTok solo ha reservado un id de seguimiento, no ha recibido
432
+ # contenido, así que fallar ahora (en vez de después de subir) es
433
+ # siempre seguro de reintentar -ver el aviso de duplicados en el
434
+ # docstring del módulo-.
435
+ if publish_id is not None and not isinstance(publish_id, str):
436
+ return self._error(
437
+ 'TikTok returned an unexpected publish_id (expected '
438
+ f"a string, received {type(publish_id).__name__})"
439
+ )
440
+
441
+ error_subida = _subir_chunks(
442
+ client, upload_url, asset.path, tamano_bytes, chunk_size, total_chunk_count
443
+ )
444
+ if error_subida is not None:
445
+ return self._error(
446
+ error_subida.mensaje, riesgo_duplicado=error_subida.riesgo_duplicado
447
+ )
448
+
449
+ if modo == "direct":
450
+ return PostResult(
451
+ platform=self.platform,
452
+ status=PostStatus.PUBLICADO,
453
+ platform_id=publish_id,
454
+ )
455
+
456
+ return PostResult(
457
+ platform=self.platform,
458
+ status=PostStatus.PENDIENTE_CONFIRMACION,
459
+ platform_id=publish_id,
460
+ error='pending: open TikTok and confirm the publication',
461
+ )
462
+
463
+ def _resolver_privacidad_y_duracion(
464
+ self, token: str, asset: MediaAsset, client: httpx.Client
465
+ ) -> tuple[str | None, PostResult | None]:
466
+ 'In direct mode, query creator info and check privacy and duration.\n\nThe configured PUBLIC_TO_EVERYONE level must appear in privacy_level_options.\nReject unsupported privacy rather than selecting a different level. The video\nmust fit the returned max_video_post_duration_sec. Return either the validated\nprivacy level or an error PostResult, without sending content.'
467
+ datos, error_creador = _consultar_creator_info(client, token)
468
+ if error_creador is not None:
469
+ return None, self._error(
470
+ f"could not query creator_info on TikTok (required "
471
+ f"before publication in mode: direct): {error_creador}"
472
+ )
473
+
474
+ opciones = datos.get("privacy_level_options")
475
+ if not isinstance(opciones, list) or not all(
476
+ isinstance(o, str) for o in opciones
477
+ ):
478
+ return None, self._error(
479
+ 'TikTok returned no valid list of '
480
+ 'privacy_level_options in creator_info: cannot '
481
+ 'confirm which privacy levels this account supports'
482
+ )
483
+
484
+ if _PRIVACY_LEVEL_DESEADO not in opciones:
485
+ return None, self._error(
486
+ 'the TikTok account does not support publishing with '
487
+ f"privacy_level={_PRIVACY_LEVEL_DESEADO!r} (the level "
488
+ 'this project expects for mode: direct); this account '
489
+ f"supports these options: {opciones!r}. It may "
490
+ 'be a private or underage account: review the '
491
+ 'account privacy settings in '
492
+ 'TikTok, or use mode: inbox to confirm the '
493
+ 'publication manually. No publication is made with a '
494
+ 'different privacy level from the expected one.'
495
+ )
496
+
497
+ maximo = datos.get("max_video_post_duration_sec")
498
+ duracion = asset.duration_s
499
+ if (
500
+ isinstance(maximo, (int, float))
501
+ and duracion is not None
502
+ and duracion > maximo
503
+ ):
504
+ return None, self._error(
505
+ f"the video is {duracion:.0f}s long and exceeds the {maximo:.0f}s "
506
+ 'maximum supported by this account for a direct post '
507
+ '(max_video_post_duration_sec from creator_info, a '
508
+ 'creator-specific limit that may be lower than the general '
509
+ 'TikTok limit); use mode: inbox or provide a shorter video.'
510
+ )
511
+
512
+ return _PRIVACY_LEVEL_DESEADO, None
513
+
514
+ def _comprobar_auditoria(self, brand: Brand, modo: str, valor_auditada: object) -> None:
515
+ 'Enforce the stable Direct Post exclusion before credentials or provider access.'
516
+ if modo != "direct":
517
+ return
518
+
519
+ raise AuditoriaRequerida(
520
+ "TikTok Direct Post is disabled and excluded from the current offer, "
521
+ "regardless of auditada configuration. Use mode: inbox for supported "
522
+ "creator-reviewed inbox upload; the creator must finish publishing in TikTok. "
523
+ "No upload was attempted."
524
+ )
525
+
526
+ def _error(self, mensaje: str, *, riesgo_duplicado: bool = False) -> PostResult:
527
+ return PostResult(
528
+ platform=self.platform,
529
+ status=PostStatus.ERROR,
530
+ error=mensaje,
531
+ riesgo_duplicado=riesgo_duplicado,
532
+ )
533
+
534
+
535
+ ADAPTADORES[Platform.TIKTOK] = TikTokAdapter