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,368 @@
|
|
|
1
|
+
"""Resource-specific proposal contract and preserving field scopes for Task 9.
|
|
2
|
+
|
|
3
|
+
No URL, HTTP verb, endpoint or arbitrary API payload is a proposal input.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import copy
|
|
8
|
+
import re
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Literal
|
|
12
|
+
|
|
13
|
+
import yaml
|
|
14
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator
|
|
15
|
+
|
|
16
|
+
from socialctl.management.youtube_resources import ResourceError, resource_id
|
|
17
|
+
|
|
18
|
+
ACTIONS = {
|
|
19
|
+
"playlist-create": ({"patch"}, {"patch"}),
|
|
20
|
+
"playlist-update": ({"playlist_id", "patch"}, {"playlist_id", "patch"}),
|
|
21
|
+
"playlist-delete": ({"playlist_id"}, {"playlist_id"}),
|
|
22
|
+
"item-insert": ({"playlist_id", "video_id", "position"}, {"playlist_id", "video_id", "position"}),
|
|
23
|
+
"item-update": ({"playlist_id", "item_id", "patch"}, {"playlist_id", "item_id", "patch"}),
|
|
24
|
+
"item-delete": ({"playlist_id", "item_id"}, {"playlist_id", "item_id"}),
|
|
25
|
+
"items-reorder": ({"playlist_id", "positions"}, {"playlist_id", "positions"}),
|
|
26
|
+
"section-create": ({"patch"}, {"patch"}),
|
|
27
|
+
"section-update": ({"section_id", "patch"}, {"section_id", "patch"}),
|
|
28
|
+
"section-delete": ({"section_id"}, {"section_id"}),
|
|
29
|
+
"channel-update": ({"channel_id", "patch"}, {"channel_id", "patch"}),
|
|
30
|
+
"channel-audience": ({"channel_id", "audience"}, {"channel_id", "audience"}),
|
|
31
|
+
"banner-upload": ({"channel_id", "file"}, {"channel_id", "file"}),
|
|
32
|
+
"banner-apply": ({"channel_id", "upload_change_id"}, {"channel_id", "upload_change_id"}),
|
|
33
|
+
"watermark-set": ({"channel_id", "target_channel_id", "file", "timing"}, {"channel_id", "target_channel_id", "file", "timing"}),
|
|
34
|
+
"watermark-unset": ({"channel_id"}, {"channel_id"}),
|
|
35
|
+
"image-insert": ({"playlist_id", "file", "image_type"}, {"playlist_id", "file", "image_type"}),
|
|
36
|
+
"image-update": ({"playlist_id", "image_id"}, {"playlist_id", "image_id", "file", "patch"}),
|
|
37
|
+
"image-delete": ({"playlist_id", "image_id"}, {"playlist_id", "image_id"}),
|
|
38
|
+
"video-recording-date": ({"video_id", "recording_date"}, {"video_id", "recording_date"}),
|
|
39
|
+
"video-delete": ({"video_id"}, {"video_id"}),
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def image_id(value):
|
|
44
|
+
# Discovery identifies a composite playlist/type ID. It is opaque and is
|
|
45
|
+
# always passed in query/JSON, never interpolated into a URL path.
|
|
46
|
+
if (not isinstance(value, str) or not 0 < len(value) <= 512 or "://" in value or "@" in value
|
|
47
|
+
or any(not char.isprintable() or char.isspace() for char in value)):
|
|
48
|
+
raise ResourceError("invalid image ID")
|
|
49
|
+
return value
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def section_id(value):
|
|
53
|
+
# ChannelSection.id is opaque (observed channel-id.section-id composites).
|
|
54
|
+
# A comma would turn exact-ID list lookup into a multi-ID filter. Separators
|
|
55
|
+
# otherwise remain encoded query/JSON data, never URL path interpolation.
|
|
56
|
+
if (not isinstance(value, str) or not 0 < len(value) <= 512 or "://" in value
|
|
57
|
+
or "@" in value or "," in value
|
|
58
|
+
or any(not char.isprintable() or char.isspace() for char in value)):
|
|
59
|
+
raise ResourceError("invalid section ID")
|
|
60
|
+
return value
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def owned_resource_id(resource, value):
|
|
64
|
+
validator = {"channelSections": section_id, "playlistImages": image_id}.get(resource, resource_id)
|
|
65
|
+
return validator(value)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Position(BaseModel):
|
|
69
|
+
model_config = ConfigDict(extra="forbid", strict=True)
|
|
70
|
+
item_id: str
|
|
71
|
+
position: int = Field(ge=0, le=4999)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class OwnedEdit(BaseModel):
|
|
75
|
+
model_config = ConfigDict(extra="forbid", strict=True)
|
|
76
|
+
version: Literal[1] = 1
|
|
77
|
+
action: str
|
|
78
|
+
playlist_id: str | None = None
|
|
79
|
+
item_id: str | None = None
|
|
80
|
+
video_id: str | None = None
|
|
81
|
+
section_id: str | None = None
|
|
82
|
+
channel_id: str | None = None
|
|
83
|
+
target_channel_id: str | None = None
|
|
84
|
+
image_id: str | None = None
|
|
85
|
+
image_type: Literal["hero"] | None = None
|
|
86
|
+
file: str | None = None
|
|
87
|
+
upload_change_id: str | None = None
|
|
88
|
+
patch: dict | None = None
|
|
89
|
+
position: int | None = Field(default=None, ge=0, le=4999)
|
|
90
|
+
positions: list[Position] | None = Field(default=None, min_length=1, max_length=5000)
|
|
91
|
+
audience: bool | None = None
|
|
92
|
+
recording_date: str | None = None
|
|
93
|
+
timing: dict | None = None
|
|
94
|
+
|
|
95
|
+
@model_validator(mode="before")
|
|
96
|
+
@classmethod
|
|
97
|
+
def exact_action_fields(cls, value):
|
|
98
|
+
if not isinstance(value, dict) or not isinstance(value.get("action"), str) or value["action"] not in ACTIONS:
|
|
99
|
+
raise ResourceError("unsupported owned-resource action")
|
|
100
|
+
required, allowed = ACTIONS[value["action"]]
|
|
101
|
+
present = set(value) - {"action", "version"}
|
|
102
|
+
if required - present or present - allowed or any(value[k] is None for k in present):
|
|
103
|
+
raise ResourceError("missing/extra/null action fields; omission preserves values, null is not accepted")
|
|
104
|
+
for key in present & {"playlist_id", "item_id", "video_id", "channel_id", "target_channel_id"}:
|
|
105
|
+
resource_id(value[key])
|
|
106
|
+
if "section_id" in present:
|
|
107
|
+
section_id(value["section_id"])
|
|
108
|
+
if "image_id" in present:
|
|
109
|
+
image_id(value["image_id"])
|
|
110
|
+
if value["action"] == "image-update" and not (present & {"file", "patch"}):
|
|
111
|
+
raise ResourceError("image-update requires metadata patch and/or supplied file")
|
|
112
|
+
if "file" in present and (not isinstance(value["file"], str) or not value["file"]):
|
|
113
|
+
raise ResourceError("invalid supplied file")
|
|
114
|
+
if "patch" in present and (not isinstance(value["patch"], dict) or not value["patch"]):
|
|
115
|
+
raise ResourceError("empty or invalid patch")
|
|
116
|
+
return value
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def edit_raw(edit):
|
|
120
|
+
return edit.model_dump(mode="python", exclude_none=True)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def validate_edit(edit):
|
|
124
|
+
try:
|
|
125
|
+
return OwnedEdit.model_validate(edit_raw(edit) if isinstance(edit, OwnedEdit) else edit)
|
|
126
|
+
except ValidationError as exc:
|
|
127
|
+
raise ResourceError(f"invalid proposal: {exc}") from None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def target(edit):
|
|
131
|
+
if edit.action.startswith("playlist-"):
|
|
132
|
+
return "playlists", edit.playlist_id
|
|
133
|
+
if edit.action.startswith(("item-", "items-")):
|
|
134
|
+
return "playlistItems", edit.item_id
|
|
135
|
+
if edit.action.startswith("section-"):
|
|
136
|
+
return "channelSections", edit.section_id
|
|
137
|
+
if edit.action.startswith("image-"):
|
|
138
|
+
return "playlistImages", edit.image_id
|
|
139
|
+
if edit.action.startswith("video-"):
|
|
140
|
+
return "videos", edit.video_id
|
|
141
|
+
return "channels", edit.channel_id
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def load_owned_edit(path):
|
|
145
|
+
try:
|
|
146
|
+
return validate_edit(yaml.safe_load(Path(path).read_text(encoding="utf-8")))
|
|
147
|
+
except (OSError, yaml.YAMLError):
|
|
148
|
+
raise ResourceError("failed to read proposal YAML") from None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def mapping(value, allowed, label):
|
|
152
|
+
if not isinstance(value, dict) or set(value) - set(allowed) or any(v is None for v in value.values()):
|
|
153
|
+
raise ResourceError(f"{label}: unknown/noneditable fields or null")
|
|
154
|
+
return value
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def text_fields(value, fields):
|
|
158
|
+
for key in set(value) & set(fields):
|
|
159
|
+
if not isinstance(value[key], str):
|
|
160
|
+
raise ResourceError(f"{key} must be text")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def localizations(value):
|
|
164
|
+
if not isinstance(value, dict):
|
|
165
|
+
raise ResourceError("localizations must be a mapping")
|
|
166
|
+
for lang, localized in value.items():
|
|
167
|
+
if not isinstance(lang, str) or not re.fullmatch(r"[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*", lang):
|
|
168
|
+
raise ResourceError("invalid localizations language")
|
|
169
|
+
mapping(localized, {"title", "description"}, "localizations")
|
|
170
|
+
text_fields(localized, {"title", "description"})
|
|
171
|
+
return value
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def merge(before, patch):
|
|
175
|
+
after = copy.deepcopy(before)
|
|
176
|
+
for key, value in patch.items():
|
|
177
|
+
after[key] = merge(after.get(key, {}), value) if isinstance(value, dict) else copy.deepcopy(value)
|
|
178
|
+
return after
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
PLAYLIST_SNIPPET = {"title", "description", "defaultLanguage"}
|
|
182
|
+
PLAYLIST_READ = {"publishedAt", "channelId", "channelTitle", "thumbnails", "localized"}
|
|
183
|
+
ITEM_SNIPPET = {"playlistId", "resourceId", "position"}
|
|
184
|
+
ITEM_READ = {"publishedAt", "channelId", "title", "description", "thumbnails", "channelTitle", "videoOwnerChannelTitle", "videoOwnerChannelId"}
|
|
185
|
+
CHANNEL_FIELDS = {"country", "description", "defaultLanguage", "keywords", "trackingAnalyticsAccountId", "unsubscribedTrailer"}
|
|
186
|
+
CHANNEL_DEPRECATED = {"profileColor", "showBrowseView", "moderateComments", "featuredChannelsTitle", "featuredChannelsUrls", "defaultTab", "showRelatedChannels"}
|
|
187
|
+
IMAGE_DEPRECATED = {"backgroundImageUrl", "bannerImageUrl", "bannerMobileImageUrl", "bannerTabletLowImageUrl", "bannerTabletImageUrl", "bannerTabletHdImageUrl", "bannerTabletExtraHdImageUrl", "bannerMobileLowImageUrl", "bannerMobileMediumHdImageUrl", "bannerMobileHdImageUrl", "bannerMobileExtraHdImageUrl", "bannerTvImageUrl", "bannerTvLowImageUrl", "bannerTvMediumImageUrl", "bannerTvHighImageUrl", "watchIconImageUrl", "trackingImageUrl", "largeBrandedBannerImageUrl", "largeBrandedBannerImageImapScript", "smallBrandedBannerImageUrl", "smallBrandedBannerImageImapScript"}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def editable(resource, part, row):
|
|
191
|
+
raw = row.get(part, {})
|
|
192
|
+
if part == "localizations":
|
|
193
|
+
return copy.deepcopy(localizations(raw))
|
|
194
|
+
if resource == "channels" and part == "brandingSettings":
|
|
195
|
+
mapping(raw, {"channel", "image", "watch", "hints"}, "brandingSettings")
|
|
196
|
+
channel = mapping(raw.get("channel", {}), CHANNEL_FIELDS | CHANNEL_DEPRECATED | {"title"}, "brandingSettings.channel")
|
|
197
|
+
image = mapping(raw.get("image", {}), {"bannerExternalUrl"} | IMAGE_DEPRECATED, "brandingSettings.image")
|
|
198
|
+
result = {}
|
|
199
|
+
if "channel" in raw:
|
|
200
|
+
result["channel"] = {k: v for k, v in channel.items() if k in CHANNEL_FIELDS or k == "title"}
|
|
201
|
+
text_fields(result["channel"], result["channel"])
|
|
202
|
+
if "bannerExternalUrl" in image:
|
|
203
|
+
result["image"] = {"bannerExternalUrl": image["bannerExternalUrl"]}
|
|
204
|
+
return result
|
|
205
|
+
scopes = {
|
|
206
|
+
("playlists", "snippet"): (PLAYLIST_SNIPPET, PLAYLIST_READ),
|
|
207
|
+
("playlists", "status"): ({"privacyStatus", "podcastStatus"}, set()),
|
|
208
|
+
("playlistItems", "snippet"): (ITEM_SNIPPET, ITEM_READ),
|
|
209
|
+
("playlistItems", "contentDetails"): ({"note", "startAt", "endAt"}, {"videoId", "videoPublishedAt"}),
|
|
210
|
+
("channelSections", "snippet"): ({"type", "title", "position"}, {"channelId"}),
|
|
211
|
+
("channelSections", "contentDetails"): ({"playlists", "channels"}, set()),
|
|
212
|
+
("channels", "status"): ({"selfDeclaredMadeForKids"}, {"privacyStatus", "isLinked", "longUploadsStatus", "madeForKids"}),
|
|
213
|
+
("playlistImages", "snippet"): ({"playlistId", "type", "width", "height"}, set()),
|
|
214
|
+
# Deprecated location fields are not silently dropped from a PUT. A
|
|
215
|
+
# legacy nonempty recordingDetails part needs separate evidence.
|
|
216
|
+
("videos", "recordingDetails"): ({"recordingDate"}, set()),
|
|
217
|
+
}
|
|
218
|
+
if (resource, part) not in scopes:
|
|
219
|
+
raise ResourceError("unsupported part")
|
|
220
|
+
writable, readonly = scopes[resource, part]
|
|
221
|
+
mapping(raw, writable | readonly, f"{resource}.{part}")
|
|
222
|
+
result = copy.deepcopy({k: v for k, v in raw.items() if k in writable})
|
|
223
|
+
if resource == "channelSections" and part == "snippet" and "type" in result:
|
|
224
|
+
result["type"] = section_type(result["type"])
|
|
225
|
+
return result
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def section_type(value):
|
|
229
|
+
types = {"allPlaylists", "completedEvents", "liveEvents", "multipleChannels", "multiplePlaylists", "popularUploads", "recentUploads", "singlePlaylist", "subscriptions", "upcomingEvents"}
|
|
230
|
+
# Provider reads also return lowercase enum spellings. Normalize only
|
|
231
|
+
# these exact known aliases; retain raw snapshots and reject unknown enums.
|
|
232
|
+
aliases = {name.lower(): name for name in types}
|
|
233
|
+
if not isinstance(value, str) or value not in types | aliases.keys():
|
|
234
|
+
raise ResourceError("unsupported section type")
|
|
235
|
+
return aliases.get(value, value)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def section_valid(body, account):
|
|
239
|
+
snip = body["snippet"]
|
|
240
|
+
snip["type"] = section_type(snip.get("type"))
|
|
241
|
+
details = body.get("contentDetails", {})
|
|
242
|
+
for key, ids in details.items():
|
|
243
|
+
if not isinstance(ids, list) or not ids or any(not isinstance(rid, str) for rid in ids) or len(ids) != len(set(ids)):
|
|
244
|
+
raise ResourceError("empty/duplicate/invalid section IDs")
|
|
245
|
+
for rid in ids:
|
|
246
|
+
resource_id(rid)
|
|
247
|
+
if key == "channels" and account in ids:
|
|
248
|
+
raise ResourceError("section cannot include own channel")
|
|
249
|
+
typ = snip["type"]
|
|
250
|
+
if typ in {"singlePlaylist", "multiplePlaylists"}:
|
|
251
|
+
if not details.get("playlists") or "channels" in details or (typ == "singlePlaylist" and len(details["playlists"]) != 1):
|
|
252
|
+
raise ResourceError("invalid content for playlist section")
|
|
253
|
+
elif typ == "multipleChannels":
|
|
254
|
+
if not details.get("channels") or "playlists" in details:
|
|
255
|
+
raise ResourceError("section requires channels only")
|
|
256
|
+
elif details:
|
|
257
|
+
raise ResourceError("this section type does not accept contentDetails")
|
|
258
|
+
if typ in {"multiplePlaylists", "multipleChannels"}:
|
|
259
|
+
title = snip.get("title")
|
|
260
|
+
if not isinstance(title, str) or not title.strip() or len(title) > 100 or "<" in title or ">" in title:
|
|
261
|
+
raise ResourceError("section title required/invalid")
|
|
262
|
+
elif "title" in snip:
|
|
263
|
+
raise ResourceError("this section type ignores title; no no-op proposed")
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def build_after(edit, resource, before, account):
|
|
267
|
+
patch = copy.deepcopy(edit.patch or {})
|
|
268
|
+
action = edit.action
|
|
269
|
+
if action == "channel-audience":
|
|
270
|
+
return {"status": {**editable("channels", "status", before), "selfDeclaredMadeForKids": edit.audience}}
|
|
271
|
+
if action == "video-recording-date":
|
|
272
|
+
value = edit.recording_date
|
|
273
|
+
try:
|
|
274
|
+
if not re.fullmatch(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})", value):
|
|
275
|
+
raise ValueError()
|
|
276
|
+
datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
277
|
+
except (ValueError, TypeError):
|
|
278
|
+
raise ResourceError("recordingDate requires ISO 8601 timestamp with explicit timezone") from None
|
|
279
|
+
return {"recordingDetails": {**editable("videos", "recordingDetails", before), "recordingDate": value}}
|
|
280
|
+
if action == "item-insert":
|
|
281
|
+
return {"snippet": {"playlistId": edit.playlist_id, "resourceId": {"kind": "youtube#video", "videoId": edit.video_id}, "position": edit.position}}
|
|
282
|
+
if action == "image-insert":
|
|
283
|
+
return {"snippet": {"playlistId": edit.playlist_id, "type": edit.image_type}}
|
|
284
|
+
allowed = {
|
|
285
|
+
"playlists": {"snippet": PLAYLIST_SNIPPET, "status": {"privacyStatus", "podcastStatus"}, "localizations": None},
|
|
286
|
+
"playlistItems": {"snippet": {"position"}, "contentDetails": {"note"}},
|
|
287
|
+
"channelSections": {"snippet": {"type", "title", "position"}, "contentDetails": {"playlists", "channels"}},
|
|
288
|
+
"channels": {"brandingSettings": {"channel"}, "localizations": None},
|
|
289
|
+
"playlistImages": {"snippet": {"width", "height"}},
|
|
290
|
+
}[resource]
|
|
291
|
+
if action == "section-update":
|
|
292
|
+
# Read-only inspection keeps new/unknown remote enums, but a PUT must
|
|
293
|
+
# not reinterpret their semantics even if patch replaces the type.
|
|
294
|
+
section_type(before.get("snippet", {}).get("type"))
|
|
295
|
+
mapping(patch, allowed, "patch")
|
|
296
|
+
for part, value in patch.items():
|
|
297
|
+
if part == "localizations":
|
|
298
|
+
localizations(value)
|
|
299
|
+
else:
|
|
300
|
+
mapping(value, allowed[part], part)
|
|
301
|
+
if part == "brandingSettings":
|
|
302
|
+
for key, val in value.items():
|
|
303
|
+
mapping(val, CHANNEL_FIELDS, "brandingSettings.channel")
|
|
304
|
+
text_fields(val, CHANNEL_FIELDS)
|
|
305
|
+
if resource == "channels" and len(patch) != 1:
|
|
306
|
+
raise ResourceError("channels.update accepts exactly one part")
|
|
307
|
+
parts = set(patch)
|
|
308
|
+
if resource in {"playlists", "playlistItems", "channelSections", "playlistImages"}:
|
|
309
|
+
parts.add("snippet")
|
|
310
|
+
if action == "playlist-update" and "localizations" in before:
|
|
311
|
+
# Writing the required snippet can remove translations even for a
|
|
312
|
+
# privacy-only update. Bind the complete preserved map to the preview,
|
|
313
|
+
# approval, single outgoing write and fresh readback.
|
|
314
|
+
parts.add("localizations")
|
|
315
|
+
if resource == "channelSections":
|
|
316
|
+
parts.add("contentDetails")
|
|
317
|
+
after = {part: merge(editable(resource, part, before), patch.get(part, {})) for part in sorted(parts)}
|
|
318
|
+
if resource == "channels":
|
|
319
|
+
channel = after.get("brandingSettings", {}).get("channel", {})
|
|
320
|
+
for field, limit in {"keywords": 500, "description": 1000}.items():
|
|
321
|
+
if len(channel.get(field, "")) > limit:
|
|
322
|
+
raise ResourceError(f"brandingSettings.channel.{field} exceeds documented maximum of {limit} characters")
|
|
323
|
+
for part, value in after.items():
|
|
324
|
+
text_fields(value, {"title", "description", "defaultLanguage", "note", "startAt", "endAt"})
|
|
325
|
+
if "position" in value and (type(value["position"]) is not int or value["position"] < 0 or value["position"] > 4999):
|
|
326
|
+
raise ResourceError("position requires a valid zero-based integer")
|
|
327
|
+
if resource == "playlists":
|
|
328
|
+
snip = after["snippet"]
|
|
329
|
+
previous = before.get("snippet", {})
|
|
330
|
+
language = snip.get("defaultLanguage")
|
|
331
|
+
translations = after.get("localizations", {})
|
|
332
|
+
if (action == "playlist-update" and language
|
|
333
|
+
and language == previous.get("defaultLanguage")
|
|
334
|
+
and language not in patch.get("localizations", {})
|
|
335
|
+
and {"title", "description"} <= previous.keys()
|
|
336
|
+
and {"title", "description"} <= snip.keys()
|
|
337
|
+
and translations.get(language) == {
|
|
338
|
+
"title": previous["title"], "description": previous["description"]}):
|
|
339
|
+
# Only an exact redundant default-language mirror follows snippet
|
|
340
|
+
# edits. Explicit/nonmirror entries and former defaults remain
|
|
341
|
+
# translations. Show normalization before approval, never hide a
|
|
342
|
+
# changed entry behind the readback mirror exception.
|
|
343
|
+
translations[language] = {"title": snip["title"], "description": snip["description"]}
|
|
344
|
+
if not snip.get("title", "").strip():
|
|
345
|
+
raise ResourceError("invalid playlist title/description")
|
|
346
|
+
status = after.get("status", {})
|
|
347
|
+
text_fields(status, {"privacyStatus", "podcastStatus"})
|
|
348
|
+
if "privacyStatus" in status and status["privacyStatus"] not in {"public", "unlisted", "private"}:
|
|
349
|
+
raise ResourceError("invalid privacyStatus")
|
|
350
|
+
if "podcastStatus" in status and status["podcastStatus"] not in {"enabled", "disabled", "unspecified"}:
|
|
351
|
+
raise ResourceError("invalid podcastStatus")
|
|
352
|
+
if action == "playlist-create" and ("description" not in snip or "privacyStatus" not in status or "podcastStatus" in status):
|
|
353
|
+
raise ResourceError("playlist creation requires explicit title/description/privacy; podcastStatus is update-only")
|
|
354
|
+
if after.get("localizations") and not snip.get("defaultLanguage"):
|
|
355
|
+
raise ResourceError("localizations requires defaultLanguage")
|
|
356
|
+
elif resource == "channelSections":
|
|
357
|
+
section_valid(after, account)
|
|
358
|
+
elif resource == "playlistItems" and len(after.get("contentDetails", {}).get("note", "")) > 280:
|
|
359
|
+
raise ResourceError("note exceeds documented maximum of 280 characters")
|
|
360
|
+
elif resource == "playlistImages":
|
|
361
|
+
for key in {"width", "height"} & set(after["snippet"]):
|
|
362
|
+
if type(after["snippet"][key]) is not int or not 0 < after["snippet"][key] <= 2147483647:
|
|
363
|
+
raise ResourceError("image dimensions must be positive integers")
|
|
364
|
+
elif resource == "channels" and after.get("localizations"):
|
|
365
|
+
settings = before.get("brandingSettings", {}).get("channel", {})
|
|
366
|
+
if not settings.get("defaultLanguage") and not before.get("snippet", {}).get("defaultLanguage"):
|
|
367
|
+
raise ResourceError("channel localizations requires existing defaultLanguage")
|
|
368
|
+
return after
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Explicit mapping from an observed reply to its actual thread write target."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def verify_reply_context(client, context, *, media_id, parent_id, thread_id=None):
|
|
5
|
+
platform = 'youtube' if thread_id is not None else client.platform.value
|
|
6
|
+
account = client.configured_channel_id if platform == 'youtube' else client.account_id
|
|
7
|
+
if platform == 'youtube':
|
|
8
|
+
from socialctl.management.youtube_resources import ResourceConflict as ContextError
|
|
9
|
+
else:
|
|
10
|
+
from socialctl.management.meta_comments import CommentError as ContextError
|
|
11
|
+
if (context.get('platform'), context.get('account_id'), context.get('media_id'),
|
|
12
|
+
context.get('parent_id'), context.get('thread_id')) != (
|
|
13
|
+
platform, account, media_id, parent_id, thread_id):
|
|
14
|
+
raise ContextError('reply context does not match the explicit parent mapping')
|
|
15
|
+
if platform == 'youtube':
|
|
16
|
+
raw = client.find_comment(media_id, thread_id, context['comment_id'], parent_id=parent_id)
|
|
17
|
+
snippet = raw['snippet']
|
|
18
|
+
fresh = (raw['id'], (snippet.get('authorChannelId') or {}).get('value'),
|
|
19
|
+
snippet.get('textOriginal', snippet.get('textDisplay')))
|
|
20
|
+
else:
|
|
21
|
+
raw = client.find_comment(media_id, context['comment_id'], parent_id=parent_id)
|
|
22
|
+
fresh = (raw['id'], raw['author_id'], raw['text'])
|
|
23
|
+
if fresh != (context.get('comment_id'), context.get('author_id'), context.get('text')):
|
|
24
|
+
raise ContextError('reply context changed since observation; sync and review again')
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Scoped platform observations, independent of local reply write journals."""
|
|
2
|
+
from socialctl.management.changes import now_utc
|
|
3
|
+
from socialctl.management.meta_comments import CommentError
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def reply_evidence(platform, account_id, media_id, parent_id, *, reply_ids=(), complete=False, thread_id=None):
|
|
7
|
+
ids = sorted(set(reply_ids))
|
|
8
|
+
return {'platform':platform, 'account_id':account_id, 'media_id':media_id,
|
|
9
|
+
'parent_id':parent_id, 'thread_id':thread_id, 'reply_ids':ids,
|
|
10
|
+
'state':'owner_reply_found' if ids else 'no_owner_reply_found' if complete else 'unknown',
|
|
11
|
+
'complete':bool(complete), 'observed_at':now_utc().isoformat()}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def bound_evidence(row):
|
|
15
|
+
evidence = row.get('reply_evidence')
|
|
16
|
+
if not evidence:
|
|
17
|
+
return None
|
|
18
|
+
# Evidence belongs to the direct parent only, never every external sibling.
|
|
19
|
+
if row.get('parent_id') or any(evidence.get(k) != row.get(k) for k in ('platform', 'account_id', 'media_id', 'thread_id')) or evidence.get('parent_id') != row.get('comment_id'):
|
|
20
|
+
raise ValueError('reply evidence differs from the observed account/media/parent')
|
|
21
|
+
ids = evidence.get('reply_ids')
|
|
22
|
+
if not isinstance(ids, list) or any(not isinstance(i, str) or not i for i in ids):
|
|
23
|
+
raise ValueError('invalid reply evidence IDs')
|
|
24
|
+
expected = 'owner_reply_found' if ids else 'no_owner_reply_found' if evidence.get('complete') is True else 'unknown'
|
|
25
|
+
if evidence.get('state') != expected:
|
|
26
|
+
raise ValueError('invalid reply evidence state')
|
|
27
|
+
return evidence
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def retain_positive(previous, current):
|
|
31
|
+
"""A failed/partial traversal cannot erase previously verified owner replies."""
|
|
32
|
+
if previous and previous.get('state') == 'owner_reply_found' and (not current or not current.get('complete')):
|
|
33
|
+
if current and current.get('reply_ids'):
|
|
34
|
+
return {**current, 'reply_ids':sorted(set(previous['reply_ids'] + current['reply_ids']))}
|
|
35
|
+
return previous
|
|
36
|
+
return current
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def youtube_reply_evidence(client, video_id, thread_id, parent_id, report):
|
|
40
|
+
ids = []
|
|
41
|
+
complete = report.get('complete') is True
|
|
42
|
+
for row in report.get('items', []):
|
|
43
|
+
snippet = row['snippet']
|
|
44
|
+
author = (snippet.get('authorChannelId') or {}).get('value')
|
|
45
|
+
if not isinstance(author, str) or not author:
|
|
46
|
+
complete = False
|
|
47
|
+
if author == client.configured_channel_id:
|
|
48
|
+
ids.append(row['id'])
|
|
49
|
+
return reply_evidence('youtube', client.configured_channel_id, video_id, parent_id,
|
|
50
|
+
thread_id=thread_id, reply_ids=ids, complete=complete)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def meta_reply_evidence(client, media_id, parent_id, listing, *, from_start=True):
|
|
54
|
+
from socialctl.management.meta_comment_inbox import verified_inbox_rows
|
|
55
|
+
rows, skipped = verified_inbox_rows(listing['data'], client.platform, media_id, parent_id)
|
|
56
|
+
authors = {ident:(raw.get('from') or {}).get('id') for ident, raw in rows.items()}
|
|
57
|
+
return reply_evidence(client.platform.value, client.account_id, media_id, parent_id,
|
|
58
|
+
reply_ids=[ident for ident, author in authors.items() if author == client.account_id],
|
|
59
|
+
complete=listing.get('complete') is True and from_start and not skipped and all(authors.values()))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ReplyCoverageError(CommentError):
|
|
63
|
+
def __init__(self, reason, message):
|
|
64
|
+
super().__init__(message)
|
|
65
|
+
self.reason = reason
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def check_reply_coverage(client, row):
|
|
69
|
+
"""Require a fresh selected-parent traversal for both inbox draft entrypoints."""
|
|
70
|
+
if row['platform'] == 'youtube':
|
|
71
|
+
report = client.list_replies(row['media_id'], row['thread_id'], row['comment_id'])
|
|
72
|
+
evidence = youtube_reply_evidence(client, row['media_id'], row['thread_id'], row['comment_id'], report)
|
|
73
|
+
else:
|
|
74
|
+
report = client.list_comments(row['media_id'], parent_id=row['comment_id'])
|
|
75
|
+
evidence = meta_reply_evidence(client, row['media_id'], row['comment_id'], report)
|
|
76
|
+
if evidence['state'] == 'owner_reply_found':
|
|
77
|
+
raise ReplyCoverageError('existing_platform_reply', 'Verified owner reply exists; refresh the inbox and inspect before another draft')
|
|
78
|
+
if evidence['state'] != 'no_owner_reply_found':
|
|
79
|
+
raise ReplyCoverageError('reply_coverage_unknown', 'Owner reply coverage is incomplete or authors unavailable; refresh/reconcile before drafting')
|
|
80
|
+
return evidence
|