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,294 @@
1
+ """Immutable supervised community batches; existing child stores own write safety."""
2
+ from __future__ import annotations
3
+ import hashlib
4
+ import hmac
5
+ import json
6
+ import uuid
7
+ from pathlib import Path
8
+ from typing import Literal
9
+
10
+ from pydantic import BaseModel, ConfigDict, Field
11
+ from socialctl.management.changes import ChangeError, now_utc
12
+ from socialctl.management.resource_changes import ResourceStore
13
+ from socialctl.models import Platform
14
+
15
+
16
+ def digest(value):
17
+ return hashlib.sha256(json.dumps(value,sort_keys=True,ensure_ascii=False,separators=(',',':')).encode()).hexdigest()
18
+
19
+
20
+ class BatchChild(BaseModel):
21
+ model_config=ConfigDict(extra='forbid')
22
+ provider: Literal['youtube-community','meta-comments','meta-management']
23
+ change_id: str
24
+ fingerprint: str
25
+ account_id: str
26
+ proposal: dict
27
+ limitations: list[str]
28
+
29
+
30
+ class CommunityBatch(BaseModel):
31
+ model_config=ConfigDict(extra='forbid')
32
+ version: Literal[1]=1
33
+ kind: Literal['community-batch']='community-batch'
34
+ id: str
35
+ brand: str
36
+ brand_root: str
37
+ accounts: dict
38
+ children: list[BatchChild]=Field(min_length=1,max_length=100)
39
+ created_at: str
40
+ fingerprint: str=''
41
+ status: Literal['proposed','applying','partial','verified']='proposed'
42
+ outcomes: dict[str,dict]=Field(default_factory=dict)
43
+
44
+
45
+ def batch_fingerprint(batch):
46
+ return digest(batch.model_dump(mode='json',exclude={'fingerprint','status','outcomes'}))
47
+
48
+
49
+ class CommunityBatchStore(ResourceStore):
50
+ model_type=CommunityBatch
51
+ fingerprint_for=staticmethod(batch_fingerprint)
52
+ def __init__(self,root):
53
+ self.root=Path(root)/'.socialctl'/'community-batches'
54
+
55
+
56
+ def child_store(brand,provider):
57
+ if provider=='youtube-community':
58
+ from socialctl.management.community_changes import CommunityStore
59
+ return CommunityStore(brand.raiz)
60
+ if provider=='meta-comments':
61
+ from socialctl.management.meta_comments import CommentStore
62
+ return CommentStore(brand.raiz)
63
+ if provider=='meta-management':
64
+ from socialctl.management.meta_changes import MetaStore
65
+ return MetaStore(brand.raiz)
66
+ raise ChangeError('unsupported community batch provider')
67
+
68
+
69
+ def child_details(brand,provider,child):
70
+ if provider=='youtube-community':
71
+ if child.edit['action'] not in {'reply','rate'}: raise ChangeError('batch supports YouTube reply/video rating only')
72
+ name,root,account=child.target_brand,child.brand_root,child.target_account
73
+ expected=(brand.cuentas.get('youtube') or {}).get('channel_id')
74
+ target=child.edit.get('parent_id') or child.edit.get('video_id')
75
+ operation=[provider,account,child.edit['action'],target]
76
+ limitations=child.effects
77
+ elif provider=='meta-comments':
78
+ if child.action not in {'add','reply'}: raise ChangeError('batch supports Meta add/reply only')
79
+ name,root,account=child.brand,child.brand_root,child.account_id
80
+ field='page_id' if child.platform=='facebook' else 'ig_user_id'
81
+ expected=(brand.cuentas.get(child.platform) or {}).get(field)
82
+ operation=[provider,child.platform,account,child.parent_id or child.media_id]
83
+ limitations=['Exact supplied text and target only. Clickability/pinning are not promised. Uncertain writes are not repeated.',
84
+ 'Requires actual creator grants and applicable actor/object eligibility; app settings alone do not prove permission.']
85
+ else:
86
+ if child.platform!='facebook' or child.edit['action'] not in {'like','unlike'}: raise ChangeError('batch supports Facebook like/unlike only')
87
+ name,root,account=child.brand,child.brand_root,child.identity['account_id']
88
+ expected=(brand.cuentas.get('facebook') or {}).get('page_id')
89
+ operation=[provider,account,child.edit.get('comment_id') or child.edit['target_id']]
90
+ limitations=['Facebook Page token, pages_manage_engagement and eligible object/Page task required. No rollback or cross-provider transaction.']
91
+ if (name,root,account)!=(brand.nombre,str(brand.raiz.resolve()),expected):
92
+ raise ChangeError('child brand/account binding differs from current brand')
93
+ return account,operation,limitations
94
+
95
+
96
+ def prepare_community_batch(brand,child_refs):
97
+ if not isinstance(child_refs,list) or not 1<=len(child_refs)<=100: raise ChangeError('batch needs 1–100 explicit children')
98
+ children,seen=[],set()
99
+ for ref in child_refs:
100
+ if not isinstance(ref,dict) or set(ref)!={'provider','change_id'}: raise ChangeError('child reference requires provider/change_id only')
101
+ store=child_store(brand,ref['provider'])
102
+ child=store.load(ref['change_id'])
103
+ if child.status!='proposed': raise ChangeError('prepare batch from proposed children only')
104
+ account,key,limits=child_details(brand,ref['provider'],child)
105
+ key=digest(key)
106
+ if key in seen: raise ChangeError('duplicate/conflicting community batch target')
107
+ seen.add(key)
108
+ children.append(BatchChild(**ref,fingerprint=child.fingerprint,account_id=account,
109
+ proposal=child.model_dump(mode='json'),limitations=limits))
110
+ batch=CommunityBatch(id=str(uuid.uuid4()),brand=brand.nombre,brand_root=str(brand.raiz.resolve()),
111
+ accounts=brand.cuentas,children=children,created_at=now_utc().isoformat())
112
+ batch.fingerprint=batch_fingerprint(batch)
113
+ CommunityBatchStore(brand.raiz).save(batch)
114
+ return batch
115
+
116
+
117
+ def http_client():
118
+ from socialctl.read_budget import ReadBudget,read_client
119
+ return read_client(ReadBudget(120))
120
+
121
+
122
+ def dispatch(brand,reference,*,reconcile=False):
123
+ store=child_store(brand,reference.provider)
124
+ with http_client() as http:
125
+ if reference.provider=='youtube-community':
126
+ from socialctl.management.youtube_community import YouTubeCommunityClient
127
+ from socialctl.management.community_changes import apply_community,reconcile_community
128
+ client=YouTubeCommunityClient(brand,http)
129
+ apply,read=apply_community,reconcile_community
130
+ elif reference.provider=='meta-comments':
131
+ from socialctl.management.meta_comments import MetaCommentsClient,apply_comment,reconcile_comment
132
+ client=MetaCommentsClient(brand,Platform(reference.proposal['platform']),http)
133
+ apply,read=apply_comment,reconcile_comment
134
+ child=store.load(reference.change_id)
135
+ if child.inbox_binding:
136
+ from socialctl.management.meta_comment_inbox import CommentInboxStore, apply_inbox_draft, reconcile_inbox_draft
137
+ inbox=CommentInboxStore(brand.raiz)
138
+ records=[r for r in inbox.load().comments if (r.platform,r.account_id,r.media_id,r.comment_id)==
139
+ (child.platform,child.account_id,child.media_id,child.parent_id)]
140
+ if len(records)!=1 or records[0].change_id!=child.id or records[0].fingerprint!=child.fingerprint:
141
+ raise ChangeError('inbox draft changed; prepare a new full batch preview')
142
+ kwargs={'media_id':child.media_id,'comment_id':child.parent_id}
143
+ if reconcile:
144
+ reconcile_inbox_draft(client,store,inbox,**kwargs)
145
+ else:
146
+ apply_inbox_draft(client,store,inbox,approval_digest=reference.fingerprint,**kwargs)
147
+ return store.load(reference.change_id)
148
+ else:
149
+ from socialctl.management.meta_client import MetaClient
150
+ from socialctl.management.meta_changes import apply_meta,reconcile_meta
151
+ client=MetaClient(brand,Platform.FACEBOOK,http)
152
+ apply,read=apply_meta,reconcile_meta
153
+ if reconcile: return read(client,store,reference.change_id)
154
+ return apply(client,store,reference.change_id,reference.fingerprint)
155
+
156
+
157
+ def validate_batch(brand,batch):
158
+ if (batch.brand,batch.brand_root,batch.accounts)!=(brand.nombre,str(brand.raiz.resolve()),brand.cuentas):
159
+ raise ChangeError('batch brand/account configuration changed; prepare a new preview')
160
+ for ref in batch.children:
161
+ child=child_store(brand,ref.provider).load(ref.change_id)
162
+ child_details(brand,ref.provider,child)
163
+ if not hmac.compare_digest(child.fingerprint,ref.fingerprint):
164
+ raise ChangeError('child proposal changed; batch approval is invalid')
165
+
166
+
167
+ def child_outcome(ref, index, status, **extra):
168
+ return {'status':status, 'child_index':index, 'provider':ref.provider,
169
+ 'platform':ref.proposal.get('platform', 'youtube'), 'change_id':ref.change_id, **extra}
170
+
171
+
172
+ def batch_document(batch, *, brand=None):
173
+ """Include pending children even when reading a historical sparse journal."""
174
+ from socialctl.agent_errors import error_for_code
175
+ value = batch.model_dump(mode='json')
176
+ outcomes = value['outcomes']
177
+ for index, ref in enumerate(batch.children, 1):
178
+ previous = outcomes.get(ref.change_id, {})
179
+ if brand is not None:
180
+ current = child_store(brand,ref.provider).load(ref.change_id)
181
+ if not hmac.compare_digest(current.fingerprint,ref.fingerprint):
182
+ raise ChangeError('child proposal changed; batch approval is invalid')
183
+ if current.status != 'proposed':
184
+ previous = child_outcome(ref,index,current.status) if current.status == 'verified' else {**previous,'status':current.status}
185
+ outcomes[ref.change_id] = {**child_outcome(ref, index, 'not_attempted'), **previous}
186
+ groups = {'verified':[], 'pending':[], 'uncertain':[], 'blocked':[]}
187
+ for ident, outcome in outcomes.items():
188
+ status = outcome['status']
189
+ group = ('verified' if status == 'verified' else 'pending' if status in {'not_attempted','proposed'}
190
+ else 'uncertain' if status in {'applying','uncertain','partial','accepted_unverifiable','applied_unverified'} else 'blocked')
191
+ groups[group].append(ident)
192
+ value['summary'] = {'total':len(batch.children), **{f'{key}_{suffix}':len(ids) if suffix == 'count' else ids
193
+ for key, ids in groups.items() for suffix in ('count','ids')}}
194
+ if brand is not None:
195
+ if len(groups['verified']) == len(batch.children):
196
+ value['status']='verified'
197
+ elif value['status']=='verified' or any(groups[k] for k in ('verified','uncertain','blocked')):
198
+ value['status']='partial'
199
+ if value['status'] != 'verified':
200
+ if groups['uncertain']:
201
+ value['error_detail'] = error_for_code('operation_uncertain', read_only=False)
202
+ elif groups['blocked']:
203
+ value['error_detail'] = error_for_code('operation_rejected', read_only=False)
204
+ elif value['status'] == 'proposed':
205
+ value['error_detail'] = error_for_code('approval_required', read_only=False)
206
+ elif groups['pending']:
207
+ value['error_detail'] = {'code':'batch_pending', 'action':'apply_remaining',
208
+ 'retryable':False, 'requires_user_action':False,
209
+ 'message':'Continue the existing approved batch with its exact fingerprint. Verified children will be skipped; do not create a new batch.'}
210
+ return value
211
+
212
+
213
+ def report_progress(callback, ref, index, total, status):
214
+ if callback is not None:
215
+ try:
216
+ callback({**child_outcome(ref, index, status), 'total':total})
217
+ except Exception:
218
+ pass # UI diagnostics never alter durable write safety.
219
+
220
+
221
+ def apply_community_batch(brand,batch_id,approval_digest,*,progress=None):
222
+ store=CommunityBatchStore(brand.raiz)
223
+ with store.apply_lock(batch_id):
224
+ batch=store.load(batch_id)
225
+ if not isinstance(approval_digest,str) or not hmac.compare_digest(batch.fingerprint,approval_digest):
226
+ raise ChangeError('approval must match the exact full batch fingerprint')
227
+ validate_batch(brand,batch)
228
+ for index, ref in enumerate(batch.children, 1):
229
+ batch.outcomes.setdefault(ref.change_id, child_outcome(ref, index, 'not_attempted'))
230
+ for index, ref in enumerate(batch.children, 1):
231
+ child=child_store(brand,ref.provider).load(ref.change_id)
232
+ if child.status=='verified':
233
+ batch.outcomes[ref.change_id]=child_outcome(ref, index, 'verified')
234
+ report_progress(progress, ref, index, len(batch.children), 'verified')
235
+ continue
236
+ batch.status='applying'
237
+ batch.outcomes[ref.change_id]=child_outcome(ref, index, 'applying')
238
+ store.save(batch)
239
+ report_progress(progress, ref, index, len(batch.children), 'applying')
240
+ try:
241
+ result=dispatch(brand,ref)
242
+ # A received receipt permits bounded read-only verification,
243
+ # never another write. Normal provider readback can lag the POST.
244
+ # Lost receipts and contradictory/unavailable evidence still stop
245
+ # this sequential batch before its next child.
246
+ if (result.status in {'uncertain', 'applied_unverified', 'accepted_unverifiable'}
247
+ and (getattr(result, 'remote_id', None) or getattr(result, 'result_id', None))):
248
+ report_progress(progress, ref, index, len(batch.children), 'verifying_receipt')
249
+ result=dispatch(brand,ref,reconcile=True)
250
+ batch.outcomes[ref.change_id]=child_outcome(ref, index, result.status)
251
+ report_progress(progress, ref, index, len(batch.children), result.status)
252
+ except ChangeError as exc:
253
+ from socialctl.read_reports import safe_error
254
+ from socialctl.agent_errors import error_document
255
+ current = child_store(brand,ref.provider).load(ref.change_id)
256
+ state = current.status if current.status in {'applying','uncertain','partial','accepted_unverifiable','applied_unverified'} else 'blocked'
257
+ batch.outcomes[ref.change_id]=child_outcome(ref, index, state,
258
+ error=safe_error(str(exc)), error_detail=error_document(exc, read_only=True))
259
+ report_progress(progress, ref, index, len(batch.children), state)
260
+ batch.status='partial';store.save(batch);return batch
261
+ except BaseException:
262
+ batch.status='partial';store.save(batch);raise
263
+ if result.status!='verified':
264
+ batch.status='partial';store.save(batch);return batch
265
+ store.save(batch)
266
+ batch.status='verified';store.save(batch);return batch
267
+
268
+
269
+ def reconcile_community_batch(brand,batch_id,*,progress=None):
270
+ store=CommunityBatchStore(brand.raiz)
271
+ with store.apply_lock(batch_id):
272
+ batch=store.load(batch_id);validate_batch(brand,batch)
273
+ for index, ref in enumerate(batch.children, 1):
274
+ child=child_store(brand,ref.provider).load(ref.change_id)
275
+ if child.status == 'proposed':
276
+ batch.outcomes.setdefault(ref.change_id, child_outcome(ref, index, 'not_attempted'))
277
+ continue
278
+ if child.status in {'applying','uncertain','partial','accepted_unverifiable','applied_unverified'}:
279
+ try:
280
+ child=dispatch(brand,ref,reconcile=True)
281
+ except ChangeError as exc:
282
+ from socialctl.read_reports import safe_error
283
+ from socialctl.agent_errors import error_document
284
+ current=child_store(brand,ref.provider).load(ref.change_id)
285
+ batch.outcomes[ref.change_id]=child_outcome(ref,index,current.status,
286
+ error=safe_error(str(exc)),error_detail=error_document(exc,read_only=True))
287
+ report_progress(progress,ref,index,len(batch.children),current.status)
288
+ store.save(batch)
289
+ continue
290
+ batch.outcomes[ref.change_id]=child_outcome(ref, index, child.status)
291
+ report_progress(progress, ref, index, len(batch.children), child.status)
292
+ store.save(batch)
293
+ batch.status='verified' if all(batch.outcomes.get(ref.change_id,{}).get('status')=='verified' for ref in batch.children) else 'partial'
294
+ store.save(batch);return batch
@@ -0,0 +1,370 @@
1
+ """Durable exact approval, per-target moderation observations and no blind retry."""
2
+ import hmac
3
+ import json
4
+ import uuid
5
+ from contextlib import ExitStack
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+ from typing import Literal
9
+
10
+ from pydantic import BaseModel, ConfigDict, Field
11
+
12
+ from socialctl.management.changes import ApprovalMismatch, now_utc
13
+ from socialctl.management.community_schema import CommentTarget, edit_raw, opaque_id, validate_edit
14
+ from socialctl.management.resource_changes import ResourceStore, fingerprint, _event
15
+ from socialctl.management.youtube_community import complete, FILTERS
16
+ from socialctl.management.youtube_resources import ResourceError, ResourceConflict, ResourceRejected, ResourceUncertain, resource_id
17
+
18
+
19
+ class CommunityChange(BaseModel):
20
+ model_config = ConfigDict(extra="forbid")
21
+ version: Literal[1] = 1
22
+ kind: Literal["youtube-community"] = "youtube-community"
23
+ id: str
24
+ target_brand: str
25
+ brand_root: str
26
+ target_account: str
27
+ edit: dict
28
+ before: dict
29
+ after: dict
30
+ effects: list[str]
31
+ fingerprint: str = ""
32
+ created_at: datetime
33
+ updated_at: datetime
34
+ status: Literal["proposed", "applying", "uncertain", "failed", "conflict", "verified", "partial", "accepted_unverifiable"] = "proposed"
35
+ verified: bool = False
36
+ result_id: str | None = None
37
+ receipt: dict | None = None
38
+ journal: list[dict] = Field(default_factory=list)
39
+
40
+
41
+ class CommunityStore(ResourceStore):
42
+ model_type = CommunityChange
43
+ fingerprint_for = staticmethod(fingerprint)
44
+
45
+ def __init__(self, brand_root):
46
+ self.root = Path(brand_root) / ".socialctl" / "youtube-community"
47
+
48
+
49
+ def binding(edit):
50
+ return {k: getattr(edit, k) for k in ("video_id", "thread_id", "comment_id", "parent_id") if getattr(edit, k) is not None}
51
+
52
+
53
+ def author(row):
54
+ value = row["snippet"].get("authorChannelId")
55
+ if not isinstance(value, dict) or not isinstance(value.get("value"), str) or not value["value"]:
56
+ raise ResourceError("comment author unverifiable")
57
+ return resource_id(value["value"])
58
+
59
+
60
+ def baseline(client, edit):
61
+ action = edit.action
62
+ if action in {"add", "reply"}:
63
+ video = client.inspect(edit.video_id)
64
+ before = {"video_id": edit.video_id, "owner": video.channel_id}
65
+ if action == "reply":
66
+ thread = client.thread(edit.video_id, edit.thread_id)
67
+ if thread["snippet"]["topLevelComment"]["id"] != edit.parent_id or thread["snippet"].get("canReply") is not True:
68
+ raise ResourceError("parent mismatch or thread does not accept replies")
69
+ before["parent"] = thread["snippet"]["topLevelComment"]
70
+ report = client.list_replies(edit.video_id, edit.thread_id, edit.parent_id)
71
+ if not report['complete']:
72
+ # Only the missing-envelope case permits preparation. Invalid identities,
73
+ # contradictory metadata and uncertain pagination still block a write.
74
+ if not str(report.get('error', '')).startswith('pagination metadata missing'):
75
+ complete(report)
76
+ before['reply_coverage'] = {'complete':False, 'absence_proven':False,
77
+ 'limitation':'Reply enumeration lacks metadata; existing remote duplicates cannot be ruled out.'}
78
+ rows = report['items']
79
+ else:
80
+ rows = []
81
+ for state in FILTERS:
82
+ rows.extend(t["snippet"]["topLevelComment"] for t in complete(client.list_threads(edit.video_id, moderation_status=state)))
83
+ if any(r["snippet"].get("authorChannelId", {}).get("value") == client.configured_channel_id
84
+ and r["snippet"].get("textOriginal") == edit.text for r in rows):
85
+ raise ResourceConflict("owned comment with exact text already exists at target; do not duplicate")
86
+ return before
87
+ if action in {"edit", "delete"}:
88
+ row = client.find_comment(**binding(edit))
89
+ if author(row) != client.configured_channel_id:
90
+ raise ResourceError("only original author can edit/delete; another author requires explicit moderation")
91
+ if action == "edit" and not isinstance(row["snippet"].get("textOriginal"), str):
92
+ raise ResourceError("author textOriginal missing; textDisplay is not original text")
93
+ return {"comment": row}
94
+ if action == "moderate":
95
+ rows = []
96
+ cache = {}
97
+ for target in edit.targets:
98
+ row = client.find_comment(**target.model_dump(exclude_none=True), moderation=True, cache=cache)
99
+ author(row)
100
+ previous = row["snippet"].get("moderationStatus")
101
+ if previous not in {"heldForReview", "published", "likelySpam", "rejected"}:
102
+ raise ResourceError("previous moderation status unverifiable")
103
+ if previous == "rejected" and edit.moderation_status != "rejected":
104
+ raise ResourceError("transition from rejected unsupported; restoration is not promised")
105
+ if edit.moderation_status == "heldForReview" and previous in {"published", "rejected"}:
106
+ raise ResourceError("transition to heldForReview unsupported from published/rejected")
107
+ rows.append({"target": target.model_dump(exclude_none=True), "comment": row})
108
+ return {"targets": rows}
109
+ if action in {"subscribe", "unsubscribe"}:
110
+ target = client.external("channels", edit.channel_id)
111
+ if edit.channel_id == client.configured_channel_id:
112
+ raise ResourceError("YouTube does not allow subscribing to your own channel")
113
+ rows = [r for r in complete(client.list_subscriptions()) if r["snippet"]["resourceId"]["channelId"] == edit.channel_id]
114
+ if action == "subscribe" and rows:
115
+ raise ResourceConflict("subscription already exists")
116
+ if action == "unsubscribe" and (len(rows) != 1 or rows[0]["id"] != edit.subscription_id):
117
+ raise ResourceError("subscription_id does not match actor and target")
118
+ return {"target": target, "subscriptions": rows}
119
+ if action == "rate":
120
+ return {"rating": client.get_rating(edit.video_id)}
121
+ if action == "report-abuse":
122
+ target = client.external("videos", edit.video_id)
123
+ reasons = complete(client.catalog("abuse-reasons", language=edit.language or "en"))
124
+ rows = [r for r in reasons if r["id"] == edit.reason_id]
125
+ if len(rows) != 1:
126
+ raise ResourceError("reason_id is absent from public catalog")
127
+ if edit.secondary_reason_id and not any(r.get("id") == edit.secondary_reason_id for r in rows[0]["snippet"].get("secondaryReasons", []) if isinstance(r, dict)):
128
+ raise ResourceError("secondary_reason_id does not belong to approved reason")
129
+ return {"target": target, "reason": rows[0]}
130
+ raise ResourceError("unsupported action")
131
+
132
+
133
+ def body_for(edit, account):
134
+ if edit.action == "add":
135
+ return {"snippet": {"channelId": account, "videoId": edit.video_id,
136
+ "topLevelComment": {"snippet": {"textOriginal": edit.text}}}}
137
+ if edit.action == "reply":
138
+ return {"snippet": {"parentId": edit.parent_id, "textOriginal": edit.text}}
139
+ if edit.action == "edit":
140
+ return {"id": edit.comment_id, "snippet": {"textOriginal": edit.text}}
141
+ if edit.action == "subscribe":
142
+ return {"snippet": {"resourceId": {"kind": "youtube#channel", "channelId": edit.channel_id}}}
143
+ if edit.action == "report-abuse":
144
+ return {k: v for k, v in {"videoId": edit.video_id, "reasonId": edit.reason_id,
145
+ "secondaryReasonId": edit.secondary_reason_id, "comments": edit.comments, "language": edit.language}.items() if v is not None}
146
+ return {}
147
+
148
+
149
+ def effects_for(edit):
150
+ notes = ["Only exact supplied IDs, actor, text and values. YouTube validates effective grants/eligibility; no permissions granted.",
151
+ "Status preflight before writing; remote race possible. Provider If-Match atomicity is not guaranteed.",
152
+ "Uncertain outcome not repeated with another UUID. Reconcile only reads."]
153
+ if edit.action in {"add", "reply", "edit"}:
154
+ notes.append("Comment visibility depends on privacy/moderation. textOriginal is verified only for its author; textDisplay does not prove original text. Pin, heart and clickable links are not promised.")
155
+ if edit.action == "delete":
156
+ notes.append("Original author's comment deletion; snapshot cannot be restored. Replies may become hidden. Absence does not prove deletion.")
157
+ if edit.action == "moderate":
158
+ notes.append("One POST for up to 50 explicit comments; individual checks. Rejected also hides replies; restoration is not promised. No automatic selection or rollback.")
159
+ notes.append("banAuthor=true automatically rejects future comments from EACH displayed author." if edit.ban_author else "banAuthor=false: future author blocking is not requested.")
160
+ notes.append("204 is an acceptance receipt; absence or invisible status does not prove status or banAuthor. Individual readback may remain partial/unverifiable.")
161
+ if edit.action in {"subscribe", "unsubscribe"}:
162
+ notes.append("Authenticated actor differs from external target channel. YouTube prevents self-subscription and duplicates and may limit volume/eligibility.")
163
+ if edit.action == "rate":
164
+ notes.append("Actor rating on explicit video, including external videos; none removes rating. Does not change official like/dislike count. Verified email required; rental/disabled ratings may prevent it.")
165
+ if edit.action == "report-abuse":
166
+ notes.append("Explicit video REPORT with displayed reason, independent of comments/audit. 204 accepts submission; no report query or guarantee of sanctions/reversal.")
167
+ return notes
168
+
169
+
170
+ def prepare_community(client, store, edit, *, reply_context=None):
171
+ edit = validate_edit(edit)
172
+ account = client.identity()
173
+ before = baseline(client, edit)
174
+ if reply_context is not None:
175
+ from socialctl.management.reply_context import verify_reply_context
176
+ verify_reply_context(client, reply_context, media_id=edit.video_id,
177
+ parent_id=edit.parent_id, thread_id=edit.thread_id)
178
+ before['reply_context'] = reply_context
179
+ moment = now_utc()
180
+ change = CommunityChange(id=str(uuid.uuid4()), target_brand=client.brand.nombre, brand_root=str(client.brand.raiz.resolve()),
181
+ target_account=account, edit=edit_raw(edit), before=before, after=body_for(edit, account), effects=effects_for(edit), created_at=moment, updated_at=moment)
182
+ change.fingerprint = fingerprint(change)
183
+ _event(change, "prepared")
184
+ store.save(change)
185
+ return change
186
+
187
+
188
+ def identity(client, change):
189
+ if (client.brand.nombre, str(client.brand.raiz.resolve()), client.configured_channel_id) != (change.target_brand, change.brand_root, change.target_account):
190
+ raise ResourceError("brand/account differs from proposal")
191
+ if client.identity() != change.target_account:
192
+ raise ResourceError("authenticated actor mismatch")
193
+
194
+
195
+ def operation_keys(change):
196
+ edit = validate_edit(change.edit)
197
+ if edit.action == "moderate":
198
+ keys = [("comments", t.video_id) for t in edit.targets]
199
+ elif edit.action in {"add", "reply", "edit", "delete"}:
200
+ keys = [("comments", edit.video_id)]
201
+ elif edit.action in {"subscribe", "unsubscribe"}:
202
+ keys = [("subscriptions", edit.channel_id)]
203
+ else:
204
+ keys = [(edit.action, edit.video_id)]
205
+ return {json.dumps([change.target_account, *key]) for key in keys}
206
+
207
+
208
+ def receipt_for(client, change, response):
209
+ edit = validate_edit(change.edit)
210
+ if response.get("http_status") == 204:
211
+ return response
212
+ receipt = {"identity_valid": False}
213
+ try:
214
+ if edit.action == "add":
215
+ receipt["thread_id"] = opaque_id(response.get("id"))
216
+ row = response["snippet"]["topLevelComment"]
217
+ change.result_id = opaque_id(row.get("id"))
218
+ client._thread(response, edit.video_id)
219
+ elif edit.action in {"reply", "edit"}:
220
+ row = response
221
+ change.result_id = opaque_id(row.get("id"))
222
+ client._comment(row, parent=edit.parent_id)
223
+ if edit.action == "edit" and change.result_id != edit.comment_id:
224
+ return receipt
225
+ else:
226
+ change.result_id = opaque_id(response.get("id"))
227
+ client._subscription(response)
228
+ receipt["identity_valid"] = response["snippet"]["resourceId"]["channelId"] == edit.channel_id
229
+ return receipt
230
+ receipt["identity_valid"] = author(row) == change.target_account
231
+ except (ResourceError, KeyError, TypeError, AttributeError):
232
+ pass
233
+ return receipt
234
+
235
+
236
+ def readback(client, store, change):
237
+ edit = validate_edit(change.edit)
238
+ try:
239
+ identity(client, change)
240
+ receipt = change.receipt or {}
241
+ if edit.action == "moderate" and receipt.get("http_status") == 204:
242
+ observations = []
243
+ cache = {}
244
+ for target in edit.targets:
245
+ observed = {"target": target.model_dump(exclude_none=True), "matches": False}
246
+ try:
247
+ row = client.find_comment(**target.model_dump(exclude_none=True), moderation=True, cache=cache)
248
+ observed["moderation_status"] = row["snippet"].get("moderationStatus")
249
+ observed["matches"] = observed["moderation_status"] == edit.moderation_status
250
+ prior = next(r["comment"] for r in change.before["targets"] if r["target"]["comment_id"] == target.comment_id)
251
+ observed["matches"] = observed["matches"] and author(row) == author(prior)
252
+ except ResourceError as exc:
253
+ observed["error"] = str(exc)
254
+ observations.append(observed)
255
+ _event(change, "moderation_target_readback", observation=observed, ban_verified=False)
256
+ store.save(change) # Each observed target survives later readback failure.
257
+ matches = sum(o["matches"] for o in observations)
258
+ _event(change, "moderation_readback", observations=observations, ban_verified=False)
259
+ change.status = "partial" if 0 < matches < len(observations) else "accepted_unverifiable"
260
+ if matches == len(observations) and not edit.ban_author:
261
+ change.status, change.verified = "verified", True
262
+ elif edit.action in {"delete", "report-abuse"} and receipt.get("http_status") == 204:
263
+ change.status = "accepted_unverifiable"
264
+ if edit.action == "delete":
265
+ observation = {"absence_is_not_proof": True}
266
+ try:
267
+ row = client.find_comment(**binding(edit))
268
+ observation["comment_still_observed"] = row["id"]
269
+ except ResourceError as exc:
270
+ observation["readback_error"] = str(exc)
271
+ _event(change, "delete_readback", **observation)
272
+ else:
273
+ _event(change, "accepted_without_provable_readback", no_report_read_endpoint=True)
274
+ elif edit.action in {"subscribe", "unsubscribe"}:
275
+ rows = complete(client.list_subscriptions())
276
+ rows = [r for r in rows if r["snippet"]["resourceId"]["channelId"] == edit.channel_id]
277
+ matches = (not rows and receipt.get("http_status") == 204) if edit.action == "unsubscribe" else (
278
+ receipt.get("identity_valid") and len(rows) == 1 and rows[0]["id"] == change.result_id)
279
+ _event(change, "subscription_readback", matches=bool(matches))
280
+ if matches:
281
+ change.status, change.verified = "verified", True
282
+ elif edit.action == "rate":
283
+ current = client.get_rating(edit.video_id)
284
+ _event(change, "rating_readback", observed=current)
285
+ if receipt.get("http_status") == 204 and current["rating"] == edit.rating:
286
+ change.status, change.verified = "verified", True
287
+ elif receipt.get("identity_valid") and change.result_id:
288
+ target = {"video_id": edit.video_id, "thread_id": receipt.get("thread_id", edit.thread_id),
289
+ "comment_id": change.result_id, "parent_id": edit.parent_id}
290
+ row = client.find_comment(**target, allow_partial=True)
291
+ matches = author(row) == change.target_account and row["snippet"].get("textOriginal") == edit.text
292
+ _event(change, "comment_readback", matches=matches, comment_id=change.result_id)
293
+ if matches:
294
+ change.status, change.verified = "verified", True
295
+ else:
296
+ _event(change, "identity_unresolved", no_candidate_adoption=True)
297
+ except ResourceError as exc:
298
+ _event(change, "readback_unavailable", error=str(exc))
299
+ store.save(change)
300
+ return change
301
+
302
+
303
+ def reconcile_community(client, store, change_id):
304
+ with store.apply_lock(change_id):
305
+ change = store.load(change_id)
306
+ identity(client, change)
307
+ if change.status in {"applying", "uncertain", "partial", "accepted_unverifiable"}:
308
+ if change.status == "applying":
309
+ change.status = "uncertain"
310
+ return readback(client, store, change)
311
+ return change
312
+
313
+
314
+ def apply_community(client, store, change_id, approval_digest):
315
+ with ExitStack() as locks:
316
+ locks.enter_context(store.apply_lock(change_id))
317
+ change = store.load(change_id)
318
+ edit = validate_edit(change.edit)
319
+ if not hmac.compare_digest(approval_digest, change.fingerprint):
320
+ raise ApprovalMismatch("approval does not match exact fingerprint")
321
+ if body_for(edit, change.target_account) != change.after or effects_for(edit) != change.effects:
322
+ raise ResourceError("body/effects do not match proposal")
323
+ keys = operation_keys(change)
324
+ for key in sorted(keys):
325
+ locks.enter_context(store.apply_lock(str(uuid.uuid5(uuid.NAMESPACE_URL, key))))
326
+ identity(client, change)
327
+ if change.status != "proposed":
328
+ if change.status in {"applying", "uncertain", "partial", "accepted_unverifiable"}:
329
+ if change.status == "applying":
330
+ change.status = "uncertain"
331
+ return readback(client, store, change)
332
+ return change
333
+ for path in store.root.glob("*.json"):
334
+ other = store.load(path.stem)
335
+ if other.id != change.id and keys & operation_keys(other) and other.status in {"applying", "uncertain", "partial"}:
336
+ raise ResourceError(f"uncertain outcome pending in {other.id}; another UUID does not allow repetition")
337
+ if (other.id != change.id and other.status == "verified"
338
+ and other.target_account == change.target_account
339
+ and edit.action in {"add", "reply"}
340
+ and other.edit == change.edit):
341
+ raise ResourceConflict("identical comment already verified locally; another UUID does not allow repetition")
342
+ try:
343
+ if change.before.get('reply_context'):
344
+ from socialctl.management.reply_context import verify_reply_context
345
+ verify_reply_context(client, change.before['reply_context'], media_id=edit.video_id,
346
+ parent_id=edit.parent_id, thread_id=edit.thread_id)
347
+ if baseline(client, edit) != {k:v for k,v in change.before.items() if k != 'reply_context'}:
348
+ raise ResourceConflict("conflict: status/author changed since preview")
349
+ except ResourceConflict:
350
+ change.status = "conflict"
351
+ _event(change, "pre_write_conflict")
352
+ store.save(change)
353
+ raise
354
+ change.status = "applying"
355
+ _event(change, "write_intent", approval_digest=approval_digest)
356
+ store.save(change)
357
+ try:
358
+ response = client.effect(edit, change.after, change.before)
359
+ except ResourceRejected as exc:
360
+ change.status = "conflict" if isinstance(exc, ResourceConflict) else "failed"
361
+ _event(change, "write_rejected", error=str(exc))
362
+ except ResourceUncertain as exc:
363
+ change.status = "uncertain"
364
+ _event(change, "write_uncertain", error=str(exc))
365
+ else:
366
+ change.status = "uncertain"
367
+ change.receipt = receipt_for(client, change, response)
368
+ _event(change, "write_response", result_id=change.result_id, receipt=change.receipt)
369
+ store.save(change)
370
+ return readback(client, store, change) if change.status == "uncertain" else change