socialcli 0.3.2__tar.gz → 0.3.3__tar.gz
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.
- {socialcli-0.3.2 → socialcli-0.3.3}/LICENSE +3 -3
- {socialcli-0.3.2 → socialcli-0.3.3}/PKG-INFO +3 -3
- {socialcli-0.3.2 → socialcli-0.3.3}/README.md +2 -2
- {socialcli-0.3.2 → socialcli-0.3.3}/pyproject.toml +1 -1
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/agent_errors.py +14 -3
- socialcli-0.3.3/socialctl/agent_guide.py +25 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/brands.py +26 -7
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/cli.py +9 -7
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/connections/account_cli.py +3 -3
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/inbox.py +10 -2
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/inventory.py +47 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/inventory_cli.py +4 -2
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/batches.py +42 -7
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/changes.py +31 -9
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/cli.py +33 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/community_changes.py +5 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/owned_changes.py +56 -19
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/owned_cli.py +31 -33
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/snippet_readback.py +14 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/youtube.py +10 -4
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/youtube_community.py +8 -2
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/youtube_resources.py +1 -1
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/native_schedule/cadence.py +9 -1
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/native_schedule/cli.py +168 -3
- socialcli-0.3.3/socialctl/native_schedule/preparation.py +94 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/native_schedule/report.py +1 -1
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/operation_output.py +5 -4
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/schedule_remote.py +3 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/ui/assets/agent-manual.md +120 -8
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/ui/assets/agents.md +6 -3
- socialcli-0.3.3/socialctl/workspace.py +16 -0
- socialcli-0.3.2/socialctl/agent_guide.py +0 -23
- socialcli-0.3.2/socialctl/workspace.py +0 -8
- {socialcli-0.3.2 → socialcli-0.3.3}/.gitignore +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/LICENSE-MIT-LEGACY +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/__init__.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/__main__.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/adapters/__init__.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/adapters/base.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/adapters/errores.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/adapters/facebook.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/adapters/instagram.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/adapters/tiktok.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/adapters/youtube.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/approval_cli.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/auth.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/authflow.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/capabilities.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/capabilities.yml +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/connections/__init__.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/connections/cli.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/connections/client.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/connections/credential_cli.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/connections/credential_store.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/connections/file_store.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/connections/keychain.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/connections/meta_user.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/connections/rotation_cli.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/connections/saas_client.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/connections/temporary_media.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/delivery_options.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/engagement.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/engagement_cli.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/executor.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/formatter.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/hosted_media.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/identity.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/inbox_cli.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/inventory_sources.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/__init__.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/batch_cli.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/comments_cli.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/community_batch_cli.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/community_batches.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/community_cli.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/community_schema.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/facebook_groups.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/groups_cli.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/inbox_readers.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/meta_changes.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/meta_cli.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/meta_client.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/meta_comment_inbox.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/meta_comments.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/meta_errors.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/meta_pagination.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/meta_schema.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/meta_webhooks.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/metadata_parts.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/owned_assets.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/owned_reorder.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/owned_schema.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/reply_context.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/reply_evidence.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/resource_changes.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/resource_cli.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/stories.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/stories_cli.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/youtube_metadata.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/youtube_owned.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/media.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/media_cli.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/media_ingress.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/media_preflight.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/media_registry.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/media_requirements.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/metricas/__init__.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/metricas/almacen.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/metricas/analytics_changes.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/metricas/analytics_cli.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/metricas/analytics_client.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/metricas/analytics_ingest.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/metricas/analytics_schema.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/metricas/base.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/metricas/facebook.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/metricas/insights.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/metricas/instagram.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/metricas/modelos.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/metricas/piezas.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/metricas/reach.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/metricas/reporting_client.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/metricas/resumen.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/metricas/slugs.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/metricas/tiktok.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/metricas/youtube.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/migration_cli.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/migration_files.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/models.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/native_schedule/__init__.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/native_schedule/api_delivery.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/native_schedule/api_reconcile.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/native_schedule/approval.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/native_schedule/dispatcher.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/native_schedule/facebook.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/native_schedule/facebook_reels.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/native_schedule/first_comment.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/native_schedule/handoff.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/native_schedule/instagram_delivery.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/native_schedule/instagram_runtime.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/native_schedule/inventory.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/native_schedule/migration.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/native_schedule/models.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/native_schedule/reconcile.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/native_schedule/remote_data.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/native_schedule/store.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/native_schedule/verification.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/native_schedule/youtube.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/plantillas/accounts.yml +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/plantillas/brand.md +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/plantillas/estrategia.md +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/postfile.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/preflight.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/publication_policy.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/publication_status.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/publication_steps.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/publish_output.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/publisher.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/queue_migration.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/read_budget.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/read_reports.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/rutas.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/schedule_sqlite.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/scheduler.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/scheduler_launchd.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/scheduler_systemd.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/source_links.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/ui/__init__.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/upload_consent.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/youtube_data.py +0 -0
- {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/youtube_data_service.py +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
SocialCli Proprietary Software License
|
|
2
|
-
Version 1 — October 24, 2026
|
|
3
|
-
Copyright (c) 2026
|
|
4
|
-
|
|
2
|
+
Version 1.1 — October 24, 2026
|
|
3
|
+
Copyright (c) 2026 Florentino.pro. All rights reserved.
|
|
4
|
+
Florentino.pro is the copyright holder, licensor and operator of SocialCli.
|
|
5
5
|
|
|
6
6
|
1. Scope
|
|
7
7
|
This license applies to SocialCli releases beginning with version 0.3.2 and
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: socialcli
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.3
|
|
4
4
|
Summary: SocialCli: publish and manage creator-owned social content from the terminal.
|
|
5
5
|
Project-URL: Homepage, https://socialcli.app
|
|
6
6
|
Project-URL: Documentation, https://socialcli.app/ai-agents.html
|
|
@@ -22,7 +22,7 @@ Description-Content-Type: text/markdown
|
|
|
22
22
|
|
|
23
23
|
# SocialCli
|
|
24
24
|
|
|
25
|
-
**Current stable version: 0.3.
|
|
25
|
+
**Current stable version: 0.3.2 (published on PyPI).** SocialCli SaaS is the current SocialCli
|
|
26
26
|
product. The repository root on `main` is its single development workspace;
|
|
27
27
|
the CLI, hosted connection service and account/billing components live together
|
|
28
28
|
here. Earlier versions remain in Git history for reference and migration.
|
|
@@ -58,7 +58,7 @@ Stripe uses test mode. Planned access costs
|
|
|
58
58
|
USD 10/month after 14 free days from the first successful connection. No real
|
|
59
59
|
subscriptions are collected in this pilot.
|
|
60
60
|
|
|
61
|
-
The current stable client is **0.3.
|
|
61
|
+
The current stable client is **0.3.2**, published on TestPyPI and PyPI after
|
|
62
62
|
reviewed CI, matching artifact hashes and clean-install verification.
|
|
63
63
|
The hosted service is managed and verified separately from the CLI release. The client's wheel and
|
|
64
64
|
source archive are verified against the
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
# SocialCli
|
|
4
4
|
|
|
5
|
-
**Current stable version: 0.3.
|
|
5
|
+
**Current stable version: 0.3.2 (published on PyPI).** SocialCli SaaS is the current SocialCli
|
|
6
6
|
product. The repository root on `main` is its single development workspace;
|
|
7
7
|
the CLI, hosted connection service and account/billing components live together
|
|
8
8
|
here. Earlier versions remain in Git history for reference and migration.
|
|
@@ -38,7 +38,7 @@ Stripe uses test mode. Planned access costs
|
|
|
38
38
|
USD 10/month after 14 free days from the first successful connection. No real
|
|
39
39
|
subscriptions are collected in this pilot.
|
|
40
40
|
|
|
41
|
-
The current stable client is **0.3.
|
|
41
|
+
The current stable client is **0.3.2**, published on TestPyPI and PyPI after
|
|
42
42
|
reviewed CI, matching artifact hashes and clean-install verification.
|
|
43
43
|
The hosted service is managed and verified separately from the CLI release. The client's wheel and
|
|
44
44
|
source archive are verified against the
|
|
@@ -61,6 +61,10 @@ _CATALOG = {
|
|
|
61
61
|
'rate_limited': ('retry_later', 'Wait for the provider rate limit before a bounded read retry.'),
|
|
62
62
|
'authorization_unavailable': ('user_action', 'Review the account authorization; this failure alone does not prove token expiry.'),
|
|
63
63
|
'permissions_required': ('user_action', 'Review granted permissions and account eligibility with the owner.'),
|
|
64
|
+
'private_video_comments_disabled': ('inspect', 'YouTube disables comments on private videos. No settings were changed. Choose an eligible video; do not change privacy without owner approval.'),
|
|
65
|
+
'pilot_evidence_required': ('correct_input', 'Pilot evidence is required before persisting this API job. Run native-schedule pilot-template and preflight --eligibility FILE.'),
|
|
66
|
+
'pilot_evidence_invalid': ('correct_input', 'The pilot eligibility file must match this account and reference an actual nonempty evidence file inside the brand workspace.'),
|
|
67
|
+
'pilot_checklist_incomplete': ('user_action', 'The pilot checklist is incomplete. The owner must verify actual project and Studio evidence before its flags can be true.'),
|
|
64
68
|
'comments_disabled': ('inspect', 'Comments are disabled on this video. Reconnecting does not enable them; do not change video settings without owner approval.'),
|
|
65
69
|
'target_unavailable': ('correct_input', 'Verify the requested target and account before another read.'),
|
|
66
70
|
'request_rejected': ('correct_input', 'Review supported arguments and target before another request.'),
|
|
@@ -74,6 +78,8 @@ _CATALOG = {
|
|
|
74
78
|
'persistence_failed': ('repair_storage', 'Preserve verified observations and repair local persistence before collecting again.'),
|
|
75
79
|
'unsupported_operation': ('unsupported', 'This integration does not support the requested operation.'),
|
|
76
80
|
'account_not_configured': ('user_action', 'Ask the owner to select and authorize the intended account; never infer one.'),
|
|
81
|
+
'workspace_unavailable': ('inspect_workspace', 'Cannot read the selected workspace. Check --root or SOCIALCLI_ROOT and local file permissions.'),
|
|
82
|
+
'brand_not_found': ('correct_input', 'The named brand is not in the selected workspace. Check --brand and --root or SOCIALCLI_ROOT; do not select another brand automatically.'),
|
|
77
83
|
'invalid_input': ('correct_input', 'Review the supported arguments and explicit workspace.'),
|
|
78
84
|
'installation_access_required': ('sign_in', 'Ask the owner to sign in to SocialCli again.'),
|
|
79
85
|
'invalid_report_options': ('correct_input', 'Review the report date, window, item limit and platform.'),
|
|
@@ -102,6 +108,7 @@ def error_for_code(code: str, *, read_only: bool = True) -> dict:
|
|
|
102
108
|
|
|
103
109
|
def error_document(exc: BaseException, *, read_only: bool) -> dict:
|
|
104
110
|
import httpx
|
|
111
|
+
from socialctl.brands import WorkspaceUnavailable, BrandNoEncontrada, AccountsInvalido, NombreDeMarcaInvalido
|
|
105
112
|
from socialctl.read_budget import LocalConfigurationError
|
|
106
113
|
from socialctl.auth import AuthError
|
|
107
114
|
from socialctl.read_budget import ReadDeadlineExceeded, ReadResponseTooLarge
|
|
@@ -131,6 +138,11 @@ def error_document(exc: BaseException, *, read_only: bool) -> dict:
|
|
|
131
138
|
code = 'operation_uncertain'
|
|
132
139
|
elif isinstance(exc, (AuthError, YouTubeManagementError, ChangeError, ResourceError, CommentError, MetaError, ReadError)) and getattr(exc, '_socialcli_error_code', None) is not None:
|
|
133
140
|
code = exc._socialcli_error_code
|
|
141
|
+
elif isinstance(exc, YouTubeDataBusy): code = 'local_data_busy'
|
|
142
|
+
elif isinstance(exc, YouTubeDataCleanupError): code = 'storage_failure'
|
|
143
|
+
elif isinstance(exc, WorkspaceUnavailable): code = 'workspace_unavailable'
|
|
144
|
+
elif isinstance(exc, BrandNoEncontrada): code = 'brand_not_found'
|
|
145
|
+
elif isinstance(exc, (AccountsInvalido, NombreDeMarcaInvalido)): code = 'invalid_input'
|
|
134
146
|
elif isinstance(exc, ReadDeadlineExceeded): code = 'read_deadline'
|
|
135
147
|
elif isinstance(exc, ReadResponseTooLarge): code = 'response_too_large'
|
|
136
148
|
elif isinstance(exc, KeyboardInterrupt): code = 'read_interrupted'
|
|
@@ -157,8 +169,7 @@ def error_document(exc: BaseException, *, read_only: bool) -> dict:
|
|
|
157
169
|
'configuration_missing': 'account_not_configured'}.get(exc.code, 'target_validation_failed')
|
|
158
170
|
elif isinstance(exc, ResourceError): code = 'target_validation_failed'
|
|
159
171
|
elif isinstance(exc, ChangeError): code = 'operation_rejected'
|
|
160
|
-
elif isinstance(exc,
|
|
161
|
-
elif isinstance(exc, (OSError, YouTubeDataCleanupError)): code = 'storage_failure'
|
|
172
|
+
elif isinstance(exc, OSError): code = 'storage_failure'
|
|
162
173
|
elif isinstance(exc, ValueError): code = 'invalid_local_state'
|
|
163
174
|
else: code = 'operation_failed'
|
|
164
175
|
result = error_for_code(code,read_only=read_only)
|
|
@@ -189,7 +200,7 @@ def safe_diagnostic(value):
|
|
|
189
200
|
'write_state': {'not_started', 'unknown', 'accepted'},
|
|
190
201
|
'content_write_state': {'not_started'},
|
|
191
202
|
'reason': {'thread_unverifiable', 'thread_identity_mismatch', 'thread_total_unverifiable', 'comment_binding_mismatch', 'parent_mismatch', 'replies_not_allowed', 'incomplete_reply_list', 'readback_mismatch'},
|
|
192
|
-
'provider_reason': {'commentsDisabled', 'quotaExceeded', 'dailyLimitExceeded', 'rateLimitExceeded', 'userRateLimitExceeded', 'insufficientPermissions', 'forbidden', 'authError', 'invalidValue', 'videoNotFound', 'commentNotFound', 'commentThreadNotFound', 'processingFailure', 'operationNotSupported'},
|
|
203
|
+
'provider_reason': {'playlistNotFound', 'playlistItemNotFound', 'playlistItemsNotAccessible', 'commentsDisabled', 'quotaExceeded', 'dailyLimitExceeded', 'rateLimitExceeded', 'userRateLimitExceeded', 'insufficientPermissions', 'forbidden', 'authError', 'invalidValue', 'videoNotFound', 'commentNotFound', 'commentThreadNotFound', 'processingFailure', 'operationNotSupported'},
|
|
193
204
|
}
|
|
194
205
|
clean = {key: val for key, val in value.items()
|
|
195
206
|
if key in allowed and isinstance(val, str) and val in allowed[key]}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Topic selection keeps advanced contracts available without bloating bootstrap."""
|
|
2
|
+
from importlib.resources import files
|
|
3
|
+
import importlib.metadata
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
TOPICS={
|
|
7
|
+
'setup':('Workspace and YouTube outcome recovery','What agents can do','Install the reviewed release','Authorize the installation, then the social account','Persist and recover','Publication gate','Explicit approval and preflight'),
|
|
8
|
+
'publish':('Publication gate','Temporary Instagram media','Canonical publishing workflow','Check supplied media locally','Explicit approval and preflight'),
|
|
9
|
+
'schedule':('Preparing YouTube schedules and interpreting inventory','Publication gate','Temporary Instagram media','Scheduling and temporary media','Native scheduling input examples (offline)','Explicit approval and preflight'),
|
|
10
|
+
'inbox':('Preparing YouTube schedules and interpreting inventory','Workspace and YouTube outcome recovery','Publication gate','Read metrics and prepare engagement','Read diagnostics','Comment API evidence and limits','Explicit approval and preflight','Resumable batches and reply context','Purged reply history'),
|
|
11
|
+
'errors':('Workspace and YouTube outcome recovery','Persist and recover','Recovery and error actions','Read diagnostics','Comment API evidence and limits','Explicit approval and preflight','Resumable batches and reply context','Purged reply history'),
|
|
12
|
+
'permissions':('Installation connection access',),
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def guide(topic):
|
|
17
|
+
if topic not in TOPICS and topic!='all':
|
|
18
|
+
raise ValueError('Choose a guide topic: '+', '.join(TOPICS)+', all.')
|
|
19
|
+
full=files('socialctl.ui').joinpath('assets/agent-manual.md').read_text(encoding='utf-8')
|
|
20
|
+
full = full.replace('{{SOCIALCLI_VERSION}}', importlib.metadata.version('socialcli'))
|
|
21
|
+
if topic=='all':return full
|
|
22
|
+
sections=re.split(r'(?=^## )',full,flags=re.MULTILINE)
|
|
23
|
+
chosen=[section for section in sections if any(section.startswith('## '+heading) for heading in TOPICS[topic])]
|
|
24
|
+
return ('# SocialCli agent guide: '+topic+'\n\n'+''.join(chosen)+
|
|
25
|
+
'\nNext: socialcli agent-guide publish; socialcli agent-guide schedule; socialcli agent-guide inbox; socialcli agent-guide errors; socialcli agent-guide permissions; socialcli agent-guide all.\n')
|
|
@@ -21,6 +21,13 @@ class BrandNoEncontrada(Exception):
|
|
|
21
21
|
"""The requested brand directory does not exist."""
|
|
22
22
|
|
|
23
23
|
|
|
24
|
+
class WorkspaceUnavailable(OSError):
|
|
25
|
+
"""The selected workspace cannot be inspected safely."""
|
|
26
|
+
|
|
27
|
+
def __init__(self):
|
|
28
|
+
super().__init__('Cannot read the selected workspace. Check --root or SOCIALCLI_ROOT and local file permissions.')
|
|
29
|
+
|
|
30
|
+
|
|
24
31
|
class BrandYaExiste(Exception):
|
|
25
32
|
"""The requested brand directory already exists."""
|
|
26
33
|
|
|
@@ -148,13 +155,28 @@ def crear_brand(raiz_social: Path, nombre: str) -> Brand:
|
|
|
148
155
|
|
|
149
156
|
|
|
150
157
|
def cargar_brand(raiz_social: Path, nombre: str, *, cleanup: bool = True) -> Brand:
|
|
158
|
+
try:
|
|
159
|
+
brand = _read_brand(raiz_social, nombre)
|
|
160
|
+
except (OSError, RuntimeError) as exc:
|
|
161
|
+
raise WorkspaceUnavailable() from None
|
|
162
|
+
if cleanup:
|
|
163
|
+
from socialctl.youtube_data import enforce_youtube_data
|
|
164
|
+
enforce_youtube_data(brand.raiz)
|
|
165
|
+
return brand
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _read_brand(raiz_social: Path, nombre: str) -> Brand:
|
|
151
169
|
"""Load an existing explicitly named brand."""
|
|
152
170
|
raiz = _validar_nombre_de_marca(raiz_social, nombre)
|
|
153
171
|
if not raiz.is_dir():
|
|
154
|
-
disponibles =
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
172
|
+
disponibles = []
|
|
173
|
+
for p in raiz_social.iterdir():
|
|
174
|
+
try:
|
|
175
|
+
if p.is_dir() and (p / 'brand.md').exists():
|
|
176
|
+
disponibles.append(p.name)
|
|
177
|
+
except OSError:
|
|
178
|
+
continue
|
|
179
|
+
disponibles.sort()
|
|
158
180
|
raise BrandNoEncontrada(
|
|
159
181
|
f"brand does not exist: '{nombre}' in {raiz_social}. "
|
|
160
182
|
f"Available brands: {', '.join(disponibles) or 'none'}"
|
|
@@ -183,9 +205,6 @@ def cargar_brand(raiz_social: Path, nombre: str, *, cleanup: bool = True) -> Bra
|
|
|
183
205
|
f"'{fichero_cuentas}' must contain an account mapping (key: value); received {type(contenido_cuentas).__name__}"
|
|
184
206
|
)
|
|
185
207
|
|
|
186
|
-
if cleanup:
|
|
187
|
-
from socialctl.youtube_data import enforce_youtube_data
|
|
188
|
-
enforce_youtube_data(raiz)
|
|
189
208
|
return Brand(
|
|
190
209
|
nombre=nombre,
|
|
191
210
|
raiz=raiz,
|
|
@@ -149,13 +149,15 @@ def show_delivery_options(
|
|
|
149
149
|
):
|
|
150
150
|
"""Explain delivery modes and limits; does not authenticate or send media."""
|
|
151
151
|
from socialctl.delivery_options import delivery_options
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
typer.echo(
|
|
152
|
+
from socialctl.operation_output import OperationOutput
|
|
153
|
+
with OperationOutput(json_output, read_only=True) as output:
|
|
154
|
+
result = delivery_options(output.brand(root, brand_nombre))
|
|
155
|
+
if json_output:
|
|
156
|
+
typer.echo(json.dumps(result, indent=2))
|
|
157
|
+
else:
|
|
158
|
+
typer.echo('SocialCli does not store customer media on its SaaS server.')
|
|
159
|
+
for platform, row in result['platforms'].items():
|
|
160
|
+
typer.echo(f"{platform}: {row['selected_delivery']} — {row['instruction']}")
|
|
159
161
|
|
|
160
162
|
|
|
161
163
|
def _show_version(value: bool):
|
|
@@ -355,13 +355,13 @@ def attach(platform:Platform,brand_name:str=typer.Option(...,'--brand'),root:Pat
|
|
|
355
355
|
client.request('GET','/api/saas/v1/connections/'+opaque(previous['connection_id']),
|
|
356
356
|
secret=keychain.get(brand,platform,previous))
|
|
357
357
|
except ServiceRejected as exc:
|
|
358
|
-
# Browser revocation removes capabilities
|
|
359
|
-
#
|
|
358
|
+
# Browser revocation removes capabilities (404); a new
|
|
359
|
+
# owner grant replaces them (409). Explicit attach authenticates the
|
|
360
360
|
# current installation, requires its owner-approved platform
|
|
361
361
|
# and existing grant, and checks the exact old account below.
|
|
362
362
|
if (exc.status,exc.code) not in {
|
|
363
363
|
(401,'installation_revoked'),(401,'installation_expired'),
|
|
364
|
-
(404,'connection_unavailable')}:
|
|
364
|
+
(404,'connection_unavailable'),(409,'connection_replaced')}:
|
|
365
365
|
raise
|
|
366
366
|
else:raise ValueError('A local connection already exists. Review it before replacing it.')
|
|
367
367
|
result=client.request('POST','/api/saas/v1/installations/'+opaque(saved['connection_id'])+'/attach',
|
|
@@ -206,7 +206,14 @@ def sync_brand_inbox(brand,*,since,budget,only=None):
|
|
|
206
206
|
if inventory.get('failure_class'):
|
|
207
207
|
errors.append({'platform': 'youtube',
|
|
208
208
|
**inventory.get('error_detail', {'code': 'read_partial', 'message': 'Video inventory incomplete.'})})
|
|
209
|
-
|
|
209
|
+
from socialctl.inventory import youtube_target_context
|
|
210
|
+
saved_media = {v['remote_id']: v for v in inventory_store.list_items()}
|
|
211
|
+
media = [ident for ident, value in saved_media.items() if value.get('last_sync_id') == inventory['sync_id']]
|
|
212
|
+
for ident in inventory.get('missing_resource_ids', []):
|
|
213
|
+
errors.append({'platform': 'youtube', 'media_id': ident, 'code': 'target_unavailable',
|
|
214
|
+
'message': 'YouTube did not return this video in the inventory detail read. Its current visibility and comment coverage are unknown.',
|
|
215
|
+
'availability_reason': 'not_returned_by_youtube', 'stage': 'inventory_details',
|
|
216
|
+
**youtube_target_context(saved_media.get(ident))})
|
|
210
217
|
complete=inventory['complete'] and inventory['details_complete']
|
|
211
218
|
client=YouTubeInboxClient(brand,http)
|
|
212
219
|
for ident in media:
|
|
@@ -224,7 +231,8 @@ def sync_brand_inbox(brand,*,since,budget,only=None):
|
|
|
224
231
|
observed_keys.update(store.key(i) for i in items)
|
|
225
232
|
row['scopes'].append({'media_id':ident,'filter':'published','complete':ok})
|
|
226
233
|
complete=complete and ok
|
|
227
|
-
errors.extend({**_problem_document(e),'platform':platform.value,'media_id':ident
|
|
234
|
+
errors.extend({**_problem_document(e),'platform':platform.value,'media_id':ident,
|
|
235
|
+
**youtube_target_context(saved_media.get(ident))} for e in problems)
|
|
228
236
|
else:
|
|
229
237
|
meta=MetaClient(brand,platform,http)
|
|
230
238
|
listing=meta.content_list('posts' if platform is Platform.FACEBOOK else 'media',for_comments=True)
|
|
@@ -146,7 +146,10 @@ def sync_content(brand, platform, client, *, max_pages=20, resume=False):
|
|
|
146
146
|
run["failure_class"] = "page_limit"
|
|
147
147
|
run["updated_at"] = now()
|
|
148
148
|
state["sync"] = run
|
|
149
|
+
if platform is Platform.YOUTUBE:
|
|
150
|
+
verified_video_deletions(brand, state)
|
|
149
151
|
store.save(state)
|
|
152
|
+
deleted_count = sum(row.get('presence') == 'deleted_verified' for row in state['items'].values())
|
|
150
153
|
return {"version": 1, "brand": brand.nombre, "platform": platform.value,
|
|
151
154
|
"account_id": probe.account_id, "identity": identity, "sync_id": run["id"],
|
|
152
155
|
"complete": run["complete"], "failure_class": run["failure_class"],
|
|
@@ -156,4 +159,48 @@ def sync_content(brand, platform, client, *, max_pages=20, resume=False):
|
|
|
156
159
|
"resume_available": run["resumable"], "pages": run["pages"],
|
|
157
160
|
"observed_items": len(run["seen_ids"]), "stored_items": len(state["items"]),
|
|
158
161
|
"missing_resource_ids": run["missing_resource_ids"], "deletion_proven": False,
|
|
162
|
+
"verified_deleted_items": deleted_count, "retained_items": len(state["items"]) - deleted_count,
|
|
163
|
+
"deletion_scope": "Per-item verified deletion evidence; absence from sync alone is not proof",
|
|
159
164
|
"source": run.get("source"), "updated_at": run["updated_at"]}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def youtube_target_context(row):
|
|
168
|
+
"""Last observed metadata is context, never a current visibility inference."""
|
|
169
|
+
row = row or {}
|
|
170
|
+
status = row.get('observed_status')
|
|
171
|
+
status = status if isinstance(status, dict) else {}
|
|
172
|
+
return {'video_title': row.get('title'), 'scheduled_publish_at': status.get('publishAt'),
|
|
173
|
+
'last_observed_privacy': status.get('privacyStatus'),
|
|
174
|
+
'metadata_observed_at': row.get('last_observed_at'),
|
|
175
|
+
'metadata_basis': 'last_inventory_observation' if row else 'unavailable'}
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def verified_video_deletions(brand, state):
|
|
179
|
+
"""Join durable exact deletion receipts without erasing historical inventory."""
|
|
180
|
+
from socialctl.management.owned_changes import OwnedStore
|
|
181
|
+
store = OwnedStore(brand.raiz)
|
|
182
|
+
count = 0
|
|
183
|
+
for path in store.root.glob('*.json'):
|
|
184
|
+
try:
|
|
185
|
+
change = store.load(path.stem)
|
|
186
|
+
except (ValueError, OSError):
|
|
187
|
+
continue
|
|
188
|
+
if (change.target_brand != brand.nombre or change.target_account != state['account_id']
|
|
189
|
+
or change.edit.get('action') != 'video-delete' or change.resource != 'videos'
|
|
190
|
+
or change.status != 'verified' or change.verified is not True
|
|
191
|
+
or not change.receipt or change.receipt.get('http_status') != 204):
|
|
192
|
+
continue
|
|
193
|
+
events = [event for event in change.journal if event.get('event') == 'delete_readback'
|
|
194
|
+
and event.get('absent_observed') is True]
|
|
195
|
+
row = state['items'].get(change.target_id)
|
|
196
|
+
if not events or row is None:
|
|
197
|
+
continue
|
|
198
|
+
# A fresh provider observation wins over an older local deletion receipt.
|
|
199
|
+
if row.get('last_sync_id') == (state.get('sync') or {}).get('id'):
|
|
200
|
+
continue
|
|
201
|
+
row.update(presence='deleted_verified', deletion_proven=True,
|
|
202
|
+
deletion_evidence={'change_id': change.id, 'fingerprint': change.fingerprint,
|
|
203
|
+
'verified_at': change.updated_at.isoformat(), 'http_status': 204,
|
|
204
|
+
'basis': 'receipt_and_exact_id_absence'})
|
|
205
|
+
count += 1
|
|
206
|
+
return count
|
|
@@ -40,8 +40,10 @@ def guarded(operation, json_output):
|
|
|
40
40
|
data = operation()
|
|
41
41
|
except ReadError as exc:
|
|
42
42
|
data = {"version": 1, "failure_class": exc.code}
|
|
43
|
-
except Exception:
|
|
44
|
-
|
|
43
|
+
except Exception as exc:
|
|
44
|
+
from socialctl.agent_errors import error_document
|
|
45
|
+
data = {"version": 1, "failure_class": "local_state_invalid",
|
|
46
|
+
"status": "error", "error": error_document(exc, read_only=True)}
|
|
45
47
|
emit(data, json_output)
|
|
46
48
|
if data.get("failure_class"):
|
|
47
49
|
raise typer.Exit(1)
|
|
@@ -18,7 +18,7 @@ from socialctl.management.metadata_parts import KINDS, MetadataEdit, editable_pa
|
|
|
18
18
|
from socialctl.management.youtube import (YouTubeManagementError, YouTubeUpdateConflict,
|
|
19
19
|
YouTubeUpdateRejected)
|
|
20
20
|
from socialctl.management.youtube_metadata import same_metadata_snapshot
|
|
21
|
-
from socialctl.management.snippet_readback import snippet_mismatch_fields
|
|
21
|
+
from socialctl.management.snippet_readback import snippet_mismatch_fields, snippet_readback_evidence
|
|
22
22
|
|
|
23
23
|
|
|
24
24
|
class BatchFile(BaseModel):
|
|
@@ -172,7 +172,7 @@ def _event(op, event, **details):
|
|
|
172
172
|
op.journal.append({"at": now_utc().isoformat(), "event": event, **details})
|
|
173
173
|
|
|
174
174
|
|
|
175
|
-
def _mismatch_paths(current, expected):
|
|
175
|
+
def _mismatch_paths(current, expected, *, before=None):
|
|
176
176
|
"""Return bounded field names without raw bodies or values."""
|
|
177
177
|
mismatches = []
|
|
178
178
|
parts = editable_parts(current.resource)
|
|
@@ -193,7 +193,9 @@ def _mismatch_paths(current, expected):
|
|
|
193
193
|
except (AttributeError, TypeError, ValueError):
|
|
194
194
|
pass
|
|
195
195
|
if part == "snippet":
|
|
196
|
-
|
|
196
|
+
fields = (snippet_readback_evidence(actual, wanted, before=before.get('snippet', {}))['mismatch_fields']
|
|
197
|
+
if before is not None else snippet_mismatch_fields(actual, wanted))
|
|
198
|
+
mismatches.extend(f"snippet.{field}" for field in fields)
|
|
197
199
|
continue
|
|
198
200
|
for field in sorted(set(actual) | set(wanted)):
|
|
199
201
|
if field not in actual or field not in wanted or actual[field] != wanted[field]:
|
|
@@ -217,9 +219,9 @@ def _apply_operation(client, store, batch, op):
|
|
|
217
219
|
store.save(batch)
|
|
218
220
|
return
|
|
219
221
|
if op.status in {"applying", "uncertain"}:
|
|
220
|
-
mismatches = _mismatch_paths(current, op.after)
|
|
222
|
+
mismatches = _mismatch_paths(current, op.after, before=op.before)
|
|
221
223
|
op.status = "manual_review" if mismatches else "applied"
|
|
222
|
-
_event(op, "reconciled_read_only", mismatch_paths=", ".join(mismatches))
|
|
224
|
+
_event(op, "reconciled_read_only", mismatch_paths=", ".join(mismatches), provider_added_fields=_added_fields(current, op))
|
|
223
225
|
store.save(batch)
|
|
224
226
|
return
|
|
225
227
|
if not same_metadata_snapshot(op.observed, current.resource, allow_etag_only=True):
|
|
@@ -267,9 +269,9 @@ def _apply_operation(client, store, batch, op):
|
|
|
267
269
|
for attempt in readback_attempts():
|
|
268
270
|
try:
|
|
269
271
|
verified = client.inspect(op.video_id)
|
|
270
|
-
mismatches = _mismatch_paths(verified, op.after)
|
|
272
|
+
mismatches = _mismatch_paths(verified, op.after, before=op.before)
|
|
271
273
|
op.status = "uncertain" if mismatches else "applied"
|
|
272
|
-
_event(op, "read_back_" + op.status, mismatch_paths=", ".join(mismatches), attempt=str(attempt))
|
|
274
|
+
_event(op, "read_back_" + op.status, mismatch_paths=", ".join(mismatches), attempt=str(attempt), provider_added_fields=_added_fields(verified, op))
|
|
273
275
|
store.save(batch)
|
|
274
276
|
if not mismatches:
|
|
275
277
|
break
|
|
@@ -333,3 +335,36 @@ def prepare_restore(client, store: BatchStore, original_id: str) -> Batch:
|
|
|
333
335
|
operations.append(_operation(client, edit, resets=resets))
|
|
334
336
|
return _save_new(store, _batch(client, operations, {str(source): source_hash},
|
|
335
337
|
restored_from=original_id, unavailable=unavailable))
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _added_fields(current, op):
|
|
341
|
+
if 'snippet' not in op.after:
|
|
342
|
+
return ''
|
|
343
|
+
evidence = snippet_readback_evidence(editable_parts(current.resource)['snippet'],
|
|
344
|
+
op.after['snippet'], before=op.before.get('snippet', {}))
|
|
345
|
+
return ', '.join(evidence['provider_added_fields'])
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def reconcile_batch(client, store, batch_id):
|
|
349
|
+
"""Recover only attempted children; never execute untouched batch members."""
|
|
350
|
+
with store.apply_lock(batch_id):
|
|
351
|
+
batch = store.load(batch_id)
|
|
352
|
+
_bound(client, batch)
|
|
353
|
+
for op in batch.operations:
|
|
354
|
+
if op.status not in {'applying', 'uncertain', 'manual_review'}:
|
|
355
|
+
continue
|
|
356
|
+
if not any(event.get('event') == 'write_intent' for event in op.journal):
|
|
357
|
+
_event(op, 'recovery_blocked', reason='write_intent_missing')
|
|
358
|
+
continue
|
|
359
|
+
try:
|
|
360
|
+
current = client.inspect(op.video_id)
|
|
361
|
+
mismatches = _mismatch_paths(current, op.after, before=op.before)
|
|
362
|
+
op.status = 'manual_review' if mismatches else 'applied'
|
|
363
|
+
_event(op, 'reconciled_read_only', mismatch_paths=', '.join(mismatches),
|
|
364
|
+
provider_added_fields=_added_fields(current, op))
|
|
365
|
+
except (YouTubeManagementError, ChangeError, OSError) as exc:
|
|
366
|
+
from socialctl.agent_errors import error_document
|
|
367
|
+
_event(op, 'reconciliation_failed', error_detail_json=json.dumps(
|
|
368
|
+
error_document(exc, read_only=True), sort_keys=True))
|
|
369
|
+
store.save(batch)
|
|
370
|
+
return batch
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from socialctl.management.snippet_readback import same_snippet_readback, snippet_mismatch_fields, readback_attempts
|
|
5
|
+
from socialctl.management.snippet_readback import same_snippet_readback, snippet_mismatch_fields, readback_attempts, snippet_readback_evidence
|
|
6
6
|
|
|
7
7
|
import fcntl
|
|
8
8
|
import hashlib
|
|
@@ -445,14 +445,15 @@ def apply_youtube_change(
|
|
|
445
445
|
if detail['retryable']:
|
|
446
446
|
continue
|
|
447
447
|
break
|
|
448
|
-
|
|
448
|
+
evidence = snippet_readback_evidence(youtube.editable_snippet(verified), change.after, before=change.before)
|
|
449
|
+
mismatch = evidence['mismatch_fields']
|
|
449
450
|
if not mismatch:
|
|
450
451
|
break
|
|
451
452
|
_append(change, 'verification_mismatch', fields=mismatch, attempt=attempt)
|
|
452
453
|
store.save(change)
|
|
453
454
|
else:
|
|
454
455
|
verified = None
|
|
455
|
-
if verified is None or
|
|
456
|
+
if verified is None or snippet_readback_evidence(youtube.editable_snippet(verified), change.after, before=change.before)['mismatch_fields']:
|
|
456
457
|
change.status = 'uncertain'
|
|
457
458
|
store.save(change)
|
|
458
459
|
error = ChangeError('uncertain outcome: YouTube accepted PUT but complete readback was not verified; reconcile this ChangeSet without repeating the write')
|
|
@@ -463,38 +464,59 @@ def apply_youtube_change(
|
|
|
463
464
|
|
|
464
465
|
change.status = "applied"
|
|
465
466
|
change.verified = True
|
|
466
|
-
_append(change, "verified_applied", etag=verified.etag)
|
|
467
|
+
_append(change, "verified_applied", etag=verified.etag, provider_added_fields=evidence["provider_added_fields"])
|
|
467
468
|
store.save(change)
|
|
468
469
|
return change
|
|
469
470
|
|
|
470
471
|
|
|
471
472
|
def _reconcile(
|
|
472
|
-
youtube: YouTubeManagementClient, store: ChangeStore, change: ChangeSet
|
|
473
|
+
youtube: YouTubeManagementClient, store: ChangeStore, change: ChangeSet, *, return_unresolved=False
|
|
473
474
|
) -> ChangeSet:
|
|
474
475
|
"""Reread only; never call update when resuming an uncertain attempt."""
|
|
475
476
|
try:
|
|
476
477
|
current = youtube.inspect(change.video_id)
|
|
477
478
|
except YouTubeManagementError as exc:
|
|
478
|
-
|
|
479
|
+
from socialctl.agent_errors import error_document
|
|
480
|
+
detail = error_document(exc, read_only=True)
|
|
481
|
+
_append(change, "reconciliation_failed", error=detail['message'], error_detail=detail)
|
|
479
482
|
store.save(change)
|
|
483
|
+
if return_unresolved:
|
|
484
|
+
return change
|
|
480
485
|
raise ChangeError(
|
|
481
486
|
"outcome remains uncertain: read-only reconciliation failed"
|
|
482
487
|
) from None
|
|
483
488
|
|
|
484
489
|
snippet = youtube.editable_snippet(current)
|
|
485
|
-
|
|
490
|
+
evidence = snippet_readback_evidence(snippet, change.after, before=change.before)
|
|
491
|
+
if not evidence['mismatch_fields']:
|
|
486
492
|
change.status = "applied"
|
|
487
493
|
change.verified = True
|
|
488
|
-
_append(change, "reconciled_applied", etag=current.etag)
|
|
494
|
+
_append(change, "reconciled_applied", etag=current.etag, provider_added_fields=evidence["provider_added_fields"])
|
|
489
495
|
store.save(change)
|
|
490
496
|
return change
|
|
491
497
|
|
|
492
498
|
change.status = "manual_review"
|
|
493
499
|
change.verified = False
|
|
494
500
|
event = "reconciled_still_before" if _same_snippet(snippet, change.before) else "reconciled_diverged"
|
|
495
|
-
_append(change, event
|
|
501
|
+
_append(change, event, mismatch_fields=evidence['mismatch_fields'],
|
|
502
|
+
provider_added_fields=evidence['provider_added_fields'])
|
|
496
503
|
store.save(change)
|
|
504
|
+
if return_unresolved:
|
|
505
|
+
return change
|
|
497
506
|
raise ChangeError(
|
|
498
507
|
"uncertain PUT requires manual review; reconciliation was read-only "
|
|
499
508
|
"and did not repeat the write"
|
|
500
509
|
)
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def reconcile_youtube_change(youtube, store, change_id):
|
|
513
|
+
"""Explicit recovery of an attempted V1 write, including manual review."""
|
|
514
|
+
with store.apply_lock(change_id):
|
|
515
|
+
change = store.load(change_id)
|
|
516
|
+
if youtube.configured_channel_id != change.target_account:
|
|
517
|
+
raise ChangeError('configured account no longer matches ChangeSet')
|
|
518
|
+
if change.status not in {'applying', 'uncertain', 'manual_review'}:
|
|
519
|
+
return change
|
|
520
|
+
if not any(event.get('event') == 'write_intent' for event in change.journal):
|
|
521
|
+
raise ChangeError('read-only recovery requires a recorded write intent')
|
|
522
|
+
return _reconcile(youtube, store, change, return_unresolved=True)
|
|
@@ -225,3 +225,36 @@ register_community(content_app, DEFAULT_ROOT)
|
|
|
225
225
|
from socialctl.metricas.analytics_cli import register as register_analytics
|
|
226
226
|
|
|
227
227
|
register_analytics(content_app, DEFAULT_ROOT)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@changes_app.command('reconcile')
|
|
231
|
+
def changes_reconcile(change_id: str, brand: str = typer.Option(..., '--brand'),
|
|
232
|
+
root: Path = typer.Option(DEFAULT_ROOT, '--root'),
|
|
233
|
+
json_output: bool = typer.Option(False, '--json')):
|
|
234
|
+
"""Read-only V1/V2 metadata recovery. Never starts pending operations."""
|
|
235
|
+
from socialctl.management.changes import reconcile_youtube_change
|
|
236
|
+
from socialctl.management.batches import reconcile_batch
|
|
237
|
+
from socialctl.management.youtube_metadata import YouTubeMetadataClient
|
|
238
|
+
from socialctl.operation_output import OperationOutput
|
|
239
|
+
with OperationOutput(json_output, read_only=True):
|
|
240
|
+
selected = cargar_brand(root, brand)
|
|
241
|
+
single_store, batch_store = ChangeStore(selected.raiz), BatchStore(selected.raiz)
|
|
242
|
+
is_batch = batch_store.path_for(change_id).exists()
|
|
243
|
+
if is_batch and single_store.path_for(change_id).exists():
|
|
244
|
+
raise ChangeError('ambiguous ChangeSet ID in both metadata stores')
|
|
245
|
+
with make_http_client() as http:
|
|
246
|
+
if is_batch:
|
|
247
|
+
result = reconcile_batch(YouTubeMetadataClient(selected, http), batch_store, change_id)
|
|
248
|
+
verified = [op.id for op in result.operations if op.status == 'applied']
|
|
249
|
+
untouched = [op.id for op in result.operations if op.status == 'pending']
|
|
250
|
+
unresolved = [op.id for op in result.operations if op.id not in verified + untouched]
|
|
251
|
+
else:
|
|
252
|
+
result = reconcile_youtube_change(YouTubeManagementClient(selected, http), single_store, change_id)
|
|
253
|
+
verified = [result.id] if result.status == 'applied' and result.verified else []
|
|
254
|
+
untouched = [result.id] if result.status == 'proposed' else []
|
|
255
|
+
unresolved = [] if verified or untouched else [result.id]
|
|
256
|
+
payload = result.model_dump(mode='json')
|
|
257
|
+
payload['verification_summary'] = {'verified_ids': verified, 'unresolved_ids': unresolved, 'untouched_ids': untouched}
|
|
258
|
+
typer.echo(_json(payload))
|
|
259
|
+
if unresolved or untouched:
|
|
260
|
+
raise typer.Exit(1)
|
|
@@ -61,6 +61,11 @@ def baseline(client, edit):
|
|
|
61
61
|
action = edit.action
|
|
62
62
|
if action in {"add", "reply"}:
|
|
63
63
|
video = client.inspect(edit.video_id)
|
|
64
|
+
if getattr(video, 'status', {}).get('privacyStatus') == 'private':
|
|
65
|
+
exc = ResourceError('YouTube disables comments on private videos. No settings were changed.')
|
|
66
|
+
exc._socialcli_error_code = 'private_video_comments_disabled'
|
|
67
|
+
exc._socialcli_diagnostic = {'source': 'local', 'stage': 'baseline_check', 'write_state': 'not_started'}
|
|
68
|
+
raise exc
|
|
64
69
|
before = {"video_id": edit.video_id, "owner": video.channel_id}
|
|
65
70
|
if action == "reply":
|
|
66
71
|
thread = client.thread(edit.video_id, edit.thread_id)
|