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.
- socialcli-0.3.0.dist-info/METADATA +168 -0
- socialcli-0.3.0.dist-info/RECORD +224 -0
- socialcli-0.3.0.dist-info/WHEEL +4 -0
- socialcli-0.3.0.dist-info/entry_points.txt +3 -0
- socialcli-0.3.0.dist-info/licenses/LICENSE +21 -0
- socialctl/__init__.py +0 -0
- socialctl/__main__.py +6 -0
- socialctl/adapters/__init__.py +6 -0
- socialctl/adapters/base.py +68 -0
- socialctl/adapters/errores.py +56 -0
- socialctl/adapters/facebook.py +180 -0
- socialctl/adapters/instagram.py +442 -0
- socialctl/adapters/tiktok.py +535 -0
- socialctl/adapters/youtube.py +331 -0
- socialctl/agent_errors.py +176 -0
- socialctl/agent_guide.py +23 -0
- socialctl/approval_cli.py +27 -0
- socialctl/auth.py +139 -0
- socialctl/authflow.py +344 -0
- socialctl/brands.py +194 -0
- socialctl/capabilities.py +57 -0
- socialctl/capabilities.yml +5945 -0
- socialctl/cli.py +1766 -0
- socialctl/connection_service/__init__.py +0 -0
- socialctl/connection_service/__main__.py +33 -0
- socialctl/connection_service/app.py +496 -0
- socialctl/connection_service/assets/PLATFORM-LOGOS-LICENSE.txt +50 -0
- socialctl/connection_service/assets/connection.css +75 -0
- socialctl/connection_service/assets/facebook.webp +0 -0
- socialctl/connection_service/assets/instagram.svg +194 -0
- socialctl/connection_service/assets/socialcli-logo.png +0 -0
- socialctl/connection_service/assets/tiktok.png +0 -0
- socialctl/connection_service/assets/youtube.svg +1 -0
- socialctl/connection_service/landing.py +146 -0
- socialctl/connection_service/legacy.py +48 -0
- socialctl/connection_service/peer.py +13 -0
- socialctl/connection_service/providers.py +324 -0
- socialctl/connection_service/settings.py +76 -0
- socialctl/connection_service/store.py +124 -0
- socialctl/connections/__init__.py +0 -0
- socialctl/connections/account_cli.py +438 -0
- socialctl/connections/cli.py +379 -0
- socialctl/connections/client.py +126 -0
- socialctl/connections/credential_cli.py +224 -0
- socialctl/connections/credential_store.py +49 -0
- socialctl/connections/file_store.py +193 -0
- socialctl/connections/keychain.py +78 -0
- socialctl/connections/meta_user.py +81 -0
- socialctl/connections/rotation_cli.py +116 -0
- socialctl/connections/saas_client.py +91 -0
- socialctl/connections/temporary_media.py +62 -0
- socialctl/delivery_options.py +70 -0
- socialctl/engagement.py +200 -0
- socialctl/engagement_cli.py +50 -0
- socialctl/executor.py +135 -0
- socialctl/formatter.py +291 -0
- socialctl/hosted_media.py +152 -0
- socialctl/identity.py +200 -0
- socialctl/inbox.py +298 -0
- socialctl/inbox_cli.py +46 -0
- socialctl/inventory.py +155 -0
- socialctl/inventory_cli.py +171 -0
- socialctl/inventory_sources.py +101 -0
- socialctl/management/__init__.py +1 -0
- socialctl/management/batch_cli.py +115 -0
- socialctl/management/batches.py +325 -0
- socialctl/management/changes.py +485 -0
- socialctl/management/cli.py +227 -0
- socialctl/management/comments_cli.py +352 -0
- socialctl/management/community_batch_cli.py +63 -0
- socialctl/management/community_batches.py +294 -0
- socialctl/management/community_changes.py +370 -0
- socialctl/management/community_cli.py +142 -0
- socialctl/management/community_schema.py +119 -0
- socialctl/management/facebook_groups.py +1118 -0
- socialctl/management/groups_cli.py +274 -0
- socialctl/management/inbox_readers.py +87 -0
- socialctl/management/meta_changes.py +197 -0
- socialctl/management/meta_cli.py +342 -0
- socialctl/management/meta_client.py +631 -0
- socialctl/management/meta_comment_inbox.py +565 -0
- socialctl/management/meta_comments.py +587 -0
- socialctl/management/meta_errors.py +59 -0
- socialctl/management/meta_pagination.py +52 -0
- socialctl/management/meta_schema.py +128 -0
- socialctl/management/meta_webhooks.py +147 -0
- socialctl/management/metadata_parts.py +144 -0
- socialctl/management/owned_assets.py +85 -0
- socialctl/management/owned_changes.py +470 -0
- socialctl/management/owned_cli.py +128 -0
- socialctl/management/owned_reorder.py +62 -0
- socialctl/management/owned_schema.py +368 -0
- socialctl/management/reply_context.py +24 -0
- socialctl/management/reply_evidence.py +80 -0
- socialctl/management/resource_changes.py +407 -0
- socialctl/management/resource_cli.py +136 -0
- socialctl/management/snippet_readback.py +26 -0
- socialctl/management/stories.py +713 -0
- socialctl/management/stories_cli.py +172 -0
- socialctl/management/youtube.py +323 -0
- socialctl/management/youtube_community.py +326 -0
- socialctl/management/youtube_metadata.py +122 -0
- socialctl/management/youtube_owned.py +259 -0
- socialctl/management/youtube_resources.py +305 -0
- socialctl/media.py +269 -0
- socialctl/media_cli.py +183 -0
- socialctl/media_ingress.py +211 -0
- socialctl/media_preflight.py +114 -0
- socialctl/media_registry.py +301 -0
- socialctl/media_requirements.py +52 -0
- socialctl/metricas/__init__.py +6 -0
- socialctl/metricas/almacen.py +133 -0
- socialctl/metricas/analytics_changes.py +295 -0
- socialctl/metricas/analytics_cli.py +191 -0
- socialctl/metricas/analytics_client.py +304 -0
- socialctl/metricas/analytics_ingest.py +260 -0
- socialctl/metricas/analytics_schema.py +174 -0
- socialctl/metricas/base.py +147 -0
- socialctl/metricas/facebook.py +418 -0
- socialctl/metricas/insights.py +227 -0
- socialctl/metricas/instagram.py +133 -0
- socialctl/metricas/modelos.py +87 -0
- socialctl/metricas/piezas.py +134 -0
- socialctl/metricas/reach.py +94 -0
- socialctl/metricas/reporting_client.py +112 -0
- socialctl/metricas/resumen.py +251 -0
- socialctl/metricas/slugs.py +89 -0
- socialctl/metricas/tiktok.py +185 -0
- socialctl/metricas/youtube.py +403 -0
- socialctl/migration_cli.py +60 -0
- socialctl/migration_files.py +68 -0
- socialctl/models.py +150 -0
- socialctl/native_schedule/__init__.py +13 -0
- socialctl/native_schedule/api_delivery.py +15 -0
- socialctl/native_schedule/api_reconcile.py +40 -0
- socialctl/native_schedule/approval.py +160 -0
- socialctl/native_schedule/cadence.py +73 -0
- socialctl/native_schedule/cli.py +510 -0
- socialctl/native_schedule/dispatcher.py +120 -0
- socialctl/native_schedule/facebook.py +112 -0
- socialctl/native_schedule/facebook_reels.py +140 -0
- socialctl/native_schedule/first_comment.py +181 -0
- socialctl/native_schedule/handoff.py +193 -0
- socialctl/native_schedule/instagram_delivery.py +224 -0
- socialctl/native_schedule/instagram_runtime.py +66 -0
- socialctl/native_schedule/inventory.py +70 -0
- socialctl/native_schedule/migration.py +310 -0
- socialctl/native_schedule/models.py +103 -0
- socialctl/native_schedule/reconcile.py +83 -0
- socialctl/native_schedule/remote_data.py +177 -0
- socialctl/native_schedule/report.py +151 -0
- socialctl/native_schedule/store.py +560 -0
- socialctl/native_schedule/verification.py +32 -0
- socialctl/native_schedule/youtube.py +339 -0
- socialctl/operation_output.py +87 -0
- socialctl/plantillas/accounts.yml +33 -0
- socialctl/plantillas/brand.md +95 -0
- socialctl/plantillas/estrategia.md +64 -0
- socialctl/postfile.py +240 -0
- socialctl/preflight.py +73 -0
- socialctl/publication_policy.py +34 -0
- socialctl/publication_status.py +75 -0
- socialctl/publication_steps.py +398 -0
- socialctl/publish_output.py +96 -0
- socialctl/publisher.py +445 -0
- socialctl/queue_migration.py +302 -0
- socialctl/read_budget.py +224 -0
- socialctl/read_reports.py +91 -0
- socialctl/rutas.py +103 -0
- socialctl/saas/__init__.py +1 -0
- socialctl/saas/access.py +75 -0
- socialctl/saas/auth.py +213 -0
- socialctl/saas/backup.py +124 -0
- socialctl/saas/billing.py +327 -0
- socialctl/saas/billing_enrollment.py +1237 -0
- socialctl/saas/billing_enrollment_schema.py +519 -0
- socialctl/saas/billing_inventory.py +775 -0
- socialctl/saas/billing_reconcile.py +127 -0
- socialctl/saas/billing_routes.py +27 -0
- socialctl/saas/budget.py +66 -0
- socialctl/saas/connections.py +374 -0
- socialctl/saas/consent.py +52 -0
- socialctl/saas/lifecycle.py +341 -0
- socialctl/saas/mail.py +89 -0
- socialctl/saas/maintenance.py +68 -0
- socialctl/saas/meta_user.py +298 -0
- socialctl/saas/meta_webhooks.py +358 -0
- socialctl/saas/migration.py +107 -0
- socialctl/saas/models.py +8 -0
- socialctl/saas/operations.py +102 -0
- socialctl/saas/pages.py +126 -0
- socialctl/saas/pairing.py +158 -0
- socialctl/saas/permissions.py +57 -0
- socialctl/saas/reminders.py +128 -0
- socialctl/saas/renewal.py +74 -0
- socialctl/saas/restore_guard.py +229 -0
- socialctl/saas/revocation.py +97 -0
- socialctl/saas/rotation.py +114 -0
- socialctl/saas/routes.py +221 -0
- socialctl/saas/settings.py +34 -0
- socialctl/saas/store.py +106 -0
- socialctl/saas/temporary_media.py +245 -0
- socialctl/saas/webhooks.py +76 -0
- socialctl/schedule_remote.py +262 -0
- socialctl/schedule_sqlite.py +44 -0
- socialctl/scheduler.py +581 -0
- socialctl/scheduler_launchd.py +319 -0
- socialctl/scheduler_systemd.py +167 -0
- socialctl/source_links.py +119 -0
- socialctl/ui/__init__.py +1 -0
- socialctl/ui/assets/agent-manual.md +850 -0
- socialctl/ui/assets/agents.md +27 -0
- socialctl/ui/assets/components.css +20 -0
- socialctl/ui/assets/connection-layout.css +53 -0
- socialctl/ui/assets/otp.template.html +10 -0
- socialctl/ui/assets/tokens.css +1 -0
- socialctl/ui/assets/trial.template.html +11 -0
- socialctl/ui/assets/web-layout.css +38 -0
- socialctl/ui/email/otp.html +10 -0
- socialctl/ui/email/trial.html +11 -0
- socialctl/upload_consent.py +13 -0
- socialctl/workspace.py +8 -0
- socialctl/youtube_data.py +471 -0
- 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
|
+
)
|