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,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
|