socialcli 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of socialcli might be problematic. Click here for more details.

Files changed (224) hide show
  1. socialcli-0.3.0.dist-info/METADATA +168 -0
  2. socialcli-0.3.0.dist-info/RECORD +224 -0
  3. socialcli-0.3.0.dist-info/WHEEL +4 -0
  4. socialcli-0.3.0.dist-info/entry_points.txt +3 -0
  5. socialcli-0.3.0.dist-info/licenses/LICENSE +21 -0
  6. socialctl/__init__.py +0 -0
  7. socialctl/__main__.py +6 -0
  8. socialctl/adapters/__init__.py +6 -0
  9. socialctl/adapters/base.py +68 -0
  10. socialctl/adapters/errores.py +56 -0
  11. socialctl/adapters/facebook.py +180 -0
  12. socialctl/adapters/instagram.py +442 -0
  13. socialctl/adapters/tiktok.py +535 -0
  14. socialctl/adapters/youtube.py +331 -0
  15. socialctl/agent_errors.py +176 -0
  16. socialctl/agent_guide.py +23 -0
  17. socialctl/approval_cli.py +27 -0
  18. socialctl/auth.py +139 -0
  19. socialctl/authflow.py +344 -0
  20. socialctl/brands.py +194 -0
  21. socialctl/capabilities.py +57 -0
  22. socialctl/capabilities.yml +5945 -0
  23. socialctl/cli.py +1766 -0
  24. socialctl/connection_service/__init__.py +0 -0
  25. socialctl/connection_service/__main__.py +33 -0
  26. socialctl/connection_service/app.py +496 -0
  27. socialctl/connection_service/assets/PLATFORM-LOGOS-LICENSE.txt +50 -0
  28. socialctl/connection_service/assets/connection.css +75 -0
  29. socialctl/connection_service/assets/facebook.webp +0 -0
  30. socialctl/connection_service/assets/instagram.svg +194 -0
  31. socialctl/connection_service/assets/socialcli-logo.png +0 -0
  32. socialctl/connection_service/assets/tiktok.png +0 -0
  33. socialctl/connection_service/assets/youtube.svg +1 -0
  34. socialctl/connection_service/landing.py +146 -0
  35. socialctl/connection_service/legacy.py +48 -0
  36. socialctl/connection_service/peer.py +13 -0
  37. socialctl/connection_service/providers.py +324 -0
  38. socialctl/connection_service/settings.py +76 -0
  39. socialctl/connection_service/store.py +124 -0
  40. socialctl/connections/__init__.py +0 -0
  41. socialctl/connections/account_cli.py +438 -0
  42. socialctl/connections/cli.py +379 -0
  43. socialctl/connections/client.py +126 -0
  44. socialctl/connections/credential_cli.py +224 -0
  45. socialctl/connections/credential_store.py +49 -0
  46. socialctl/connections/file_store.py +193 -0
  47. socialctl/connections/keychain.py +78 -0
  48. socialctl/connections/meta_user.py +81 -0
  49. socialctl/connections/rotation_cli.py +116 -0
  50. socialctl/connections/saas_client.py +91 -0
  51. socialctl/connections/temporary_media.py +62 -0
  52. socialctl/delivery_options.py +70 -0
  53. socialctl/engagement.py +200 -0
  54. socialctl/engagement_cli.py +50 -0
  55. socialctl/executor.py +135 -0
  56. socialctl/formatter.py +291 -0
  57. socialctl/hosted_media.py +152 -0
  58. socialctl/identity.py +200 -0
  59. socialctl/inbox.py +298 -0
  60. socialctl/inbox_cli.py +46 -0
  61. socialctl/inventory.py +155 -0
  62. socialctl/inventory_cli.py +171 -0
  63. socialctl/inventory_sources.py +101 -0
  64. socialctl/management/__init__.py +1 -0
  65. socialctl/management/batch_cli.py +115 -0
  66. socialctl/management/batches.py +325 -0
  67. socialctl/management/changes.py +485 -0
  68. socialctl/management/cli.py +227 -0
  69. socialctl/management/comments_cli.py +352 -0
  70. socialctl/management/community_batch_cli.py +63 -0
  71. socialctl/management/community_batches.py +294 -0
  72. socialctl/management/community_changes.py +370 -0
  73. socialctl/management/community_cli.py +142 -0
  74. socialctl/management/community_schema.py +119 -0
  75. socialctl/management/facebook_groups.py +1118 -0
  76. socialctl/management/groups_cli.py +274 -0
  77. socialctl/management/inbox_readers.py +87 -0
  78. socialctl/management/meta_changes.py +197 -0
  79. socialctl/management/meta_cli.py +342 -0
  80. socialctl/management/meta_client.py +631 -0
  81. socialctl/management/meta_comment_inbox.py +565 -0
  82. socialctl/management/meta_comments.py +587 -0
  83. socialctl/management/meta_errors.py +59 -0
  84. socialctl/management/meta_pagination.py +52 -0
  85. socialctl/management/meta_schema.py +128 -0
  86. socialctl/management/meta_webhooks.py +147 -0
  87. socialctl/management/metadata_parts.py +144 -0
  88. socialctl/management/owned_assets.py +85 -0
  89. socialctl/management/owned_changes.py +470 -0
  90. socialctl/management/owned_cli.py +128 -0
  91. socialctl/management/owned_reorder.py +62 -0
  92. socialctl/management/owned_schema.py +368 -0
  93. socialctl/management/reply_context.py +24 -0
  94. socialctl/management/reply_evidence.py +80 -0
  95. socialctl/management/resource_changes.py +407 -0
  96. socialctl/management/resource_cli.py +136 -0
  97. socialctl/management/snippet_readback.py +26 -0
  98. socialctl/management/stories.py +713 -0
  99. socialctl/management/stories_cli.py +172 -0
  100. socialctl/management/youtube.py +323 -0
  101. socialctl/management/youtube_community.py +326 -0
  102. socialctl/management/youtube_metadata.py +122 -0
  103. socialctl/management/youtube_owned.py +259 -0
  104. socialctl/management/youtube_resources.py +305 -0
  105. socialctl/media.py +269 -0
  106. socialctl/media_cli.py +183 -0
  107. socialctl/media_ingress.py +211 -0
  108. socialctl/media_preflight.py +114 -0
  109. socialctl/media_registry.py +301 -0
  110. socialctl/media_requirements.py +52 -0
  111. socialctl/metricas/__init__.py +6 -0
  112. socialctl/metricas/almacen.py +133 -0
  113. socialctl/metricas/analytics_changes.py +295 -0
  114. socialctl/metricas/analytics_cli.py +191 -0
  115. socialctl/metricas/analytics_client.py +304 -0
  116. socialctl/metricas/analytics_ingest.py +260 -0
  117. socialctl/metricas/analytics_schema.py +174 -0
  118. socialctl/metricas/base.py +147 -0
  119. socialctl/metricas/facebook.py +418 -0
  120. socialctl/metricas/insights.py +227 -0
  121. socialctl/metricas/instagram.py +133 -0
  122. socialctl/metricas/modelos.py +87 -0
  123. socialctl/metricas/piezas.py +134 -0
  124. socialctl/metricas/reach.py +94 -0
  125. socialctl/metricas/reporting_client.py +112 -0
  126. socialctl/metricas/resumen.py +251 -0
  127. socialctl/metricas/slugs.py +89 -0
  128. socialctl/metricas/tiktok.py +185 -0
  129. socialctl/metricas/youtube.py +403 -0
  130. socialctl/migration_cli.py +60 -0
  131. socialctl/migration_files.py +68 -0
  132. socialctl/models.py +150 -0
  133. socialctl/native_schedule/__init__.py +13 -0
  134. socialctl/native_schedule/api_delivery.py +15 -0
  135. socialctl/native_schedule/api_reconcile.py +40 -0
  136. socialctl/native_schedule/approval.py +160 -0
  137. socialctl/native_schedule/cadence.py +73 -0
  138. socialctl/native_schedule/cli.py +510 -0
  139. socialctl/native_schedule/dispatcher.py +120 -0
  140. socialctl/native_schedule/facebook.py +112 -0
  141. socialctl/native_schedule/facebook_reels.py +140 -0
  142. socialctl/native_schedule/first_comment.py +181 -0
  143. socialctl/native_schedule/handoff.py +193 -0
  144. socialctl/native_schedule/instagram_delivery.py +224 -0
  145. socialctl/native_schedule/instagram_runtime.py +66 -0
  146. socialctl/native_schedule/inventory.py +70 -0
  147. socialctl/native_schedule/migration.py +310 -0
  148. socialctl/native_schedule/models.py +103 -0
  149. socialctl/native_schedule/reconcile.py +83 -0
  150. socialctl/native_schedule/remote_data.py +177 -0
  151. socialctl/native_schedule/report.py +151 -0
  152. socialctl/native_schedule/store.py +560 -0
  153. socialctl/native_schedule/verification.py +32 -0
  154. socialctl/native_schedule/youtube.py +339 -0
  155. socialctl/operation_output.py +87 -0
  156. socialctl/plantillas/accounts.yml +33 -0
  157. socialctl/plantillas/brand.md +95 -0
  158. socialctl/plantillas/estrategia.md +64 -0
  159. socialctl/postfile.py +240 -0
  160. socialctl/preflight.py +73 -0
  161. socialctl/publication_policy.py +34 -0
  162. socialctl/publication_status.py +75 -0
  163. socialctl/publication_steps.py +398 -0
  164. socialctl/publish_output.py +96 -0
  165. socialctl/publisher.py +445 -0
  166. socialctl/queue_migration.py +302 -0
  167. socialctl/read_budget.py +224 -0
  168. socialctl/read_reports.py +91 -0
  169. socialctl/rutas.py +103 -0
  170. socialctl/saas/__init__.py +1 -0
  171. socialctl/saas/access.py +75 -0
  172. socialctl/saas/auth.py +213 -0
  173. socialctl/saas/backup.py +124 -0
  174. socialctl/saas/billing.py +327 -0
  175. socialctl/saas/billing_enrollment.py +1237 -0
  176. socialctl/saas/billing_enrollment_schema.py +519 -0
  177. socialctl/saas/billing_inventory.py +775 -0
  178. socialctl/saas/billing_reconcile.py +127 -0
  179. socialctl/saas/billing_routes.py +27 -0
  180. socialctl/saas/budget.py +66 -0
  181. socialctl/saas/connections.py +374 -0
  182. socialctl/saas/consent.py +52 -0
  183. socialctl/saas/lifecycle.py +341 -0
  184. socialctl/saas/mail.py +89 -0
  185. socialctl/saas/maintenance.py +68 -0
  186. socialctl/saas/meta_user.py +298 -0
  187. socialctl/saas/meta_webhooks.py +358 -0
  188. socialctl/saas/migration.py +107 -0
  189. socialctl/saas/models.py +8 -0
  190. socialctl/saas/operations.py +102 -0
  191. socialctl/saas/pages.py +126 -0
  192. socialctl/saas/pairing.py +158 -0
  193. socialctl/saas/permissions.py +57 -0
  194. socialctl/saas/reminders.py +128 -0
  195. socialctl/saas/renewal.py +74 -0
  196. socialctl/saas/restore_guard.py +229 -0
  197. socialctl/saas/revocation.py +97 -0
  198. socialctl/saas/rotation.py +114 -0
  199. socialctl/saas/routes.py +221 -0
  200. socialctl/saas/settings.py +34 -0
  201. socialctl/saas/store.py +106 -0
  202. socialctl/saas/temporary_media.py +245 -0
  203. socialctl/saas/webhooks.py +76 -0
  204. socialctl/schedule_remote.py +262 -0
  205. socialctl/schedule_sqlite.py +44 -0
  206. socialctl/scheduler.py +581 -0
  207. socialctl/scheduler_launchd.py +319 -0
  208. socialctl/scheduler_systemd.py +167 -0
  209. socialctl/source_links.py +119 -0
  210. socialctl/ui/__init__.py +1 -0
  211. socialctl/ui/assets/agent-manual.md +850 -0
  212. socialctl/ui/assets/agents.md +27 -0
  213. socialctl/ui/assets/components.css +20 -0
  214. socialctl/ui/assets/connection-layout.css +53 -0
  215. socialctl/ui/assets/otp.template.html +10 -0
  216. socialctl/ui/assets/tokens.css +1 -0
  217. socialctl/ui/assets/trial.template.html +11 -0
  218. socialctl/ui/assets/web-layout.css +38 -0
  219. socialctl/ui/email/otp.html +10 -0
  220. socialctl/ui/email/trial.html +11 -0
  221. socialctl/upload_consent.py +13 -0
  222. socialctl/workspace.py +8 -0
  223. socialctl/youtube_data.py +471 -0
  224. socialctl/youtube_data_service.py +225 -0
socialctl/scheduler.py ADDED
@@ -0,0 +1,581 @@
1
+ """Local persistent and idempotent scheduling queue."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import fcntl
6
+ import hashlib
7
+ import hmac
8
+ import json
9
+ import os
10
+ import tempfile
11
+ import uuid
12
+ import sqlite3
13
+ from contextlib import contextmanager
14
+ from datetime import datetime, timezone
15
+ from pathlib import Path
16
+ from typing import Any, Iterator
17
+
18
+ from pydantic import BaseModel, ConfigDict, ValidationError, field_validator
19
+
20
+ from socialctl.brands import Brand
21
+ from socialctl.media import ruta_relativa_efectiva
22
+ from socialctl.models import Platform, Post
23
+
24
+ APPROVAL_HASH_VERSION = "v2"
25
+ _HASH_CHUNK_SIZE = 1024 * 1024
26
+ _UNSET = object()
27
+ _REUSABLE_HISTORY_STATUSES = {"cancelled", "published", "error"}
28
+
29
+
30
+ class ScheduleError(ValueError):
31
+ """A readable queue format or consistency error."""
32
+
33
+
34
+ class ScheduleEntry(BaseModel):
35
+ model_config = ConfigDict(extra="forbid")
36
+
37
+ id: str
38
+ brand: str
39
+ slug: str
40
+ platform: str
41
+ scheduled_at: datetime
42
+ status: str = "approved"
43
+ attempts: int = 0
44
+ last_error: str | None = None
45
+ platform_id: str | None = None
46
+ content_hash: str | None = None
47
+ approval_migration: str | None = None
48
+ created_at: datetime
49
+ updated_at: datetime
50
+
51
+ @field_validator("scheduled_at", "created_at", "updated_at")
52
+ @classmethod
53
+ def validate_datetime(cls, value: datetime) -> datetime:
54
+ if value.tzinfo is None or value.utcoffset() is None:
55
+ raise ScheduleError("date must include a timezone, for example -04:00")
56
+ return value
57
+
58
+
59
+ def occurrence_id(brand_root: Path, entry: ScheduleEntry) -> str:
60
+ """Existing publisher identity, shared by audit and migration."""
61
+ return str(uuid.uuid5(uuid.NAMESPACE_URL,
62
+ f"{brand_root.resolve()}:{entry.id}:{entry.created_at.isoformat()}:{entry.scheduled_at.isoformat()}"))
63
+
64
+
65
+ def tombstone_identity(entry: ScheduleEntry) -> tuple:
66
+ # Date edits must never resurrect the same transferred occurrence.
67
+ return (entry.id, entry.created_at.astimezone(timezone.utc), entry.brand, entry.slug, entry.platform)
68
+
69
+
70
+ class ScheduleStore:
71
+ """Atomic persistence for a brand's queue."""
72
+
73
+ def __init__(self, brand_root: Path, *, backend: str | None = None) -> None:
74
+ self.root = brand_root / ".socialctl"
75
+ self.path = self.root / "schedules.json"
76
+ self.mutation_lock_path = self.root / "schedules.lock"
77
+ self.executor_lock_path = self.root / "executor.lock"
78
+ self.database_path = self.root / "schedules.sqlite3"
79
+ if backend not in (None, "json", "sqlite"):
80
+ raise ScheduleError("backend must be json or sqlite")
81
+ if backend == "sqlite" and not self.database_path.exists():
82
+ with self._mutation_lock():
83
+ if not self.database_path.exists():
84
+ self._ensure_root()
85
+ entries = self._load_unlocked()
86
+ from socialctl import schedule_sqlite
87
+ temporary = self.root / "schedules.sqlite3.pending"
88
+ try:
89
+ schedule_sqlite.save(temporary, [e.model_dump(mode="json") for e in entries])
90
+ os.replace(temporary, self.database_path)
91
+ self._sync_directory(self.root)
92
+ finally:
93
+ temporary.unlink(missing_ok=True)
94
+
95
+ def load(self) -> list[ScheduleEntry]:
96
+ return self._load_unlocked()
97
+
98
+ def _load_unlocked(self) -> list[ScheduleEntry]:
99
+ if self.database_path.exists():
100
+ from socialctl import schedule_sqlite
101
+ try:
102
+ return [ScheduleEntry.model_validate(item) for item in schedule_sqlite.load(self.database_path)]
103
+ except (OSError, ValueError, sqlite3.Error) as exc:
104
+ raise ScheduleError(f"could not read SQLite: {exc}") from exc
105
+ if not self.path.exists():
106
+ return []
107
+ try:
108
+ data = json.loads(self.path.read_text(encoding="utf-8"))
109
+ entries = data.get("entries", []) if isinstance(data, dict) else data
110
+ if not isinstance(entries, list):
111
+ raise ScheduleError("queue does not contain a list of entries")
112
+ return [ScheduleEntry.model_validate(item) for item in entries]
113
+ except (OSError, json.JSONDecodeError, ValidationError) as exc:
114
+ raise ScheduleError(f"could not read the queue {self.path}: {exc}") from exc
115
+
116
+ def save(self, entries: list[ScheduleEntry]) -> None:
117
+ with self._mutation_lock():
118
+ self._save_unlocked(entries)
119
+
120
+ def _save_unlocked(self, entries: list[ScheduleEntry]) -> None:
121
+ if self.database_path.exists():
122
+ from socialctl import schedule_sqlite
123
+ try:
124
+ schedule_sqlite.save(self.database_path, [e.model_dump(mode="json") for e in entries])
125
+ self._sync_directory(self.root)
126
+ return
127
+ except (OSError, ValueError, sqlite3.Error) as exc:
128
+ raise ScheduleError(f"could not save SQLite: {exc}") from exc
129
+ payload = {
130
+ "version": 1,
131
+ "updated_at": datetime.now(timezone.utc).isoformat(),
132
+ "entries": [entry.model_dump(mode="json") for entry in entries],
133
+ }
134
+ tmp_name: str | None = None
135
+ try:
136
+ self._ensure_root()
137
+ fd, tmp_name = tempfile.mkstemp(
138
+ prefix="schedules.", suffix=".tmp", dir=self.root
139
+ )
140
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
141
+ json.dump(payload, handle, indent=2, ensure_ascii=False)
142
+ handle.write("\n")
143
+ handle.flush()
144
+ os.fsync(handle.fileno())
145
+ os.replace(tmp_name, self.path)
146
+ # El fsync del fichero cubre sus bytes, pero el rename solo es
147
+ # durable al sincronizar también el directorio que contiene la
148
+ # entrada. claim_due no puede devolver ``running`` (y por tanto
149
+ # no puede abrir la puerta a publicar) antes de completar esto.
150
+ self._sync_directory(self.root)
151
+ except OSError as exc:
152
+ if tmp_name is not None:
153
+ try:
154
+ os.unlink(tmp_name)
155
+ except OSError:
156
+ pass
157
+ raise ScheduleError(f"could not save the queue {self.path}: {exc}") from exc
158
+
159
+ def _ensure_root(self) -> None:
160
+ """Create the queue directory and durably record its parent entry."""
161
+ self.root.mkdir(parents=True, exist_ok=True)
162
+ self._sync_directory(self.root.parent)
163
+
164
+ def backup(self, destination: Path) -> None:
165
+ """Consistent snapshot while holding the queue mutation lock."""
166
+ with self._mutation_lock():
167
+ if self.database_path.exists():
168
+ from socialctl import schedule_sqlite
169
+ schedule_sqlite.backup(self.database_path, destination)
170
+ else:
171
+ destination.write_bytes(self.path.read_bytes())
172
+ with destination.open("rb") as handle:
173
+ os.fsync(handle.fileno())
174
+ self._sync_directory(destination.parent)
175
+
176
+ @staticmethod
177
+ def _sync_directory(path: Path) -> None:
178
+ descriptor = os.open(path, os.O_RDONLY)
179
+ try:
180
+ os.fsync(descriptor)
181
+ finally:
182
+ os.close(descriptor)
183
+
184
+ def add(self, entry: ScheduleEntry) -> None:
185
+ with self._mutation_lock():
186
+ entries = self._load_unlocked()
187
+ for old in entries:
188
+ if old.id == entry.id and old.status not in _REUSABLE_HISTORY_STATUSES:
189
+ raise ScheduleError(f"a pending schedule already exists with ID {entry.id}")
190
+ entries.append(entry)
191
+ entries.sort(key=lambda item: item.scheduled_at)
192
+ self._save_unlocked(entries)
193
+
194
+ def add_many(self, new_entries: list[ScheduleEntry]) -> None:
195
+ """Add a batch atomically: all entries or none."""
196
+ with self._mutation_lock():
197
+ entries = self._load_unlocked()
198
+ ids = {
199
+ entry.id
200
+ for entry in entries
201
+ if entry.status not in _REUSABLE_HISTORY_STATUSES
202
+ }
203
+ nuevos = [entry.id for entry in new_entries]
204
+ if len(set(nuevos)) != len(nuevos):
205
+ raise ScheduleError("batch contains duplicate identifiers")
206
+ repetidos = ids.intersection(nuevos)
207
+ if repetidos:
208
+ raise ScheduleError(f"a pending schedule already exists with ID {sorted(repetidos)[0]}")
209
+ entries.extend(new_entries)
210
+ entries.sort(key=lambda item: item.scheduled_at)
211
+ self._save_unlocked(entries)
212
+
213
+ def update(self, entry: ScheduleEntry) -> None:
214
+ with self._mutation_lock():
215
+ entries = self._load_unlocked()
216
+ index = self._current_index(entries, entry.id)
217
+ if entries[index].created_at != entry.created_at:
218
+ raise ScheduleError(
219
+ f"schedule {entry.id} is an obsolete historical occurrence"
220
+ )
221
+ entries[index] = entry
222
+ self._save_unlocked(entries)
223
+
224
+ def get(self, entry_id: str) -> ScheduleEntry:
225
+ entries = self.load()
226
+ return entries[self._current_index(entries, entry_id)]
227
+
228
+ def due(self, now: datetime | None = None) -> list[ScheduleEntry]:
229
+ self.assert_ready()
230
+ tombstones = self.tombstones()
231
+ moment = now or datetime.now(timezone.utc)
232
+ return [
233
+ entry for entry in self.load()
234
+ if tombstone_identity(entry) not in tombstones
235
+ and entry.status == "approved" and entry.scheduled_at.astimezone(timezone.utc) <= moment
236
+ ]
237
+
238
+ def claim_due(self, entry_id: str, moment: datetime, *,
239
+ expected: ScheduleEntry | None = None) -> ScheduleEntry | None:
240
+ """Atomically move a due approved entry to running."""
241
+ with self._mutation_lock():
242
+ entries = self._load_unlocked()
243
+ entry = entries[self._current_index(entries, entry_id)]
244
+ # Slot admission covers this snapshot, not any later occurrence with
245
+ # the same reusable ID. Compare all evidence under the mutation lock.
246
+ if expected is not None and entry.model_dump(mode="json") != expected.model_dump(mode="json"):
247
+ return None
248
+ if (
249
+ tombstone_identity(entry) in self.tombstones()
250
+ or entry.status != "approved"
251
+ or entry.scheduled_at.astimezone(timezone.utc) > moment
252
+ ):
253
+ return None
254
+ entry.status = "running"
255
+ entry.attempts += 1
256
+ entry.updated_at = moment
257
+ self._save_unlocked(entries)
258
+ return entry.model_copy(deep=True)
259
+
260
+ def cancel(self, entry_id: str, moment: datetime) -> ScheduleEntry:
261
+ """Cancel only while the entry remains in a cancelable state."""
262
+ with self._mutation_lock():
263
+ entries = self._load_unlocked()
264
+ entry = entries[self._current_index(entries, entry_id)]
265
+ if entry.status not in {"approved", "error"}:
266
+ raise ScheduleError(
267
+ f"cannot cancel {entry_id}: current state {entry.status}"
268
+ )
269
+ entry.status = "cancelled"
270
+ entry.updated_at = moment
271
+ self._save_unlocked(entries)
272
+ return entry.model_copy(deep=True)
273
+
274
+ def reschedule(
275
+ self, entry_id: str, scheduled_at: datetime, moment: datetime
276
+ ) -> ScheduleEntry:
277
+ """Reschedule only if a concurrent executor has not claimed the entry."""
278
+ with self._mutation_lock():
279
+ entries = self._load_unlocked()
280
+ entry = entries[self._current_index(entries, entry_id)]
281
+ if entry.status not in {"approved", "error"}:
282
+ raise ScheduleError(
283
+ f"cannot reschedule {entry_id}: current state {entry.status}"
284
+ )
285
+ entry.scheduled_at = scheduled_at
286
+ entry.status = "approved"
287
+ entry.last_error = None
288
+ entry.updated_at = moment
289
+ self._save_unlocked(entries)
290
+ return entry.model_copy(deep=True)
291
+
292
+ def transition(
293
+ self,
294
+ entry_id: str,
295
+ expected_statuses: set[str],
296
+ *,
297
+ status: str,
298
+ moment: datetime,
299
+ last_error: str | None = None,
300
+ platform_id: str | None | object = _UNSET,
301
+ ) -> ScheduleEntry | None:
302
+ """Update execution fields only from a still-current state."""
303
+ with self._mutation_lock():
304
+ entries = self._load_unlocked()
305
+ entry = entries[self._current_index(entries, entry_id)]
306
+ if entry.status not in expected_statuses:
307
+ return None
308
+ entry.status = status
309
+ entry.last_error = last_error
310
+ if platform_id is not _UNSET:
311
+ entry.platform_id = platform_id # type: ignore[assignment]
312
+ entry.updated_at = moment
313
+ self._save_unlocked(entries)
314
+ return entry.model_copy(deep=True)
315
+
316
+ @staticmethod
317
+ def _current_index(entries: list[ScheduleEntry], entry_id: str) -> int:
318
+ matches = [index for index, entry in enumerate(entries) if entry.id == entry_id]
319
+ if not matches:
320
+ raise ScheduleError(f"schedule does not exist: {entry_id}")
321
+ active = [
322
+ index
323
+ for index in matches
324
+ if entries[index].status not in _REUSABLE_HISTORY_STATUSES
325
+ ]
326
+ if len(active) > 1:
327
+ raise ScheduleError(
328
+ f"queue contains multiple active schedules with ID {entry_id}"
329
+ )
330
+ if active:
331
+ return active[0]
332
+ return max(matches, key=lambda index: (entries[index].created_at, index))
333
+
334
+ def recover_stale(self, max_age_s: int = 900) -> int:
335
+ """Isolate an interrupted execution with an uncertain remote result for review."""
336
+ now = now_utc()
337
+ with self._mutation_lock():
338
+ entries = self._load_unlocked()
339
+ changed = 0
340
+ for entry in entries:
341
+ age = (now - entry.updated_at.astimezone(timezone.utc)).total_seconds()
342
+ if entry.status == "running" and age > max_age_s:
343
+ entry.status = "manual_review"
344
+ entry.last_error = (
345
+ "execution interrupted: confirm the remote result before retrying"
346
+ )
347
+ entry.updated_at = now
348
+ changed += 1
349
+ if changed:
350
+ self._save_unlocked(entries)
351
+ return changed
352
+
353
+ @contextmanager
354
+ def _mutation_lock(self) -> Iterator[None]:
355
+ with self._file_lock(self.mutation_lock_path, nonblocking=False):
356
+ self.assert_ready()
357
+ yield
358
+
359
+ def tombstones(self) -> set[tuple]:
360
+ path = self.root / "legacy-tombstones.json"
361
+ if not path.exists():
362
+ return set()
363
+ try:
364
+ data = json.loads(path.read_bytes())
365
+ if set(data) != {"version", "entries"} or type(data["version"]) is not int or data["version"] != 1 or not isinstance(data["entries"], dict):
366
+ raise ValueError()
367
+ identities = set()
368
+ for key, item in data["entries"].items():
369
+ if set(item) != {"entry", "digest"} or not isinstance(item["digest"], str) or len(item["digest"]) != 64:
370
+ raise ValueError()
371
+ int(item["digest"], 16)
372
+ entry = ScheduleEntry.model_validate(item["entry"])
373
+ if key != occurrence_id(self.root.parent, entry) or entry.brand != self.root.parent.name:
374
+ raise ValueError()
375
+ identity = tombstone_identity(entry)
376
+ if identity in identities:
377
+ raise ValueError()
378
+ identities.add(identity)
379
+ return identities
380
+ except (OSError, ValueError, TypeError, KeyError) as exc:
381
+ raise ScheduleError("invalid tombstone ledger; execution blocked") from exc
382
+
383
+ def assert_ready(self) -> None:
384
+ from socialctl.schedule_remote import is_remote, REMOTE_NOTICE
385
+ if is_remote(self.root.parent):
386
+ raise ScheduleError(REMOTE_NOTICE)
387
+ from socialctl.native_schedule.migration import validate_committed_guard
388
+ self.tombstones() # Corruption fails closed even without a guard.
389
+ guard = self.root / "migration-active.json"
390
+ if guard.exists():
391
+ if not validate_committed_guard(self):
392
+ raise ScheduleError("migration interrupted or in progress; explicit recovery required before execution")
393
+ for path in (self.root / "native-migrations").glob("*/intent.json"):
394
+ try:
395
+ if json.loads(path.read_bytes())["status"] == "committed" and validate_committed_guard(self):
396
+ continue
397
+ except (OSError, ValueError, KeyError):
398
+ pass
399
+ raise ScheduleError("native transfer interrupted; requires migration-recover")
400
+ for path in (self.root / "migrations").glob("*/intent.json"):
401
+ try:
402
+ if json.loads(path.read_bytes())["status"] in {"committed", "rolled_back"}:
403
+ continue
404
+ except (OSError, ValueError, KeyError):
405
+ pass
406
+ raise ScheduleError("migration interrupted; explicit rollback required before execution")
407
+
408
+ @contextmanager
409
+ def executor_lock(self) -> Iterator[None]:
410
+ """Prevent two processes from executing the same brand simultaneously."""
411
+ self.root.mkdir(parents=True, exist_ok=True)
412
+ descriptor = os.open(
413
+ self.executor_lock_path, os.O_RDWR | os.O_CREAT, 0o600
414
+ )
415
+ acquired = False
416
+ try:
417
+ try:
418
+ fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
419
+ acquired = True
420
+ except BlockingIOError as exc:
421
+ raise ScheduleError(
422
+ "another executor is already processing this brand's queue"
423
+ ) from exc
424
+ self.assert_ready()
425
+ yield
426
+ finally:
427
+ if acquired:
428
+ fcntl.flock(descriptor, fcntl.LOCK_UN)
429
+ os.close(descriptor)
430
+
431
+ @contextmanager
432
+ def _file_lock(self, path: Path, *, nonblocking: bool) -> Iterator[None]:
433
+ self.root.mkdir(parents=True, exist_ok=True)
434
+ descriptor = os.open(path, os.O_RDWR | os.O_CREAT, 0o600)
435
+ operation = fcntl.LOCK_EX | (fcntl.LOCK_NB if nonblocking else 0)
436
+ acquired = False
437
+ try:
438
+ fcntl.flock(descriptor, operation)
439
+ acquired = True
440
+ yield
441
+ finally:
442
+ if acquired:
443
+ fcntl.flock(descriptor, fcntl.LOCK_UN)
444
+ os.close(descriptor)
445
+
446
+
447
+ def parse_scheduled_at(value: str) -> datetime:
448
+ try:
449
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
450
+ except ValueError as exc:
451
+ raise ScheduleError(
452
+ 'invalid date; use ISO 8601 with timezone, for example 2026-09-15T14:00:00-04:00'
453
+ ) from exc
454
+ if parsed.tzinfo is None or parsed.utcoffset() is None:
455
+ raise ScheduleError("date must include a timezone, for example -04:00")
456
+ return parsed
457
+
458
+
459
+ def now_utc() -> datetime:
460
+ return datetime.now(timezone.utc)
461
+
462
+
463
+ def _canonical_json(value: Any) -> bytes:
464
+ return json.dumps(
465
+ value,
466
+ ensure_ascii=False,
467
+ sort_keys=True,
468
+ separators=(",", ":"),
469
+ default=str,
470
+ ).encode("utf-8")
471
+
472
+
473
+ def approval_hash(post: Post, brand: Brand, platform: Platform) -> str:
474
+ """Compute the v2 fingerprint of payload, account identity and actual media bytes."""
475
+ platform_post = post.platforms[platform]
476
+ media = [
477
+ {
478
+ "kind": asset.kind.value,
479
+ "path": ruta_relativa_efectiva(asset),
480
+ }
481
+ for asset in platform_post.media
482
+ ]
483
+ approved = {
484
+ "account_identity": {
485
+ "account": brand.cuentas.get(platform.value) or {},
486
+ "brand": brand.nombre,
487
+ "platform": platform.value,
488
+ },
489
+ "payload": {
490
+ "body": platform_post.body,
491
+ "first_comment": platform_post.first_comment,
492
+ "hashtags": platform_post.hashtags,
493
+ "link": platform_post.link,
494
+ "media": media,
495
+ "platform": platform.value,
496
+ "privacy": platform_post.privacy,
497
+ "title": platform_post.title,
498
+ },
499
+ "schema": "socialctl.schedule.approval.v2",
500
+ }
501
+ if platform == Platform.YOUTUBE:
502
+ from socialctl.upload_consent import youtube_certification
503
+ approved['uploader_certification'] = youtube_certification()
504
+ # Non-YouTube approvals retain their existing serialization. YouTube uploads
505
+ # need a new owner preview because earlier approvals lacked certification.
506
+ # An explicit empty list is intent and must produce a different approval.
507
+ for field in ("tags", "visible_hashtags", "content_origin", "source_video_id"):
508
+ value = getattr(platform_post, field)
509
+ if value is not None:
510
+ approved["payload"][field] = value
511
+ if post.source_mapping_digest is not None:
512
+ approved["payload"]["source_mapping_digest"] = post.source_mapping_digest
513
+ digest = hashlib.sha256(_canonical_json(approved))
514
+ for asset in platform_post.media:
515
+ digest.update(b"\0media-bytes\0")
516
+ digest.update(_canonical_json(ruta_relativa_efectiva(asset)))
517
+ try:
518
+ with asset.path.open("rb") as handle:
519
+ while chunk := handle.read(_HASH_CHUNK_SIZE):
520
+ digest.update(chunk)
521
+ except OSError as exc:
522
+ raise ScheduleError(
523
+ f"could not calculate the approval fingerprint for {asset.path}: {exc}"
524
+ ) from exc
525
+ return f"{APPROVAL_HASH_VERSION}:{digest.hexdigest()}"
526
+
527
+
528
+ def schedule_digest(slug: str, approvals: dict[str, str], at: datetime) -> str:
529
+ """Binds an existing-post preview to payloads, identity and the exact instant."""
530
+ return hashlib.sha256(_canonical_json({
531
+ "schema": "socialctl.schedule.preview.v1", "slug": slug,
532
+ "at": at.astimezone(timezone.utc).isoformat(),
533
+ "approvals": approvals,
534
+ })).hexdigest()
535
+
536
+
537
+ def legacy_approval_hash(post: Post, platform: Platform) -> str:
538
+ """Reproduce the exact unversioned fingerprint used by existing queues."""
539
+ platform_post = post.platforms[platform]
540
+ digest = hashlib.sha256()
541
+ digest.update(platform_post.body.encode("utf-8"))
542
+ digest.update((platform_post.title or "").encode("utf-8"))
543
+ digest.update((platform_post.first_comment or "").encode("utf-8"))
544
+ digest.update("\0".join(platform_post.hashtags).encode("utf-8"))
545
+ for asset in platform_post.media:
546
+ digest.update(str(asset.path).encode("utf-8"))
547
+ try:
548
+ stat = asset.path.stat()
549
+ digest.update(str(stat.st_size).encode("ascii"))
550
+ digest.update(str(stat.st_mtime_ns).encode("ascii"))
551
+ except OSError:
552
+ digest.update(b"missing")
553
+ return digest.hexdigest()
554
+
555
+
556
+ def approval_review_reason(
557
+ stored_hash: str | None,
558
+ post: Post,
559
+ brand: Brand,
560
+ platform: Platform,
561
+ ) -> str | None:
562
+ """Return a manual-review reason, or None for an unchanged v2 approval."""
563
+ if not stored_hash:
564
+ return "legacy entry has no approval fingerprint; manual review required"
565
+ if stored_hash.startswith(f"{APPROVAL_HASH_VERSION}:"):
566
+ current = approval_hash(post, brand, platform)
567
+ if hmac.compare_digest(stored_hash, current):
568
+ return None
569
+ return (
570
+ 'approval fingerprint does not match: content, media or account identity changed; manual review required'
571
+ )
572
+ if ":" in stored_hash:
573
+ return "unknown approval fingerprint version; manual review required"
574
+ current_legacy = legacy_approval_hash(post, platform)
575
+ if not hmac.compare_digest(stored_hash, current_legacy):
576
+ return (
577
+ 'legacy approval fingerprint does not match current content; manual review required'
578
+ )
579
+ return (
580
+ 'the legacy approval fingerprint matches, but did not cover media bytes or account identity; manual review required'
581
+ )