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.

Files changed (224) hide show
  1. socialcli-0.3.0.dist-info/METADATA +168 -0
  2. socialcli-0.3.0.dist-info/RECORD +224 -0
  3. socialcli-0.3.0.dist-info/WHEEL +4 -0
  4. socialcli-0.3.0.dist-info/entry_points.txt +3 -0
  5. socialcli-0.3.0.dist-info/licenses/LICENSE +21 -0
  6. socialctl/__init__.py +0 -0
  7. socialctl/__main__.py +6 -0
  8. socialctl/adapters/__init__.py +6 -0
  9. socialctl/adapters/base.py +68 -0
  10. socialctl/adapters/errores.py +56 -0
  11. socialctl/adapters/facebook.py +180 -0
  12. socialctl/adapters/instagram.py +442 -0
  13. socialctl/adapters/tiktok.py +535 -0
  14. socialctl/adapters/youtube.py +331 -0
  15. socialctl/agent_errors.py +176 -0
  16. socialctl/agent_guide.py +23 -0
  17. socialctl/approval_cli.py +27 -0
  18. socialctl/auth.py +139 -0
  19. socialctl/authflow.py +344 -0
  20. socialctl/brands.py +194 -0
  21. socialctl/capabilities.py +57 -0
  22. socialctl/capabilities.yml +5945 -0
  23. socialctl/cli.py +1766 -0
  24. socialctl/connection_service/__init__.py +0 -0
  25. socialctl/connection_service/__main__.py +33 -0
  26. socialctl/connection_service/app.py +496 -0
  27. socialctl/connection_service/assets/PLATFORM-LOGOS-LICENSE.txt +50 -0
  28. socialctl/connection_service/assets/connection.css +75 -0
  29. socialctl/connection_service/assets/facebook.webp +0 -0
  30. socialctl/connection_service/assets/instagram.svg +194 -0
  31. socialctl/connection_service/assets/socialcli-logo.png +0 -0
  32. socialctl/connection_service/assets/tiktok.png +0 -0
  33. socialctl/connection_service/assets/youtube.svg +1 -0
  34. socialctl/connection_service/landing.py +146 -0
  35. socialctl/connection_service/legacy.py +48 -0
  36. socialctl/connection_service/peer.py +13 -0
  37. socialctl/connection_service/providers.py +324 -0
  38. socialctl/connection_service/settings.py +76 -0
  39. socialctl/connection_service/store.py +124 -0
  40. socialctl/connections/__init__.py +0 -0
  41. socialctl/connections/account_cli.py +438 -0
  42. socialctl/connections/cli.py +379 -0
  43. socialctl/connections/client.py +126 -0
  44. socialctl/connections/credential_cli.py +224 -0
  45. socialctl/connections/credential_store.py +49 -0
  46. socialctl/connections/file_store.py +193 -0
  47. socialctl/connections/keychain.py +78 -0
  48. socialctl/connections/meta_user.py +81 -0
  49. socialctl/connections/rotation_cli.py +116 -0
  50. socialctl/connections/saas_client.py +91 -0
  51. socialctl/connections/temporary_media.py +62 -0
  52. socialctl/delivery_options.py +70 -0
  53. socialctl/engagement.py +200 -0
  54. socialctl/engagement_cli.py +50 -0
  55. socialctl/executor.py +135 -0
  56. socialctl/formatter.py +291 -0
  57. socialctl/hosted_media.py +152 -0
  58. socialctl/identity.py +200 -0
  59. socialctl/inbox.py +298 -0
  60. socialctl/inbox_cli.py +46 -0
  61. socialctl/inventory.py +155 -0
  62. socialctl/inventory_cli.py +171 -0
  63. socialctl/inventory_sources.py +101 -0
  64. socialctl/management/__init__.py +1 -0
  65. socialctl/management/batch_cli.py +115 -0
  66. socialctl/management/batches.py +325 -0
  67. socialctl/management/changes.py +485 -0
  68. socialctl/management/cli.py +227 -0
  69. socialctl/management/comments_cli.py +352 -0
  70. socialctl/management/community_batch_cli.py +63 -0
  71. socialctl/management/community_batches.py +294 -0
  72. socialctl/management/community_changes.py +370 -0
  73. socialctl/management/community_cli.py +142 -0
  74. socialctl/management/community_schema.py +119 -0
  75. socialctl/management/facebook_groups.py +1118 -0
  76. socialctl/management/groups_cli.py +274 -0
  77. socialctl/management/inbox_readers.py +87 -0
  78. socialctl/management/meta_changes.py +197 -0
  79. socialctl/management/meta_cli.py +342 -0
  80. socialctl/management/meta_client.py +631 -0
  81. socialctl/management/meta_comment_inbox.py +565 -0
  82. socialctl/management/meta_comments.py +587 -0
  83. socialctl/management/meta_errors.py +59 -0
  84. socialctl/management/meta_pagination.py +52 -0
  85. socialctl/management/meta_schema.py +128 -0
  86. socialctl/management/meta_webhooks.py +147 -0
  87. socialctl/management/metadata_parts.py +144 -0
  88. socialctl/management/owned_assets.py +85 -0
  89. socialctl/management/owned_changes.py +470 -0
  90. socialctl/management/owned_cli.py +128 -0
  91. socialctl/management/owned_reorder.py +62 -0
  92. socialctl/management/owned_schema.py +368 -0
  93. socialctl/management/reply_context.py +24 -0
  94. socialctl/management/reply_evidence.py +80 -0
  95. socialctl/management/resource_changes.py +407 -0
  96. socialctl/management/resource_cli.py +136 -0
  97. socialctl/management/snippet_readback.py +26 -0
  98. socialctl/management/stories.py +713 -0
  99. socialctl/management/stories_cli.py +172 -0
  100. socialctl/management/youtube.py +323 -0
  101. socialctl/management/youtube_community.py +326 -0
  102. socialctl/management/youtube_metadata.py +122 -0
  103. socialctl/management/youtube_owned.py +259 -0
  104. socialctl/management/youtube_resources.py +305 -0
  105. socialctl/media.py +269 -0
  106. socialctl/media_cli.py +183 -0
  107. socialctl/media_ingress.py +211 -0
  108. socialctl/media_preflight.py +114 -0
  109. socialctl/media_registry.py +301 -0
  110. socialctl/media_requirements.py +52 -0
  111. socialctl/metricas/__init__.py +6 -0
  112. socialctl/metricas/almacen.py +133 -0
  113. socialctl/metricas/analytics_changes.py +295 -0
  114. socialctl/metricas/analytics_cli.py +191 -0
  115. socialctl/metricas/analytics_client.py +304 -0
  116. socialctl/metricas/analytics_ingest.py +260 -0
  117. socialctl/metricas/analytics_schema.py +174 -0
  118. socialctl/metricas/base.py +147 -0
  119. socialctl/metricas/facebook.py +418 -0
  120. socialctl/metricas/insights.py +227 -0
  121. socialctl/metricas/instagram.py +133 -0
  122. socialctl/metricas/modelos.py +87 -0
  123. socialctl/metricas/piezas.py +134 -0
  124. socialctl/metricas/reach.py +94 -0
  125. socialctl/metricas/reporting_client.py +112 -0
  126. socialctl/metricas/resumen.py +251 -0
  127. socialctl/metricas/slugs.py +89 -0
  128. socialctl/metricas/tiktok.py +185 -0
  129. socialctl/metricas/youtube.py +403 -0
  130. socialctl/migration_cli.py +60 -0
  131. socialctl/migration_files.py +68 -0
  132. socialctl/models.py +150 -0
  133. socialctl/native_schedule/__init__.py +13 -0
  134. socialctl/native_schedule/api_delivery.py +15 -0
  135. socialctl/native_schedule/api_reconcile.py +40 -0
  136. socialctl/native_schedule/approval.py +160 -0
  137. socialctl/native_schedule/cadence.py +73 -0
  138. socialctl/native_schedule/cli.py +510 -0
  139. socialctl/native_schedule/dispatcher.py +120 -0
  140. socialctl/native_schedule/facebook.py +112 -0
  141. socialctl/native_schedule/facebook_reels.py +140 -0
  142. socialctl/native_schedule/first_comment.py +181 -0
  143. socialctl/native_schedule/handoff.py +193 -0
  144. socialctl/native_schedule/instagram_delivery.py +224 -0
  145. socialctl/native_schedule/instagram_runtime.py +66 -0
  146. socialctl/native_schedule/inventory.py +70 -0
  147. socialctl/native_schedule/migration.py +310 -0
  148. socialctl/native_schedule/models.py +103 -0
  149. socialctl/native_schedule/reconcile.py +83 -0
  150. socialctl/native_schedule/remote_data.py +177 -0
  151. socialctl/native_schedule/report.py +151 -0
  152. socialctl/native_schedule/store.py +560 -0
  153. socialctl/native_schedule/verification.py +32 -0
  154. socialctl/native_schedule/youtube.py +339 -0
  155. socialctl/operation_output.py +87 -0
  156. socialctl/plantillas/accounts.yml +33 -0
  157. socialctl/plantillas/brand.md +95 -0
  158. socialctl/plantillas/estrategia.md +64 -0
  159. socialctl/postfile.py +240 -0
  160. socialctl/preflight.py +73 -0
  161. socialctl/publication_policy.py +34 -0
  162. socialctl/publication_status.py +75 -0
  163. socialctl/publication_steps.py +398 -0
  164. socialctl/publish_output.py +96 -0
  165. socialctl/publisher.py +445 -0
  166. socialctl/queue_migration.py +302 -0
  167. socialctl/read_budget.py +224 -0
  168. socialctl/read_reports.py +91 -0
  169. socialctl/rutas.py +103 -0
  170. socialctl/saas/__init__.py +1 -0
  171. socialctl/saas/access.py +75 -0
  172. socialctl/saas/auth.py +213 -0
  173. socialctl/saas/backup.py +124 -0
  174. socialctl/saas/billing.py +327 -0
  175. socialctl/saas/billing_enrollment.py +1237 -0
  176. socialctl/saas/billing_enrollment_schema.py +519 -0
  177. socialctl/saas/billing_inventory.py +775 -0
  178. socialctl/saas/billing_reconcile.py +127 -0
  179. socialctl/saas/billing_routes.py +27 -0
  180. socialctl/saas/budget.py +66 -0
  181. socialctl/saas/connections.py +374 -0
  182. socialctl/saas/consent.py +52 -0
  183. socialctl/saas/lifecycle.py +341 -0
  184. socialctl/saas/mail.py +89 -0
  185. socialctl/saas/maintenance.py +68 -0
  186. socialctl/saas/meta_user.py +298 -0
  187. socialctl/saas/meta_webhooks.py +358 -0
  188. socialctl/saas/migration.py +107 -0
  189. socialctl/saas/models.py +8 -0
  190. socialctl/saas/operations.py +102 -0
  191. socialctl/saas/pages.py +126 -0
  192. socialctl/saas/pairing.py +158 -0
  193. socialctl/saas/permissions.py +57 -0
  194. socialctl/saas/reminders.py +128 -0
  195. socialctl/saas/renewal.py +74 -0
  196. socialctl/saas/restore_guard.py +229 -0
  197. socialctl/saas/revocation.py +97 -0
  198. socialctl/saas/rotation.py +114 -0
  199. socialctl/saas/routes.py +221 -0
  200. socialctl/saas/settings.py +34 -0
  201. socialctl/saas/store.py +106 -0
  202. socialctl/saas/temporary_media.py +245 -0
  203. socialctl/saas/webhooks.py +76 -0
  204. socialctl/schedule_remote.py +262 -0
  205. socialctl/schedule_sqlite.py +44 -0
  206. socialctl/scheduler.py +581 -0
  207. socialctl/scheduler_launchd.py +319 -0
  208. socialctl/scheduler_systemd.py +167 -0
  209. socialctl/source_links.py +119 -0
  210. socialctl/ui/__init__.py +1 -0
  211. socialctl/ui/assets/agent-manual.md +850 -0
  212. socialctl/ui/assets/agents.md +27 -0
  213. socialctl/ui/assets/components.css +20 -0
  214. socialctl/ui/assets/connection-layout.css +53 -0
  215. socialctl/ui/assets/otp.template.html +10 -0
  216. socialctl/ui/assets/tokens.css +1 -0
  217. socialctl/ui/assets/trial.template.html +11 -0
  218. socialctl/ui/assets/web-layout.css +38 -0
  219. socialctl/ui/email/otp.html +10 -0
  220. socialctl/ui/email/trial.html +11 -0
  221. socialctl/upload_consent.py +13 -0
  222. socialctl/workspace.py +8 -0
  223. socialctl/youtube_data.py +471 -0
  224. 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