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,319 @@
|
|
|
1
|
+
"""Workspace-owned macOS queue agents; unknown supervisor evidence fails closed."""
|
|
2
|
+
import hashlib
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import plistlib
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
import stat
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import tempfile
|
|
12
|
+
import time
|
|
13
|
+
|
|
14
|
+
from socialctl.scheduler_systemd import SupervisorError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def unit_name(brand: str, root: Path) -> str:
|
|
18
|
+
identity = str(Path(root).resolve()) + '\0' + brand
|
|
19
|
+
return 'com.socialcli.' + hashlib.sha256(identity.encode()).hexdigest()[:20] + '.run-due'
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _plan(brand, root):
|
|
23
|
+
from socialctl.rutas import validar_componente_de_ruta
|
|
24
|
+
root = Path(root).resolve()
|
|
25
|
+
selected = validar_componente_de_ruta(root, brand, SupervisorError, 'brand name')
|
|
26
|
+
if not root.is_dir() or not (selected / 'brand.md').is_file():
|
|
27
|
+
raise SupervisorError('Select an existing brand and its explicit workspace root.')
|
|
28
|
+
# Preserve a venv interpreter symlink, rather than resolving to base Python.
|
|
29
|
+
argv = [str(Path(sys.executable).absolute()), '-m', 'socialctl', 'run-due',
|
|
30
|
+
'--brand', brand, '--root', str(root)]
|
|
31
|
+
if any(any(ord(c) < 32 or ord(c) == 127 for c in arg) for arg in argv):
|
|
32
|
+
raise SupervisorError('Scheduler paths and names must not contain control characters.')
|
|
33
|
+
name = unit_name(brand, root)
|
|
34
|
+
directory = Path.home() / 'Library/LaunchAgents'
|
|
35
|
+
log_dir = selected / '.socialctl'
|
|
36
|
+
payload = {'Label': name, 'ProgramArguments': argv, 'WorkingDirectory': str(root),
|
|
37
|
+
'StartInterval': 60, 'RunAtLoad': True, 'Umask': 0o077,
|
|
38
|
+
'StandardOutPath': str(log_dir / 'run-due.log'),
|
|
39
|
+
'StandardErrorPath': str(log_dir / 'run-due.error.log'), 'ProcessType': 'Background'}
|
|
40
|
+
return directory / (name + '.plist'), payload, plistlib.dumps(payload, sort_keys=False)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _run(args, *, check=True):
|
|
44
|
+
executable = shutil.which('launchctl')
|
|
45
|
+
if not executable:
|
|
46
|
+
raise SupervisorError('No launchd supervisor is available. Nothing was verified or changed.')
|
|
47
|
+
try:
|
|
48
|
+
result = subprocess.run([executable, *args], capture_output=True, text=True,
|
|
49
|
+
timeout=15, check=False)
|
|
50
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
51
|
+
raise SupervisorError('Could not contact the macOS user supervisor; inspect launchctl before retrying.') from None
|
|
52
|
+
if check and result.returncode:
|
|
53
|
+
raise SupervisorError('The macOS user supervisor rejected the operation; inspect launchctl before retrying.')
|
|
54
|
+
return result
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _domain():
|
|
58
|
+
domain = f'gui/{os.getuid()}'
|
|
59
|
+
_run(['print', domain])
|
|
60
|
+
return domain
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _job(domain, name):
|
|
64
|
+
result = _run(['print', domain + '/' + name], check=False)
|
|
65
|
+
if result.returncode == 0:
|
|
66
|
+
return result.stdout
|
|
67
|
+
# A successful domain probe plus the specific missing-service diagnostic is
|
|
68
|
+
# necessary: arbitrary command failures are not evidence of absence.
|
|
69
|
+
if (result.returncode == 113 and
|
|
70
|
+
f'Could not find service "{name}" in domain' in result.stderr):
|
|
71
|
+
return None
|
|
72
|
+
raise SupervisorError('Could not verify whether the macOS scheduler is registered; nothing was replaced or removed.')
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _safe_parents(path):
|
|
76
|
+
if not path.is_absolute() or any(parent.is_symlink() for parent in path.parents):
|
|
77
|
+
raise SupervisorError('Refusing a symlinked supervisor configuration directory.')
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _configuration(path, expected):
|
|
81
|
+
_safe_parents(path)
|
|
82
|
+
if not path.exists() and not path.is_symlink():
|
|
83
|
+
return False
|
|
84
|
+
# Read without following the final link and validate the opened inode.
|
|
85
|
+
if path.is_symlink() or not path.is_file():
|
|
86
|
+
raise SupervisorError('Existing scheduler configuration is unsafe; review it manually.')
|
|
87
|
+
with os.fdopen(os.open(path, os.O_RDONLY | os.O_NOFOLLOW), 'rb') as stream:
|
|
88
|
+
info = os.fstat(stream.fileno())
|
|
89
|
+
if (not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid()
|
|
90
|
+
or stat.S_IMODE(info.st_mode) != 0o600 or stream.read() != expected):
|
|
91
|
+
raise SupervisorError('Existing scheduler configuration differs or is not private and owned; nothing was replaced or removed.')
|
|
92
|
+
return True
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _mkdir(directory):
|
|
96
|
+
_safe_parents(directory / '.scheduler-check')
|
|
97
|
+
missing = []
|
|
98
|
+
while not directory.exists():
|
|
99
|
+
missing.append(directory)
|
|
100
|
+
directory = directory.parent
|
|
101
|
+
for directory in reversed(missing):
|
|
102
|
+
directory.mkdir(mode=0o700)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _save_new(path, content):
|
|
106
|
+
# Link a complete private file into place without ever replacing a target,
|
|
107
|
+
# including one created between the preflight check and this operation.
|
|
108
|
+
with tempfile.NamedTemporaryFile(dir=path.parent, prefix='.' + path.name, delete=False) as stream:
|
|
109
|
+
temporary = Path(stream.name)
|
|
110
|
+
try:
|
|
111
|
+
os.fchmod(stream.fileno(), 0o600)
|
|
112
|
+
stream.write(content)
|
|
113
|
+
stream.flush()
|
|
114
|
+
os.fsync(stream.fileno())
|
|
115
|
+
except BaseException:
|
|
116
|
+
temporary.unlink(missing_ok=True)
|
|
117
|
+
raise
|
|
118
|
+
try:
|
|
119
|
+
os.link(temporary, path, follow_symlinks=False)
|
|
120
|
+
finally:
|
|
121
|
+
temporary.unlink(missing_ok=True)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _fields(output, target):
|
|
125
|
+
"""Read only unambiguous direct job fields, never nested environment fields.
|
|
126
|
+
|
|
127
|
+
Apple explicitly documents `print` as diagnostic output, not an API. A
|
|
128
|
+
changed/missing format therefore yields unverified evidence, not success.
|
|
129
|
+
"""
|
|
130
|
+
lines = output.strip().splitlines()
|
|
131
|
+
if not lines or lines[0].strip() != target + ' = {' or lines[-1].strip() != '}':
|
|
132
|
+
return {}, []
|
|
133
|
+
fields, argv, depth, arguments = {}, [], 1, False
|
|
134
|
+
for line in lines[1:-1]:
|
|
135
|
+
text = line.strip()
|
|
136
|
+
if text == '}':
|
|
137
|
+
depth -= 1
|
|
138
|
+
if depth < 1:
|
|
139
|
+
return {}, []
|
|
140
|
+
if depth == 1:
|
|
141
|
+
arguments = False
|
|
142
|
+
elif text.endswith(' = {'):
|
|
143
|
+
if depth == 1 and text == 'arguments = {':
|
|
144
|
+
if 'arguments' in fields:
|
|
145
|
+
return {}, []
|
|
146
|
+
fields['arguments'] = True
|
|
147
|
+
arguments = True
|
|
148
|
+
depth += 1
|
|
149
|
+
elif depth == 2 and arguments:
|
|
150
|
+
if not text:
|
|
151
|
+
return {}, []
|
|
152
|
+
argv.append(text)
|
|
153
|
+
elif depth == 1 and ' = ' in text:
|
|
154
|
+
key, value = text.split(' = ', 1)
|
|
155
|
+
if key in fields:
|
|
156
|
+
return {}, []
|
|
157
|
+
fields[key] = value
|
|
158
|
+
return (fields, argv) if depth == 1 else ({}, [])
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _loaded_matches(output, domain, path, payload):
|
|
162
|
+
fields, argv = _fields(output, domain + '/' + payload['Label'])
|
|
163
|
+
expected = {'path': str(path), 'type': 'LaunchAgent',
|
|
164
|
+
'program': payload['ProgramArguments'][0],
|
|
165
|
+
'working directory': payload['WorkingDirectory'],
|
|
166
|
+
'stdout path': payload['StandardOutPath'], 'stderr path': payload['StandardErrorPath'],
|
|
167
|
+
'run interval': '60 seconds'}
|
|
168
|
+
conflicts = (any(key in fields and fields[key] != value for key, value in expected.items())
|
|
169
|
+
or ('arguments' in fields and argv != payload['ProgramArguments']))
|
|
170
|
+
return (all(fields.get(key) == value for key, value in expected.items())
|
|
171
|
+
and argv == payload['ProgramArguments']
|
|
172
|
+
and fields.get('state') in {'running', 'not running'}), fields, conflicts
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _enabled(domain, name):
|
|
176
|
+
result = _run(['print-disabled', domain], check=False)
|
|
177
|
+
if result.returncode:
|
|
178
|
+
return None
|
|
179
|
+
lines = result.stdout.strip().splitlines()
|
|
180
|
+
if not lines or lines[0].strip() != 'disabled services = {' or lines[-1].strip() != '}':
|
|
181
|
+
return None
|
|
182
|
+
# Current macOS prints enabled/disabled; retain the boolean spelling used
|
|
183
|
+
# by earlier diagnostic fixtures without treating unknowns as on.
|
|
184
|
+
states = r'(true|false|enabled|disabled)'
|
|
185
|
+
entries = []
|
|
186
|
+
for line in lines[1:-1]:
|
|
187
|
+
if not re.fullmatch(r'\s*"[^"\n]+"\s*=>\s*' + states + r'\s*', line):
|
|
188
|
+
return None
|
|
189
|
+
match = re.fullmatch(r'\s*"' + re.escape(name) + r'"\s*=>\s*' + states + r'\s*', line)
|
|
190
|
+
if match:
|
|
191
|
+
entries.append(match.group(1))
|
|
192
|
+
elif '"' + name + '"' in line:
|
|
193
|
+
return None
|
|
194
|
+
return entries[0] in {'false', 'enabled'} if len(entries) == 1 else None
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _legacy_present(brand, path, domain):
|
|
198
|
+
name = f'com.socialctl.{brand.lower()}.run-due'
|
|
199
|
+
legacy_path = path.parent / (name + '.plist')
|
|
200
|
+
return legacy_path.exists() or legacy_path.is_symlink() or _job(domain, name) is not None
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def inspect(brand: str, root: Path) -> dict:
|
|
204
|
+
report = {'kind': 'launchd_user', 'unit': unit_name(brand, root),
|
|
205
|
+
'configuration_matches': False, 'registered': False, 'enabled': None,
|
|
206
|
+
'active': False, 'survives_logout': False, 'service_failed': None,
|
|
207
|
+
'last_exit_code': None, 'last_terminating_signal': None,
|
|
208
|
+
'reason': 'configuration_missing'}
|
|
209
|
+
try:
|
|
210
|
+
path, payload, content = _plan(brand, root)
|
|
211
|
+
if not _configuration(path, content):
|
|
212
|
+
return report
|
|
213
|
+
domain = _domain()
|
|
214
|
+
if _legacy_present(brand, path, domain):
|
|
215
|
+
report['reason'] = 'legacy_job_present'
|
|
216
|
+
return report
|
|
217
|
+
observed = _job(domain, payload['Label'])
|
|
218
|
+
report['registered'] = observed is not None
|
|
219
|
+
if observed is None:
|
|
220
|
+
report['reason'] = 'job_unregistered'
|
|
221
|
+
return report
|
|
222
|
+
matches, fields, conflicts = _loaded_matches(observed, domain, path, payload)
|
|
223
|
+
report['configuration_matches'] = matches
|
|
224
|
+
code, signal = fields.get('last exit code', ''), fields.get('last terminating signal', '')
|
|
225
|
+
if re.fullmatch(r'-?\d+', code):
|
|
226
|
+
report['last_exit_code'] = int(code)
|
|
227
|
+
report['service_failed'] = int(code) != 0
|
|
228
|
+
if re.fullmatch(r'\d+', signal):
|
|
229
|
+
report['last_terminating_signal'] = int(signal)
|
|
230
|
+
if int(signal) != 0:
|
|
231
|
+
report['service_failed'] = True
|
|
232
|
+
elif 'last terminating signal' in fields and report['service_failed'] is not True:
|
|
233
|
+
# Unknown signal text must not inherit success from an older exit.
|
|
234
|
+
report['service_failed'] = None
|
|
235
|
+
if not matches:
|
|
236
|
+
report['reason'] = 'loaded_configuration_mismatch' if conflicts else 'loaded_configuration_unverified'
|
|
237
|
+
return report
|
|
238
|
+
report['active'] = True # Registered interval task; a PID need not run between ticks.
|
|
239
|
+
report['enabled'] = _enabled(domain, payload['Label'])
|
|
240
|
+
report['reason'] = ('last_execution_failed' if report['service_failed'] is True
|
|
241
|
+
else 'verified_now' if report['enabled'] is True and report['service_failed'] is False
|
|
242
|
+
else 'execution_or_enabled_state_unverified')
|
|
243
|
+
except SupervisorError:
|
|
244
|
+
report['reason'] = 'configuration_or_supervisor_unverified'
|
|
245
|
+
except (OSError, ValueError):
|
|
246
|
+
report['reason'] = 'configuration_unreadable'
|
|
247
|
+
return report
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def install(brand: str, root: Path) -> dict:
|
|
251
|
+
path, payload, content = _plan(brand, root)
|
|
252
|
+
saved = _configuration(path, content)
|
|
253
|
+
domain = _domain()
|
|
254
|
+
if _legacy_present(brand, path, domain):
|
|
255
|
+
raise SupervisorError('A legacy brand-only scheduler exists. Review and explicitly migrate it first; nothing was replaced. See docs/local-scheduler.md.')
|
|
256
|
+
observed = _job(domain, payload['Label'])
|
|
257
|
+
if observed is not None and (not saved or not _loaded_matches(observed, domain, path, payload)[0]):
|
|
258
|
+
raise SupervisorError('An existing launchd job differs or is unverified; nothing was replaced.')
|
|
259
|
+
# launchd opens the log targets. Do not direct it through caller-supplied links.
|
|
260
|
+
log_dir = Path(payload['StandardOutPath']).parent
|
|
261
|
+
_safe_parents(log_dir / '.scheduler-check')
|
|
262
|
+
for key in ('StandardOutPath', 'StandardErrorPath'):
|
|
263
|
+
log = Path(payload[key])
|
|
264
|
+
if log.is_symlink() or (log.exists() and (not log.is_file() or log.stat().st_uid != os.getuid())):
|
|
265
|
+
raise SupervisorError('The scheduler log destination is unsafe; nothing was installed.')
|
|
266
|
+
_mkdir(path.parent)
|
|
267
|
+
_mkdir(log_dir)
|
|
268
|
+
if not saved:
|
|
269
|
+
_save_new(path, content)
|
|
270
|
+
if not _configuration(path, content):
|
|
271
|
+
raise SupervisorError('The scheduler configuration disappeared during inspection; nothing was loaded.')
|
|
272
|
+
_run(['enable', domain + '/' + payload['Label']])
|
|
273
|
+
if observed is None:
|
|
274
|
+
_run(['bootstrap', domain, str(path)])
|
|
275
|
+
# A new bootstrap can return before a complete diagnostic snapshot exists.
|
|
276
|
+
# Obtain fresh positive proof for a short bounded period; never replay a
|
|
277
|
+
# mutation or reinterpret missing evidence as a successful installation.
|
|
278
|
+
attempts = 5 if observed is None else 1
|
|
279
|
+
deadline = time.monotonic() + 1.0
|
|
280
|
+
retryable = {'job_unregistered', 'loaded_configuration_unverified',
|
|
281
|
+
'configuration_or_supervisor_unverified', 'execution_or_enabled_state_unverified'}
|
|
282
|
+
for attempt in range(attempts):
|
|
283
|
+
if attempt and time.monotonic() >= deadline:
|
|
284
|
+
break
|
|
285
|
+
if not _configuration(path, content):
|
|
286
|
+
break
|
|
287
|
+
report = inspect(brand, root)
|
|
288
|
+
if not _configuration(path, content):
|
|
289
|
+
break
|
|
290
|
+
if report['service_failed'] is True or report['enabled'] is False:
|
|
291
|
+
break
|
|
292
|
+
if all(report[key] is True for key in ('configuration_matches', 'registered', 'enabled', 'active')):
|
|
293
|
+
return report
|
|
294
|
+
if (report['reason'] not in retryable or attempt + 1 == attempts
|
|
295
|
+
or time.monotonic() + 0.2 >= deadline):
|
|
296
|
+
break
|
|
297
|
+
time.sleep(0.2)
|
|
298
|
+
raise SupervisorError('The scheduler configuration was saved, but a working enabled launchd job was not verified; run schedule-doctor.')
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def uninstall(brand: str, root: Path) -> bool:
|
|
302
|
+
path, payload, content = _plan(brand, root)
|
|
303
|
+
saved = _configuration(path, content)
|
|
304
|
+
domain = _domain()
|
|
305
|
+
observed = _job(domain, payload['Label'])
|
|
306
|
+
if observed is None and not saved:
|
|
307
|
+
return False
|
|
308
|
+
if observed is not None:
|
|
309
|
+
if not saved or not _loaded_matches(observed, domain, path, payload)[0]:
|
|
310
|
+
raise SupervisorError('The registered scheduler differs or is unverified; nothing was removed.')
|
|
311
|
+
if not _configuration(path, content):
|
|
312
|
+
raise SupervisorError('The scheduler ownership file disappeared during inspection; nothing was removed.')
|
|
313
|
+
_run(['bootout', domain + '/' + payload['Label']])
|
|
314
|
+
if _job(domain, payload['Label']) is not None:
|
|
315
|
+
raise SupervisorError('The scheduler is still registered; its configuration was preserved.')
|
|
316
|
+
# Recheck before unlinking; never delete an edited/foreign file after bootout.
|
|
317
|
+
if _configuration(path, content):
|
|
318
|
+
path.unlink()
|
|
319
|
+
return True
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Customer-owned Linux user timers. No shell, media transfer or policy changes."""
|
|
2
|
+
import hashlib
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SupervisorError(ValueError):
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def unit_name(brand: str, root: Path) -> str:
|
|
16
|
+
identity = str(Path(root).resolve()) + '\0' + brand
|
|
17
|
+
return 'socialcli-' + hashlib.sha256(identity.encode()).hexdigest()[:20] + '-run-due'
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _quote(value, *, command=False):
|
|
21
|
+
value = str(value)
|
|
22
|
+
if not value or any(ord(c) < 32 or ord(c) == 127 for c in value):
|
|
23
|
+
raise SupervisorError('Scheduler paths and names must not contain control characters')
|
|
24
|
+
value = value.replace('\\', '\\\\').replace('"', '\\"').replace('%', '%%')
|
|
25
|
+
if command:
|
|
26
|
+
value = value.replace('$', '$$')
|
|
27
|
+
return '"' + value + '"'
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def render(brand: str, root: Path, *, executable=None) -> tuple[str, str]:
|
|
31
|
+
root = Path(root).resolve()
|
|
32
|
+
executable = Path(executable or sys.executable).absolute()
|
|
33
|
+
name = unit_name(brand,root)
|
|
34
|
+
argv = [str(executable), '-m', 'socialctl', 'run-due', '--brand', brand, '--root', str(root)]
|
|
35
|
+
command = ' '.join(_quote(arg,command=True) for arg in argv)
|
|
36
|
+
# WorkingDirectory consumes the raw value, unlike ExecStart's word parser.
|
|
37
|
+
# Reject suffixes the unit-file parser would strip or join to the next line.
|
|
38
|
+
directory = str(root)
|
|
39
|
+
if directory != directory.rstrip() or directory.endswith('\\'):
|
|
40
|
+
raise SupervisorError('Scheduler workspace must not end with whitespace or a backslash')
|
|
41
|
+
directory = directory.replace('%','%%')
|
|
42
|
+
service = ('# Managed by SocialCli\n[Unit]\nDescription=SocialCli local queue executor\n\n'
|
|
43
|
+
'[Service]\nType=oneshot\nWorkingDirectory=' + directory + '\nExecStart=' + command +
|
|
44
|
+
'\nTimeoutStartSec=0\nUMask=0077\n')
|
|
45
|
+
timer = ('# Managed by SocialCli\n[Unit]\nDescription=SocialCli local queue timer\n\n'
|
|
46
|
+
'[Timer]\nOnActiveSec=5s\nOnUnitInactiveSec=60s\nAccuracySec=1s\nUnit=' + name +
|
|
47
|
+
'.service\n\n[Install]\nWantedBy=timers.target\n')
|
|
48
|
+
return service,timer
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _directory():
|
|
52
|
+
configured = os.environ.get('XDG_CONFIG_HOME','').strip()
|
|
53
|
+
base = Path(configured) if configured else Path.home()/'.config'
|
|
54
|
+
if not base.is_absolute():
|
|
55
|
+
raise SupervisorError('XDG_CONFIG_HOME must be an absolute directory')
|
|
56
|
+
return base/'systemd'/'user'
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _run(tool, args, *, check=True):
|
|
60
|
+
executable = shutil.which(tool)
|
|
61
|
+
if not executable:
|
|
62
|
+
raise SupervisorError(f'{tool} is unavailable; no working Linux user supervisor was verified')
|
|
63
|
+
try:
|
|
64
|
+
result = subprocess.run([executable,*args],capture_output=True,text=True,timeout=15,check=False)
|
|
65
|
+
except (OSError,subprocess.TimeoutExpired) as exc:
|
|
66
|
+
raise SupervisorError('Could not contact the Linux user supervisor; inspect the user service manager') from exc
|
|
67
|
+
if check and result.returncode:
|
|
68
|
+
raise SupervisorError('Linux user supervisor operation failed; inspect systemctl --user status and journalctl --user')
|
|
69
|
+
return result
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _atomic(path, text):
|
|
73
|
+
if path.is_symlink():
|
|
74
|
+
raise SupervisorError('Refusing to replace a symlinked scheduler unit')
|
|
75
|
+
with tempfile.NamedTemporaryFile(mode='w',dir=path.parent,prefix='.'+path.name,delete=False) as stream:
|
|
76
|
+
temporary = Path(stream.name)
|
|
77
|
+
try:
|
|
78
|
+
stream.write(text)
|
|
79
|
+
stream.flush()
|
|
80
|
+
os.fsync(stream.fileno())
|
|
81
|
+
except BaseException:
|
|
82
|
+
temporary.unlink(missing_ok=True)
|
|
83
|
+
raise
|
|
84
|
+
try:
|
|
85
|
+
temporary.chmod(0o600)
|
|
86
|
+
os.replace(temporary,path)
|
|
87
|
+
finally:
|
|
88
|
+
temporary.unlink(missing_ok=True)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def inspect(brand: str, root: Path) -> dict:
|
|
92
|
+
name = unit_name(brand,root)
|
|
93
|
+
result = {'kind':'systemd_user','unit':name, 'configuration_matches':False,
|
|
94
|
+
'enabled':False,'active':False,'survives_logout':None,'service_failed':None}
|
|
95
|
+
try:
|
|
96
|
+
directory = _directory()
|
|
97
|
+
expected = render(brand,root)
|
|
98
|
+
paths = [directory/(name+suffix) for suffix in ('.service','.timer')]
|
|
99
|
+
if any(path.is_symlink() or not path.is_file() or path.read_text()!=text
|
|
100
|
+
for path,text in zip(paths,expected)):
|
|
101
|
+
return result
|
|
102
|
+
for path in paths:
|
|
103
|
+
observed = _run('systemctl',['--user','show','--property=NeedDaemonReload,DropInPaths,FragmentPath,ActiveState,Result',path.name])
|
|
104
|
+
properties = dict(line.split('=',1) for line in observed.stdout.splitlines() if '=' in line)
|
|
105
|
+
if (properties.get('NeedDaemonReload')!='no' or properties.get('DropInPaths')!=''
|
|
106
|
+
or properties.get('FragmentPath')!=str(path)):
|
|
107
|
+
return result
|
|
108
|
+
if path.suffix == '.service':
|
|
109
|
+
result['service_failed'] = (properties.get('Result') != 'success'
|
|
110
|
+
or properties.get('ActiveState') not in {'active','inactive','activating'})
|
|
111
|
+
result['configuration_matches']=True
|
|
112
|
+
enabled = _run('systemctl',['--user','is-enabled',name+'.timer'],check=False)
|
|
113
|
+
result['enabled']=enabled.returncode==0 and enabled.stdout.strip()=='enabled'
|
|
114
|
+
active = _run('systemctl',['--user','is-active',name+'.timer'],check=False)
|
|
115
|
+
result['active']=active.returncode==0 and active.stdout.strip()=='active'
|
|
116
|
+
linger = _run('loginctl',['show-user',str(os.getuid()),'--property=Linger','--value'],check=False)
|
|
117
|
+
if linger.returncode==0 and linger.stdout.strip() in {'yes','no'}:
|
|
118
|
+
result['survives_logout']=linger.stdout.strip()=='yes'
|
|
119
|
+
except (SupervisorError,OSError,ValueError):
|
|
120
|
+
# Fixed fields only; do not echo manager output or environment variables.
|
|
121
|
+
pass
|
|
122
|
+
return result
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def install(brand: str, root: Path) -> dict:
|
|
126
|
+
expected = render(brand,root) # Validate paths before effects.
|
|
127
|
+
_run('systemctl',['--user','show','--property=Version','--value'])
|
|
128
|
+
directory = _directory()
|
|
129
|
+
directory.mkdir(parents=True,exist_ok=True)
|
|
130
|
+
name = unit_name(brand,root)
|
|
131
|
+
for suffix,text in zip(('.service','.timer'),expected):
|
|
132
|
+
_atomic(directory/(name+suffix),text)
|
|
133
|
+
_run('systemctl',['--user','daemon-reload'])
|
|
134
|
+
_run('systemctl',['--user','enable','--now',name+'.timer'])
|
|
135
|
+
report = inspect(brand,root)
|
|
136
|
+
if not all(report[key] for key in ('configuration_matches','enabled','active')):
|
|
137
|
+
raise SupervisorError('Scheduler units were saved, but an enabled active timer was not verified; run schedule-doctor')
|
|
138
|
+
return report
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def uninstall(brand: str, root: Path) -> bool:
|
|
142
|
+
directory = _directory()
|
|
143
|
+
name = unit_name(brand,root)
|
|
144
|
+
paths = [directory/(name+suffix) for suffix in ('.timer','.service')]
|
|
145
|
+
has_files = any(path.exists() or path.is_symlink() for path in paths)
|
|
146
|
+
if any(path.is_symlink() for path in paths):
|
|
147
|
+
raise SupervisorError('Refusing to remove a symlinked scheduler unit')
|
|
148
|
+
# A manager can retain an active timer after its files disappear. Missing
|
|
149
|
+
# files alone must never be reported as successful absence.
|
|
150
|
+
_run('systemctl',['--user','show','--property=Version','--value'])
|
|
151
|
+
observed = _run('systemctl',['--user','show','--property=LoadState,ActiveState,UnitFileState',name+'.timer'],check=False)
|
|
152
|
+
properties = dict(line.split('=',1) for line in observed.stdout.splitlines() if '=' in line)
|
|
153
|
+
absent = (properties.get('LoadState') == 'not-found'
|
|
154
|
+
and properties.get('ActiveState') == 'inactive'
|
|
155
|
+
and properties.get('UnitFileState') in {'','disabled','not-found'})
|
|
156
|
+
if not has_files and absent:
|
|
157
|
+
return False
|
|
158
|
+
if properties.get('ActiveState') != 'inactive':
|
|
159
|
+
# Stop first: disable --now can fail before its stop phase when the
|
|
160
|
+
# on-disk configuration is damaged. Never stop the paired service.
|
|
161
|
+
_run('systemctl',['--user','stop',name+'.timer'])
|
|
162
|
+
_run('systemctl',['--user','disable','--now',name+'.timer'])
|
|
163
|
+
# An in-flight approved write may finish. Killing it would create uncertainty.
|
|
164
|
+
for path in paths:
|
|
165
|
+
path.unlink(missing_ok=True)
|
|
166
|
+
_run('systemctl',['--user','daemon-reload'])
|
|
167
|
+
return True
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Resolve explicit creator-owned source IDs before the existing approval flow."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from urllib.parse import parse_qs, urlsplit
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
|
|
11
|
+
from socialctl.models import Platform
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class StrictLoader(yaml.SafeLoader):
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _mapping(loader, node):
|
|
19
|
+
result = {}
|
|
20
|
+
for key_node, value_node in node.value:
|
|
21
|
+
key = loader.construct_object(key_node)
|
|
22
|
+
if not isinstance(key, str) or key in result:
|
|
23
|
+
raise ValueError('source-videos.yml contains duplicate/non-string keys')
|
|
24
|
+
result[key] = loader.construct_object(value_node)
|
|
25
|
+
return result
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
StrictLoader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _mapping)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _entry(brand, slug):
|
|
32
|
+
path = brand.raiz / 'source-videos.yml'
|
|
33
|
+
try:
|
|
34
|
+
with path.open('rb') as handle:
|
|
35
|
+
raw = handle.read(256 * 1024 + 1)
|
|
36
|
+
except FileNotFoundError:
|
|
37
|
+
return None
|
|
38
|
+
except OSError:
|
|
39
|
+
raise ValueError('cannot read source-videos.yml') from None
|
|
40
|
+
try:
|
|
41
|
+
if len(raw) > 256 * 1024:
|
|
42
|
+
raise ValueError('source-videos.yml exceeds 256 KiB')
|
|
43
|
+
data = yaml.load(raw.decode('utf-8'), Loader=StrictLoader)
|
|
44
|
+
if (not isinstance(data, dict) or set(data) != {'version', 'clips'}
|
|
45
|
+
or type(data['version']) is not int or data['version'] != 1
|
|
46
|
+
or not isinstance(data['clips'], dict)):
|
|
47
|
+
raise ValueError('source-videos.yml requires version: 1 and an explicit clips mapping')
|
|
48
|
+
for key, row in data['clips'].items():
|
|
49
|
+
if (not isinstance(key, str) or not key or '/' in key or '\\' in key
|
|
50
|
+
or key in {'.', '..'} or not isinstance(row, dict)
|
|
51
|
+
or not {'video_id'} <= set(row) <= {'video_id', 'series'}
|
|
52
|
+
or not isinstance(row['video_id'], str)
|
|
53
|
+
or not re.fullmatch(r'[A-Za-z0-9_-]{11}', row['video_id'])
|
|
54
|
+
or ('series' in row and (not isinstance(row['series'], str) or not row['series'].strip()))):
|
|
55
|
+
raise ValueError('invalid exact clip mapping: use a quoted 11-character video_id and optional series')
|
|
56
|
+
return data['clips'].get(slug)
|
|
57
|
+
except (yaml.YAMLError, UnicodeError):
|
|
58
|
+
raise ValueError('source-videos.yml is not valid strict YAML') from None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def resolve_source_link(brand, post_slug):
|
|
62
|
+
entry = _entry(brand, post_slug)
|
|
63
|
+
return f"https://www.youtube.com/watch?v={entry['video_id']}" if entry else None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _digest(slug, entry):
|
|
67
|
+
return hashlib.sha256(json.dumps({'version': 1, 'slug': slug, 'entry': entry},
|
|
68
|
+
sort_keys=True, separators=(',', ':')).encode()).hexdigest()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _video_links(text):
|
|
72
|
+
for raw in re.findall(r'(?:https?://|www\.)[^\s<>]+', text or ''):
|
|
73
|
+
url = urlsplit(raw if '://' in raw else f'https://{raw}')
|
|
74
|
+
host = (url.hostname or '').casefold()
|
|
75
|
+
parts = url.path.strip('/').split('/')
|
|
76
|
+
if host in {'youtube.com', 'www.youtube.com', 'm.youtube.com', 'music.youtube.com'}:
|
|
77
|
+
ids = parse_qs(url.query).get('v', []) if url.path == '/watch' else parts[1:2] if parts[0] in {'shorts', 'embed', 'live'} else []
|
|
78
|
+
for ident in ids:
|
|
79
|
+
yield ident
|
|
80
|
+
elif host in {'youtu.be', 'www.youtu.be'}:
|
|
81
|
+
yield parts[0]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def apply_source_mapping(brand, post, *, required=False):
|
|
85
|
+
entry = _entry(brand, post.slug)
|
|
86
|
+
if entry is None:
|
|
87
|
+
if required:
|
|
88
|
+
raise ValueError('source link unresolved: add this exact post slug to source-videos.yml')
|
|
89
|
+
return post
|
|
90
|
+
post = post.model_copy(deep=True)
|
|
91
|
+
ident = entry['video_id']
|
|
92
|
+
url = f'https://www.youtube.com/watch?v={ident}'
|
|
93
|
+
generated = f'Full video: {url}'
|
|
94
|
+
for platform, pp in post.platforms.items():
|
|
95
|
+
if (pp.source_video_id not in {None, ident} or pp.content_origin not in {None, 'youtube_long'}
|
|
96
|
+
or any(link != ident for field in (pp.body, pp.link, pp.first_comment)
|
|
97
|
+
for link in _video_links(field))):
|
|
98
|
+
raise ValueError(f'{platform.value}: explicit source/link conflicts with source-videos.yml')
|
|
99
|
+
pp.source_video_id, pp.content_origin = ident, 'youtube_long'
|
|
100
|
+
if platform is Platform.TIKTOK:
|
|
101
|
+
if pp.first_comment:
|
|
102
|
+
raise ValueError('TikTok source linking uses the caption; first comments are unsupported')
|
|
103
|
+
if ident not in set(_video_links(pp.body)):
|
|
104
|
+
pp.body = f'{pp.body}\n\n{generated}'
|
|
105
|
+
elif ident not in set(_video_links(pp.first_comment)):
|
|
106
|
+
pp.first_comment = f'{pp.first_comment}\n{generated}' if pp.first_comment else generated
|
|
107
|
+
post.source_mapping_digest = _digest(post.slug, entry)
|
|
108
|
+
return post
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def verify_source_binding(brand, slug, mapping_digest):
|
|
112
|
+
if mapping_digest is not None:
|
|
113
|
+
entry = _entry(brand, slug)
|
|
114
|
+
if entry is None or _digest(slug, entry) != mapping_digest:
|
|
115
|
+
raise ValueError('source mapping changed after preview; run --dry-run and approve again')
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def verify_source_mapping(brand, post):
|
|
119
|
+
verify_source_binding(brand, post.slug, post.source_mapping_digest)
|
socialctl/ui/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Shared SocialCli visual system. Assets are compiled by scripts/build_ui_assets.py."""
|