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
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Authenticate raw Stripe deliveries and durably queue before acknowledgement."""
|
|
2
|
+
import asyncio
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from fastapi import HTTPException, Request
|
|
8
|
+
from socialctl.saas.billing import API_VERSION
|
|
9
|
+
|
|
10
|
+
WEBHOOK_PATH='/api/saas/v1/billing/webhook'
|
|
11
|
+
MAX_BODY=1024*1024
|
|
12
|
+
EVENTS={'checkout.session.completed','checkout.session.expired','invoice.paid','invoice.payment_failed',
|
|
13
|
+
'invoice.payment_action_required','customer.subscription.created','customer.subscription.updated',
|
|
14
|
+
'customer.subscription.deleted','customer.subscription.paused','customer.subscription.resumed',
|
|
15
|
+
'charge.refunded','charge.dispute.created','charge.dispute.updated','charge.dispute.closed'}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def semantic_hash(event):
|
|
19
|
+
value={k:v for k,v in event.items() if k!='pending_webhooks'}
|
|
20
|
+
return hashlib.sha256(json.dumps(value,sort_keys=True,separators=(',',':')).encode()).hexdigest()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def verified_event(raw,signature,settings,now):
|
|
24
|
+
try:
|
|
25
|
+
parts=[part.split('=',1) for part in signature.split(',')]
|
|
26
|
+
timestamps=[v for k,v in parts if k=='t']
|
|
27
|
+
signatures=[v for k,v in parts if k=='v1']
|
|
28
|
+
if len(timestamps)!=1 or len(signatures)>10:raise ValueError()
|
|
29
|
+
timestamp=int(timestamps[0])
|
|
30
|
+
expected=hmac.new(settings.webhook_secret.encode(),timestamps[0].encode()+b'.'+raw,hashlib.sha256).hexdigest()
|
|
31
|
+
if abs(now-timestamp)>300 or not any(hmac.compare_digest(expected,s) for s in signatures):raise ValueError()
|
|
32
|
+
event=json.loads(raw)
|
|
33
|
+
if (not isinstance(event,dict) or event.get('object')!='event' or event.get('livemode') is not settings.expected_livemode
|
|
34
|
+
or event.get('api_version')!=API_VERSION or event.get('account') not in (None,settings.account_id)
|
|
35
|
+
or not re.fullmatch(r'evt_[A-Za-z0-9_]{1,200}',event.get('id',''))
|
|
36
|
+
or not isinstance(event.get('type'),str) or not isinstance(event.get('data',{}).get('object'),dict)):
|
|
37
|
+
raise ValueError()
|
|
38
|
+
return event
|
|
39
|
+
except (ValueError,TypeError,KeyError,AttributeError):
|
|
40
|
+
raise HTTPException(400,'Invalid Stripe event.') from None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def install_webhook_route(app,billing):
|
|
44
|
+
@app.post(WEBHOOK_PATH)
|
|
45
|
+
async def webhook(request:Request):
|
|
46
|
+
raw=bytearray()
|
|
47
|
+
try:
|
|
48
|
+
async with asyncio.timeout(10):
|
|
49
|
+
async for chunk in request.stream():
|
|
50
|
+
raw.extend(chunk)
|
|
51
|
+
if len(raw)>MAX_BODY:raise HTTPException(413,'Event is too large.')
|
|
52
|
+
except TimeoutError:
|
|
53
|
+
raise HTTPException(408,'Event timed out.') from None
|
|
54
|
+
event=verified_event(bytes(raw),request.headers.get('stripe-signature',''),billing.settings,billing.clock())
|
|
55
|
+
def persist():
|
|
56
|
+
digest=hashlib.sha256(raw).hexdigest()
|
|
57
|
+
identity=(billing.settings.account_id,billing.settings.mode,event['id'])
|
|
58
|
+
with billing.vault.transaction() as db:
|
|
59
|
+
previous=db.execute('SELECT body_hash,semantic_hash FROM saas_billing_events WHERE account_id=? AND mode=? AND event_id=?',identity).fetchone()
|
|
60
|
+
if previous:
|
|
61
|
+
if previous['body_hash']!=digest and previous['semantic_hash']!=semantic_hash(event):
|
|
62
|
+
raise HTTPException(400,'Conflicting Stripe event.')
|
|
63
|
+
return
|
|
64
|
+
customer=event['data']['object'].get('customer')
|
|
65
|
+
if not isinstance(customer,str) or not re.fullmatch(r'cus_[A-Za-z0-9_]{1,200}',customer):customer=None
|
|
66
|
+
state='pending' if event['type'] in EVENTS else 'ignored'
|
|
67
|
+
# Refunds/disputes remain available for explicit operator review.
|
|
68
|
+
if event['type'].startswith(('charge.refund','charge.dispute')):state='review'
|
|
69
|
+
product=(event['data']['object'].get('metadata') or {}).get('app')
|
|
70
|
+
if product is not None and product!='socialcli':state='ignored'
|
|
71
|
+
db.execute('INSERT INTO saas_billing_events(account_id,mode,event_id,body_hash,customer_id,type,state,received,semantic_hash) VALUES(?,?,?,?,?,?,?,?,?)',
|
|
72
|
+
(*identity,digest,customer,event['type'],state,billing.clock(),semantic_hash(event)))
|
|
73
|
+
if state!='ignored':
|
|
74
|
+
billing.vault.put(db,'saas-stripe-event',':'.join(identity),event,expires=billing.clock()+7*86400)
|
|
75
|
+
await asyncio.to_thread(persist)
|
|
76
|
+
return {'received':True}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""An explicit, bounded SSH client. Never forwards ordinary local commands."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
import shlex
|
|
7
|
+
import subprocess
|
|
8
|
+
from pathlib import Path, PurePosixPath
|
|
9
|
+
from typing import Literal
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
13
|
+
|
|
14
|
+
from socialctl.brands import _validar_nombre_de_marca
|
|
15
|
+
from socialctl.scheduler import ScheduleError, parse_scheduled_at
|
|
16
|
+
|
|
17
|
+
remote_app = typer.Typer(help="Manage authoritative queue via SSH; only existing server posts.")
|
|
18
|
+
from socialctl.workspace import default_root
|
|
19
|
+
|
|
20
|
+
DEFAULT_ROOT = default_root()
|
|
21
|
+
REMOTE_NOTICE = "Remote executor: local copy is stale and non-authoritative. Use schedule-remote status or schedule-remote health with --brand."
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SchedulePreview(BaseModel):
|
|
25
|
+
"""Only this validation-only envelope may cross SSH on a failed dry-run."""
|
|
26
|
+
model_config = ConfigDict(extra="forbid", strict=True)
|
|
27
|
+
protocol: Literal["socialctl.schedule-preview.v1"]
|
|
28
|
+
valid: bool
|
|
29
|
+
preview: str
|
|
30
|
+
digest: str = Field(pattern=r"^[a-f0-9]{64}$")
|
|
31
|
+
scheduled_at: str
|
|
32
|
+
|
|
33
|
+
@field_validator("scheduled_at")
|
|
34
|
+
@classmethod
|
|
35
|
+
def valid_instant(cls, value):
|
|
36
|
+
parse_scheduled_at(value)
|
|
37
|
+
return value
|
|
38
|
+
|
|
39
|
+
@field_validator("preview")
|
|
40
|
+
@classmethod
|
|
41
|
+
def no_credentials(cls, value):
|
|
42
|
+
if re.search(r"(?i)(?:[\"']?(?:access[_-]?token|refresh[_-]?token|client[_-]?secret|password|authorization|token)[\"']?\s*[:=]|\bbearer\s+\S+)", value):
|
|
43
|
+
raise ValueError("preview may contain a secret")
|
|
44
|
+
return value
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def is_remote(brand_root: Path) -> bool:
|
|
48
|
+
path = brand_root / ".socialctl/remote-executor.json"
|
|
49
|
+
return path.exists() or path.is_symlink()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class RemoteConfig(BaseModel):
|
|
53
|
+
model_config = ConfigDict(extra="forbid", strict=True)
|
|
54
|
+
host: str
|
|
55
|
+
user: str
|
|
56
|
+
port: int = Field(gt=0, le=65535)
|
|
57
|
+
root: str
|
|
58
|
+
executable: str
|
|
59
|
+
|
|
60
|
+
@field_validator("host")
|
|
61
|
+
@classmethod
|
|
62
|
+
def host_valid(cls, value):
|
|
63
|
+
if len(value) > 253 or any(not re.fullmatch(r"[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?", part) or len(part) > 63 for part in value.split(".")):
|
|
64
|
+
raise ValueError("invalid host")
|
|
65
|
+
return value
|
|
66
|
+
|
|
67
|
+
@field_validator("user")
|
|
68
|
+
@classmethod
|
|
69
|
+
def user_valid(cls, value):
|
|
70
|
+
if not re.fullmatch(r"[a-zA-Z_][a-zA-Z0-9_-]{0,63}", value):
|
|
71
|
+
raise ValueError("invalid user")
|
|
72
|
+
return value
|
|
73
|
+
|
|
74
|
+
@field_validator("root", "executable")
|
|
75
|
+
@classmethod
|
|
76
|
+
def path_valid(cls, value):
|
|
77
|
+
if not re.fullmatch(r"/[A-Za-z0-9_./-]+", value) or ".." in value.split("/") or str(PurePosixPath(value)) != value or value == "/":
|
|
78
|
+
raise ValueError("invalid absolute path")
|
|
79
|
+
return value
|
|
80
|
+
|
|
81
|
+
@field_validator("executable")
|
|
82
|
+
@classmethod
|
|
83
|
+
def executable_valid(cls, value):
|
|
84
|
+
if not value.endswith("/.venv/bin/socialctl"):
|
|
85
|
+
raise ValueError("invalid socialcli executable")
|
|
86
|
+
return value
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _component(value: str) -> str:
|
|
90
|
+
if not re.fullmatch(r"[\w][\w .-]*", value) or value in {".", ".."}:
|
|
91
|
+
raise ScheduleError("invalid remote identifier")
|
|
92
|
+
return value
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _entry(value: str) -> str:
|
|
96
|
+
parts = value.split("/")
|
|
97
|
+
if len(parts) != 2 or parts[1] not in {"facebook", "instagram"}:
|
|
98
|
+
raise ScheduleError("invalid remote identifier")
|
|
99
|
+
_component(parts[0])
|
|
100
|
+
return value
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _send(root: Path, brand: str, args: list[str]) -> dict | None:
|
|
104
|
+
try:
|
|
105
|
+
_component(brand)
|
|
106
|
+
brand_root = _validar_nombre_de_marca(root, brand)
|
|
107
|
+
config = RemoteConfig.model_validate_json((brand_root / ".socialctl/remote-executor.json").read_bytes())
|
|
108
|
+
except Exception as exc:
|
|
109
|
+
raise ScheduleError("remote configuration invalid or missing") from exc
|
|
110
|
+
preview_mode = args[0] == "schedule" and "--dry-run" in args
|
|
111
|
+
if preview_mode:
|
|
112
|
+
args = [*args, "--preview-json"]
|
|
113
|
+
# The server runs the same strict schedule validation as a local proposal.
|
|
114
|
+
# No compatibility context crosses SSH; only run-due can authorize old entries.
|
|
115
|
+
command = shlex.join([config.executable] + args + ["--brand", brand, "--root", config.root])
|
|
116
|
+
argv = ["ssh", "-T", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=yes",
|
|
117
|
+
"-o", "ConnectTimeout=15", "-p", str(config.port), "--", f"{config.user}@{config.host}", command]
|
|
118
|
+
try:
|
|
119
|
+
result = subprocess.run(argv, stdin=subprocess.DEVNULL, capture_output=True,
|
|
120
|
+
text=True, timeout=180, check=False)
|
|
121
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
122
|
+
raise ScheduleError("remote connection failed; check SSH and schedule-remote status before retrying") from exc
|
|
123
|
+
if args[0] == 'native-schedule' and args[-1] == '--json':
|
|
124
|
+
from socialctl.native_schedule.report import validate_remote
|
|
125
|
+
try:
|
|
126
|
+
return validate_remote(result.stdout, command=args[1], brand=brand, exit_code=result.returncode, request_args=args[1:-1])
|
|
127
|
+
except (ValueError, TypeError, KeyError, IndexError, RecursionError) as exc:
|
|
128
|
+
raise ScheduleError('Native remote result unverified; inspect authoritative state before any further mutation') from exc
|
|
129
|
+
if preview_mode and result.returncode in {0, 1}:
|
|
130
|
+
try:
|
|
131
|
+
response = SchedulePreview.model_validate_json(result.stdout)
|
|
132
|
+
if response.valid != (result.returncode == 0):
|
|
133
|
+
raise ValueError("inconsistent status")
|
|
134
|
+
except ValueError as exc:
|
|
135
|
+
raise ScheduleError("remote preview invalid or unsafe; unverified output was not displayed") from exc
|
|
136
|
+
typer.echo(response.preview)
|
|
137
|
+
if not response.valid:
|
|
138
|
+
raise ScheduleError("Nothing scheduled: fix the issues above.")
|
|
139
|
+
typer.echo(f"Approval digest: {response.digest}")
|
|
140
|
+
typer.echo(f"--dry-run: would schedule for {response.scheduled_at}.")
|
|
141
|
+
return
|
|
142
|
+
if result.returncode:
|
|
143
|
+
# Transport and remote exceptions can contain credentials/URLs.
|
|
144
|
+
raise ScheduleError("remote command failed; verify remote status before retrying")
|
|
145
|
+
if args[0] == "native-schedule":
|
|
146
|
+
from socialctl.native_schedule.approval import reject_secrets
|
|
147
|
+
try:
|
|
148
|
+
if len(result.stdout) > 1_000_000:
|
|
149
|
+
raise ValueError("oversized output")
|
|
150
|
+
reject_secrets(result.stdout)
|
|
151
|
+
except ValueError as exc:
|
|
152
|
+
raise ScheduleError("native remote response invalid or unsafe") from exc
|
|
153
|
+
typer.echo(result.stdout, nl=False)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _run(operation):
|
|
157
|
+
try:
|
|
158
|
+
operation()
|
|
159
|
+
except ScheduleError as exc:
|
|
160
|
+
typer.echo(str(exc))
|
|
161
|
+
raise typer.Exit(1) from exc
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@remote_app.command("status")
|
|
165
|
+
def status(brand: str = typer.Option(..., "--brand"), root: Path = typer.Option(DEFAULT_ROOT)):
|
|
166
|
+
_run(lambda: _send(root, brand, ["schedule-status"]))
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@remote_app.command("health")
|
|
170
|
+
def health(brand: str = typer.Option(..., "--brand"), root: Path = typer.Option(DEFAULT_ROOT)):
|
|
171
|
+
_run(lambda: _send(root, brand, ["schedule-health"]))
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@remote_app.command("cancel")
|
|
175
|
+
def cancel(entry_id: str, brand: str = typer.Option(..., "--brand"), root: Path = typer.Option(DEFAULT_ROOT)):
|
|
176
|
+
_run(lambda: _send(root, brand, ["schedule-cancel", _entry(entry_id)]))
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@remote_app.command("reschedule")
|
|
180
|
+
def reschedule(entry_id: str, at: str = typer.Option(..., "--at"),
|
|
181
|
+
brand: str = typer.Option(..., "--brand"), root: Path = typer.Option(DEFAULT_ROOT)):
|
|
182
|
+
def operation():
|
|
183
|
+
parse_scheduled_at(at)
|
|
184
|
+
_send(root, brand, ["schedule-reschedule", _entry(entry_id), "--at", at])
|
|
185
|
+
_run(operation)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@remote_app.command("schedule")
|
|
189
|
+
def schedule(slug: str, at: str = typer.Option(..., "--at"),
|
|
190
|
+
brand: str = typer.Option(..., "--brand"), root: Path = typer.Option(DEFAULT_ROOT),
|
|
191
|
+
dry_run: bool = typer.Option(False, "--dry-run"),
|
|
192
|
+
yes: bool = typer.Option(False, "--yes"),
|
|
193
|
+
approval_digest: str | None = typer.Option(None, "--approval-digest"),
|
|
194
|
+
only: list[str] = typer.Option(None, "--only")):
|
|
195
|
+
def operation():
|
|
196
|
+
parse_scheduled_at(at)
|
|
197
|
+
args = ["schedule", _component(slug), "--at", at]
|
|
198
|
+
if dry_run:
|
|
199
|
+
if yes or approval_digest:
|
|
200
|
+
raise ScheduleError("dry-run does not accept approval")
|
|
201
|
+
args += ["--dry-run"]
|
|
202
|
+
else:
|
|
203
|
+
if not yes or not approval_digest or not re.fullmatch(r"[a-f0-9]{64}", approval_digest):
|
|
204
|
+
raise ScheduleError("show --dry-run and approve its digest with --approval-digest and --yes")
|
|
205
|
+
args += ["--approval-digest", approval_digest, "--yes"]
|
|
206
|
+
for platform in only or []:
|
|
207
|
+
if platform not in {"facebook", "instagram"}:
|
|
208
|
+
raise ScheduleError("invalid remote platform")
|
|
209
|
+
args += ["--only", platform]
|
|
210
|
+
_send(root, brand, args)
|
|
211
|
+
_run(operation)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def send_native(root: Path, brand: str, args: list[str]):
|
|
215
|
+
"""Bounded native command grammar; paths resolve only at authoritative root."""
|
|
216
|
+
json_output = bool(args and args[-1] == "--json")
|
|
217
|
+
if json_output:
|
|
218
|
+
args = args[:-1]
|
|
219
|
+
operations={'prepare','approve','status','dispatch','reconcile','handoff','submit-marker','cancel','reschedule','migration-prepare','migration-apply','migration-recover','deliver'}
|
|
220
|
+
if not args or args[0] not in operations:
|
|
221
|
+
raise ScheduleError('invalid native remote command')
|
|
222
|
+
operation=args[0]
|
|
223
|
+
identifier=lambda value: bool(re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_.-]{0,127}',value))
|
|
224
|
+
relative_artifact=lambda value: len(value)<=512 and all(identifier(part) for part in value.split('/'))
|
|
225
|
+
digest=lambda value: bool(re.fullmatch(r'[a-f0-9]{64}',value))
|
|
226
|
+
valid=operation in {'status','deliver'} and len(args)==1
|
|
227
|
+
if operation=='deliver' and len(args)==3:
|
|
228
|
+
valid=args[1]=='--eligibility-dir' and relative_artifact(args[2])
|
|
229
|
+
elif operation=='migration-prepare':
|
|
230
|
+
valid=len(args)==4 and args[1]=='--manifest' and identifier(args[2]) and args[3]=='--dry-run'
|
|
231
|
+
elif operation=='migration-apply':
|
|
232
|
+
valid=len(args)==5 and args[1]=='--manifest' and identifier(args[2]) and args[3]=='--digest' and digest(args[4])
|
|
233
|
+
elif operation=='migration-recover':
|
|
234
|
+
valid=len(args)==3 and args[1]=='--digest' and digest(args[2])
|
|
235
|
+
elif operation not in {'status','deliver'} and len(args)>=2:
|
|
236
|
+
valid=identifier(args[1])
|
|
237
|
+
tail=args[2:]
|
|
238
|
+
if operation=='prepare':
|
|
239
|
+
valid=valid and (tail==['--dry-run'] or (len(tail)==3 and tail[:2]==['--persist','--approval-digest'] and digest(tail[2])))
|
|
240
|
+
elif operation=='approve':
|
|
241
|
+
valid=valid and len(tail)==2 and tail[0]=='--approval-digest' and digest(tail[1])
|
|
242
|
+
elif operation=='reconcile':
|
|
243
|
+
valid=valid and (tail in ([],['--yes']) or (len(tail)==2 and tail[0]=='--observation' and identifier(tail[1])))
|
|
244
|
+
elif operation in {'cancel','reschedule'}:
|
|
245
|
+
if operation=='reschedule':
|
|
246
|
+
try:
|
|
247
|
+
from socialctl.native_schedule.models import aware
|
|
248
|
+
stamp=aware(datetime.fromisoformat(tail[1].replace('Z','+00:00')))
|
|
249
|
+
valid=valid and tail[0]=='--publish-at'
|
|
250
|
+
tail=tail[2:]
|
|
251
|
+
except (ValueError,IndexError):
|
|
252
|
+
valid=False
|
|
253
|
+
valid=valid and (tail==['--dry-run'] or (len(tail)==2 and tail[0]=='--approval-digest' and digest(tail[1])))
|
|
254
|
+
elif operation=='dispatch':
|
|
255
|
+
if tail[:1]==['--yes']:
|
|
256
|
+
tail=tail[1:]
|
|
257
|
+
valid=valid and (tail==[] or (len(tail)==2 and tail[0]=='--eligibility' and relative_artifact(tail[1])))
|
|
258
|
+
else:
|
|
259
|
+
valid=valid and tail==[]
|
|
260
|
+
if not valid:
|
|
261
|
+
raise ScheduleError('invalid native remote arguments')
|
|
262
|
+
return _send(root,brand,['native-schedule',*args,*(['--json'] if json_output else [])])
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""SQLite ordered occurrences; DELETE+EXTRA exceeds FULL durability."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import sqlite3
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@contextmanager
|
|
11
|
+
def connect(path: Path):
|
|
12
|
+
existed = path.exists()
|
|
13
|
+
connection = sqlite3.connect(path, timeout=30)
|
|
14
|
+
try:
|
|
15
|
+
connection.execute("PRAGMA journal_mode=DELETE")
|
|
16
|
+
# EXTRA also syncs the directory after deleting the rollback journal.
|
|
17
|
+
connection.execute("PRAGMA synchronous=EXTRA")
|
|
18
|
+
version = connection.execute("PRAGMA user_version").fetchone()[0]
|
|
19
|
+
if version not in (0, 1) or (version == 0 and existed):
|
|
20
|
+
raise ValueError(f"unknown SQLite version: {version}")
|
|
21
|
+
if version == 0:
|
|
22
|
+
with connection:
|
|
23
|
+
connection.execute("CREATE TABLE entries (position INTEGER PRIMARY KEY, payload TEXT NOT NULL)")
|
|
24
|
+
connection.execute("PRAGMA user_version=1")
|
|
25
|
+
yield connection
|
|
26
|
+
finally:
|
|
27
|
+
connection.close()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def load(path: Path) -> list[dict]:
|
|
31
|
+
with connect(path) as connection:
|
|
32
|
+
return [json.loads(row[0]) for row in connection.execute("SELECT payload FROM entries ORDER BY position")]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def save(path: Path, entries: list[dict]) -> None:
|
|
36
|
+
with connect(path) as connection, connection:
|
|
37
|
+
connection.execute("DELETE FROM entries")
|
|
38
|
+
connection.executemany("INSERT INTO entries(position, payload) VALUES (?, ?)",
|
|
39
|
+
[(index, json.dumps(entry, ensure_ascii=False)) for index, entry in enumerate(entries)])
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def backup(path: Path, destination: Path) -> None:
|
|
43
|
+
with connect(path) as connection, sqlite3.connect(destination) as target:
|
|
44
|
+
connection.backup(target)
|