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,127 @@
1
+ """Canonical Stripe reconciliation: event timestamps never decide access."""
2
+ from socialctl.saas.billing import fixed_price, unavailable, stripe_id
3
+ from socialctl.saas.budget import using_budget
4
+
5
+
6
+ def paid_until(billing,customer,subscription,invoices):
7
+ end=0
8
+ for invoice in invoices:
9
+ parent=invoice.get('parent') or {}
10
+ details=parent.get('subscription_details') or {}
11
+ if (invoice.get('livemode') is not billing.settings.expected_livemode or invoice.get('customer')!=customer
12
+ or invoice.get('status')!='paid' or invoice.get('paid_out_of_band')
13
+ or invoice.get('currency')!='usd'
14
+ or details.get('subscription')!=subscription['id']):
15
+ continue
16
+ lines=invoice.get('lines') or {}
17
+ data=lines.get('data',[])
18
+ if lines.get('has_more') is not False:continue
19
+ matches=[line for line in data if ((line.get('pricing') or {}).get('price_details') or {}).get('price')==billing.settings.price_id]
20
+ if len(matches)!=1:continue
21
+ line=matches[0];price=(line.get('pricing') or {}).get('price_details') or {}
22
+ if line.get('currency')!='usd' or line.get('amount')!=1000 or line.get('quantity')!=1 or price.get('price')!=billing.settings.price_id:continue
23
+ period=line.get('period') or {}
24
+ start,finish=period.get('start'),period.get('end')
25
+ if type(start) not in (int,float) or type(finish) not in (int,float) or not 0<finish-start<=32*86400:continue
26
+ if start<=billing.clock():end=max(end,finish)
27
+ return end
28
+
29
+
30
+ def reconcile(billing,user_id):
31
+ with billing.lease(user_id) as (row,token):
32
+ if not row['customer_id']:return
33
+ if billing.api('GET','/account').get('id')!=billing.settings.account_id:raise unavailable()
34
+ customer=billing.customer(user_id,row,token)
35
+ subscriptions=billing.listing('/subscriptions',{'customer':customer,'status':'all'})
36
+ ongoing=[s for s in subscriptions if s.get('status') not in {'canceled','incomplete_expired'}]
37
+ candidates=ongoing or [s for s in subscriptions if s.get('status')=='canceled']
38
+ status='';end=0;selected=None;cancel=False;review=''
39
+ if len(ongoing)>1:
40
+ review='multiple_subscriptions'
41
+ else:
42
+ invoices=billing.listing('/invoices',{'customer':customer,'status':'paid'}) if candidates else []
43
+ for sub in candidates:
44
+ items=sub.get('items') or {};data=items.get('data',[])
45
+ valid=(billing.owned(sub,user_id) and sub.get('customer')==customer and items.get('has_more') is False
46
+ and len(data)==1 and data[0].get('quantity')==1 and fixed_price(data[0].get('price',{}),billing.settings.price_id,expected_livemode=billing.settings.expected_livemode))
47
+ if not valid:
48
+ review='subscription_mismatch';continue
49
+ until=paid_until(billing,customer,sub,invoices)
50
+ if selected is None or until>end:
51
+ selected=stripe_id(sub.get('id'),'sub_');status=sub.get('status','')
52
+ end=until
53
+ # Clover Portal can schedule an explicit cancel_at timestamp
54
+ # while cancel_at_period_end remains false. Both mean no renewal.
55
+ cancel=(sub.get('cancel_at_period_end') is True
56
+ or type(sub.get('cancel_at')) is int and sub['cancel_at']>0)
57
+ if sub.get('pause_collection'):status='paused'
58
+ if status=='trialing':review='unexpected_stripe_trial'
59
+ if review and review!='unexpected_stripe_trial':status='';end=0
60
+ with billing.vault.transaction() as db:
61
+ current=db.execute('SELECT * FROM saas_billing WHERE user_id=?',(user_id,)).fetchone()
62
+ user=billing.metadata.user(db,user_id)
63
+ if not current or current['lease']!=token or current['lease_until']<=billing.clock() or not user or user['deleting']:
64
+ raise unavailable()
65
+ db.execute('INSERT OR IGNORE INTO saas_entitlements(user_id) VALUES(?)',(user_id,))
66
+ db.execute('UPDATE saas_entitlements SET subscription_status=?,paid_until=? WHERE user_id=?',(status,end,user_id))
67
+ db.execute('UPDATE saas_billing SET subscription_id=?,cancel_at_period_end=?,review=?,sync_due=? WHERE user_id=?',
68
+ (selected,int(cancel),review,billing.clock()+300,user_id))
69
+ return {'status':status,'paid_until':end,'review':review}
70
+
71
+
72
+ def run_pending(billing,limit=100,*,budget=None):
73
+ with using_budget(budget,billing):
74
+ return _run_pending(billing,limit,budget)
75
+
76
+
77
+ def _run_pending(billing,limit,budget):
78
+ """Run on the isolated five-minute maintenance timer; safe to retry on crash."""
79
+ from fastapi import HTTPException
80
+ with billing.vault.transaction() as db:
81
+ events=[dict(r) for r in db.execute("SELECT * FROM saas_billing_events WHERE state='pending' AND next_attempt<=? ORDER BY next_attempt,received,event_id LIMIT ?",(billing.clock(),limit))]
82
+ due=[r[0] for r in db.execute('SELECT b.user_id FROM saas_billing b JOIN saas_users u ON u.id=b.user_id WHERE b.customer_id IS NOT NULL AND b.sync_due<=? AND u.deleting=0 ORDER BY b.sync_due,b.user_id LIMIT ?',(billing.clock(),limit))]
83
+ processed=0;failed=0;visited=set()
84
+ # Reserve half the phase for periodic reconciliation, even under event load.
85
+ event_budget=budget.portion(0.5) if budget else None
86
+ if event_budget:billing.budget=event_budget
87
+ for event in events:
88
+ if event_budget and event_budget.expired():break
89
+ key=(event['account_id'],event['mode'],event['event_id'])
90
+ with billing.vault.transaction() as db:
91
+ row=db.execute('SELECT user_id FROM saas_billing WHERE customer_id=?',(event['customer_id'],)).fetchone()
92
+ if not row:
93
+ # A customer write may still be in flight. Preserve the event until
94
+ # that write finishes; unrelated deliveries eventually need review.
95
+ state='ignored' if billing.clock()-event['received']>86400 else 'pending'
96
+ if state=='ignored':
97
+ with billing.vault.read_transaction() as db:
98
+ saved=billing.vault.get(db,'saas-stripe-event',':'.join(key))
99
+ if saved and (saved['payload']['data']['object'].get('metadata') or {}).get('app')=='socialcli':
100
+ state='review'
101
+ else:
102
+ user_id=row['user_id']
103
+ try:
104
+ billing.reconcile(user_id)
105
+ state='done';processed+=1;visited.add(user_id)
106
+ except HTTPException:
107
+ state='pending';failed+=1
108
+ with billing.vault.transaction() as db:
109
+ db.execute("UPDATE saas_billing_events SET state=?,next_attempt=? WHERE account_id=? AND mode=? AND event_id=? AND state='pending'",(state,billing.clock()+300,*key))
110
+ if state in {'done','ignored'}:
111
+ billing.vault.delete(db,'saas-stripe-event',':'.join(key))
112
+ if budget:billing.budget=budget
113
+ for user_id in due:
114
+ if budget and budget.expired():break
115
+ if user_id in visited:continue
116
+ try:
117
+ billing.reconcile(user_id)
118
+ except HTTPException:
119
+ failed+=1
120
+ with billing.vault.transaction() as db:
121
+ db.execute('UPDATE saas_billing SET sync_due=? WHERE user_id=? AND lease_until<=?',
122
+ (billing.clock()+300,user_id,billing.clock()))
123
+ with billing.vault.transaction() as db:
124
+ event_due=db.execute("SELECT count(*) FROM saas_billing_events WHERE state='pending' AND next_attempt<=?",(billing.clock(),)).fetchone()[0]
125
+ customer_due=db.execute("SELECT count(*) FROM saas_billing b JOIN saas_users u ON u.id=b.user_id WHERE b.customer_id IS NOT NULL AND b.sync_due<=? AND u.deleting=0",(billing.clock(),)).fetchone()[0]
126
+ return {'processed':processed,'retry_pending':failed,'due':event_due+customer_due,
127
+ 'budget_exhausted':bool(budget and budget.expired())}
@@ -0,0 +1,27 @@
1
+ """Account billing actions use the verified browser identity, never body IDs."""
2
+ import asyncio
3
+ from fastapi import Request
4
+ from fastapi.responses import RedirectResponse
5
+ from socialctl.saas.routes import read_form
6
+
7
+
8
+ def install_billing_routes(app,billing):
9
+ app.state.saas_billing=billing
10
+
11
+ async def action(request,method):
12
+ data=await read_form(request,billing.origin)
13
+ app.state.saas_valid_form(request,data,'saas-account')
14
+ user=await asyncio.to_thread(app.state.saas_sessions.require_user,request)
15
+ url=await asyncio.to_thread(method,user.id)
16
+ response=RedirectResponse(url,status_code=303)
17
+ # These POSTs only redirect to validated Stripe HTTPS destinations.
18
+ response.headers['Content-Security-Policy']="default-src 'none'; form-action 'self' https://checkout.stripe.com https://billing.stripe.com; frame-ancestors 'none'; base-uri 'none'"
19
+ return response
20
+
21
+ @app.post('/account/billing/checkout')
22
+ async def checkout(request:Request):
23
+ return await action(request,billing.checkout)
24
+
25
+ @app.post('/account/billing/portal')
26
+ async def portal(request:Request):
27
+ return await action(request,billing.portal)
@@ -0,0 +1,66 @@
1
+ """Cooperative maintenance deadlines; the process manager is the hard guard."""
2
+ from contextlib import contextmanager
3
+ import math
4
+ import time
5
+
6
+
7
+ class BudgetExpired(Exception):
8
+ pass
9
+
10
+
11
+ class WorkBudget:
12
+ def __init__(self, seconds, *, clock=time.monotonic):
13
+ if not math.isfinite(seconds) or seconds < 0:
14
+ raise ValueError('Invalid maintenance budget.')
15
+ self.clock = clock
16
+ self.deadline = clock() + seconds
17
+
18
+ def remaining(self):
19
+ return max(0.0, self.deadline - self.clock())
20
+
21
+ def expired(self):
22
+ return self.remaining() <= 0
23
+
24
+ def timeout(self, maximum):
25
+ remaining = self.remaining()
26
+ if remaining <= 0:
27
+ raise BudgetExpired()
28
+ return min(maximum, remaining)
29
+
30
+ def portion(self, fraction):
31
+ return WorkBudget(self.remaining() * fraction, clock=self.clock)
32
+
33
+
34
+ @contextmanager
35
+ def using_budget(budget, *adapters):
36
+ """Only maintenance-owned synchronous adapters are temporarily constrained."""
37
+ previous = []
38
+ try:
39
+ if budget is not None:
40
+ for adapter in adapters:
41
+ if adapter is not None:
42
+ previous.append((adapter, getattr(adapter, 'budget', None)))
43
+ adapter.budget = budget
44
+ yield
45
+ finally:
46
+ for adapter, original in reversed(previous):
47
+ adapter.budget = original
48
+
49
+
50
+ @contextmanager
51
+ def hard_deadline(seconds=230):
52
+ """One-shot worker only: end the actual container process, not docker exec.
53
+
54
+ Crash-safe durable leases recover on the next run. An SMTP DATA operation
55
+ interrupted here stays uncertain and must never be replayed automatically.
56
+ """
57
+ import os
58
+ import threading
59
+ timer=threading.Timer(seconds,lambda:os._exit(124))
60
+ timer.daemon=True
61
+ timer.start()
62
+ try:
63
+ yield
64
+ finally:
65
+ timer.cancel()
66
+ timer.join()
@@ -0,0 +1,374 @@
1
+ """Owner-bound OAuth, browser account selection and recoverable local activation."""
2
+ import asyncio
3
+ import hmac
4
+ import json
5
+ import secrets
6
+ from html import escape
7
+ from fastapi import HTTPException,Request
8
+ from fastapi.responses import HTMLResponse,RedirectResponse
9
+ from pydantic import BaseModel,ConfigDict,Field
10
+ from socialctl.saas import pages
11
+ from socialctl.saas.auth import digest
12
+ from socialctl.saas.pairing import proof,INSTALLATION_SECONDS
13
+ from socialctl.saas.routes import read_form
14
+ from socialctl.saas.access import AccessControl
15
+ from socialctl.saas import renewal, permissions
16
+ from socialctl.connection_service.providers import RefreshFailure
17
+
18
+ OAUTH_SECONDS=600
19
+ INACTIVITY_SECONDS=90*86400
20
+
21
+
22
+ class AttachRequest(BaseModel):
23
+ model_config=ConfigDict(extra='forbid')
24
+ platform:str=Field(pattern='^(youtube|facebook|instagram|tiktok)$')
25
+ brand:str=Field(min_length=1,max_length=80)
26
+
27
+
28
+ class OAuthRequest(AttachRequest):
29
+ poll_challenge:str=Field(pattern='^[0-9a-f]{64}$')
30
+ management:bool=False
31
+ analytics:bool=False
32
+
33
+
34
+ def safe_account(account):
35
+ return {k:account[k] for k in ('id','name','page_id') if k in account}
36
+
37
+
38
+ class Connections:
39
+ def __init__(self,metadata,installations,providers,clock):
40
+ self.metadata,self.vault,self.installations,self.providers,self.clock=metadata,metadata.vault,installations,providers,clock
41
+ self.access=AccessControl(metadata)
42
+
43
+ def owner(self,db,installation_id):
44
+ installation=self.vault.get(db,'saas-installation',installation_id)
45
+ if installation is None or installation['expires']<=self.clock():
46
+ raise HTTPException(401,'installation_revoked: Sign in to SocialCli again.')
47
+ user_id=installation['payload']['user_id']
48
+ user=self.metadata.user(db,user_id)
49
+ if user is None or user['deleting']:
50
+ raise HTTPException(401,'account_unavailable: Sign in to SocialCli again.')
51
+ return user_id
52
+
53
+ def require_platform(self,db,installation_id,platform):
54
+ user_id=self.owner(db,installation_id)
55
+ installation=self.vault.get(db,'saas-installation',installation_id)
56
+ from socialctl.saas import consent
57
+ consent.require_privacy(self.metadata,db,user_id)
58
+ consent.require_assistant(installation['payload'])
59
+ permissions.require(installation['payload'],platform)
60
+ if platform=='youtube' and db.execute("SELECT 1 FROM saas_google_revocations WHERE user_id=? AND state='pending' LIMIT 1",(installation['payload']['user_id'],)).fetchone():
61
+ raise HTTPException(409,'google_revocation_pending: Google disconnect is still finishing. Try connecting again after cleanup completes.')
62
+
63
+ def allowed(self,db,user_id,*,new=False):
64
+ decision=self.access.decision(db,user_id,self.clock())
65
+ if not decision.allowed and not (new and decision.reason=='trial_not_started'):
66
+ raise HTTPException(402,'subscription_required: Open your SocialCli account to manage access.')
67
+ return decision
68
+
69
+ def brand(self,db,user_id,label):
70
+ brand=db.execute('SELECT * FROM saas_brands WHERE user_id=?',(user_id,)).fetchone()
71
+ if brand and brand['label']!=label:
72
+ raise HTTPException(409,'brand_limit: This subscription already has a brand. Use its existing name.')
73
+ return brand
74
+
75
+ def authorization(self,db,auth_id,request=None,user=None):
76
+ record=self.vault.get(db,'authorization',auth_id)
77
+ if record is None or record['expires']<=self.clock() or 'saas' not in record['payload']:
78
+ raise HTTPException(404,'authorization_expired: Request a new connection link.')
79
+ if request is not None and not hmac.compare_digest(record['credential_hash'],proof(request)):
80
+ raise HTTPException(404,'Authorization unavailable.')
81
+ owner=self.owner(db,record['payload']['saas']['installation_id'])
82
+ self.require_platform(db,record['payload']['saas']['installation_id'],record['payload']['platform'])
83
+ if owner!=record['payload']['saas']['user_id'] or user is not None and owner!=user.id:
84
+ raise HTTPException(403,'This connection belongs to another account.')
85
+ return record
86
+
87
+ def candidate(self,db,installation_id,user_id,platform,label,account,*,grant=None,existing=None,expected=None):
88
+ self.require_platform(db,installation_id,platform)
89
+ connection_id,secret=secrets.token_urlsafe(24),secrets.token_urlsafe(32)
90
+ data={'installation_id':installation_id,'user_id':user_id,'platform':platform,'brand':label,
91
+ 'account':safe_account(account),'expected_grant':expected,'existing_grant':existing}
92
+ if grant is not None: data['grant']=grant
93
+ self.vault.put(db,'saas-candidate',connection_id,data,expires=self.clock()+OAUTH_SECONDS,credential_hash=digest(secret))
94
+ return {'version':1,'connection_id':connection_id,'connection_secret':secret,'platform':platform,
95
+ 'account':data['account'],'granted_scopes':grant['granted_scopes'] if grant else [],'status':'selected'}
96
+
97
+ def capability(self,db,connection_id,request,*,require_slot=True,require_access=True):
98
+ record=self.vault.get(db,'saas-capability',connection_id)
99
+ if record is None:
100
+ candidate=self.vault.get(db,'saas-candidate',connection_id)
101
+ if candidate and candidate['expires']>self.clock() and hmac.compare_digest(candidate['credential_hash'],proof(request)):
102
+ raise HTTPException(409,'activation_required: Finish installing this connection.')
103
+ raise HTTPException(404,'connection_unavailable: Check your SocialCli account.')
104
+ if record['expires']<=self.clock() or not hmac.compare_digest(record['credential_hash'],proof(request)):
105
+ raise HTTPException(404,'connection_unavailable: Check your SocialCli account.')
106
+ data=record['payload']
107
+ if self.owner(db,data['installation_id'])!=data['user_id']:
108
+ raise HTTPException(403,'Connection ownership mismatch.')
109
+ if request.method!='DELETE':
110
+ from socialctl.saas import consent
111
+ consent.require_privacy(self.metadata,db,data['user_id'])
112
+ installation=self.vault.get(db,'saas-installation',data['installation_id'])
113
+ consent.require_assistant(installation['payload'])
114
+ if require_access:self.require_platform(db,data['installation_id'],data['platform'])
115
+ slot=db.execute('SELECT * FROM saas_slots WHERE user_id=? AND platform=?',(data['user_id'],data['platform'])).fetchone()
116
+ if require_slot and (slot is None or slot['grant_id']!=data['grant_id']):
117
+ raise HTTPException(409,'connection_replaced: Select the current connection in your SocialCli account.')
118
+ return record
119
+
120
+ def activate(self,connection_id,request):
121
+ with self.vault.transaction() as db:
122
+ active=self.vault.get(db,'saas-capability',connection_id)
123
+ if active:
124
+ self.capability(db,connection_id,request)
125
+ return {'version':1,'status':'active'}
126
+ candidate=self.vault.get(db,'saas-candidate',connection_id)
127
+ if candidate is None or candidate['expires']<=self.clock() or not hmac.compare_digest(candidate['credential_hash'],proof(request)):
128
+ raise HTTPException(404,'Connection activation expired.')
129
+ data=candidate['payload'];user_id=self.owner(db,data['installation_id'])
130
+ self.require_platform(db,data['installation_id'],data['platform'])
131
+ if user_id!=data['user_id']: raise HTTPException(403,'Connection ownership mismatch.')
132
+ self.allowed(db,user_id,new=True)
133
+ brand=self.brand(db,user_id,data['brand'])
134
+ current=db.execute('SELECT * FROM saas_slots WHERE user_id=? AND platform=?',(user_id,data['platform'])).fetchone()
135
+ current_id=current['grant_id'] if current else None
136
+ if current_id!=data['expected_grant']:
137
+ raise HTTPException(409,'connection_changed: Another connection changed. Review the current account before retrying.')
138
+ if data['platform'] in {'facebook','instagram'}:
139
+ other='instagram' if data['platform']=='facebook' else 'facebook'
140
+ linked=db.execute('SELECT grant_id FROM saas_slots WHERE user_id=? AND platform=?',(user_id,other)).fetchone()
141
+ if linked:
142
+ other_grant=self.vault.get(db,'saas-grant',linked['grant_id'])
143
+ if other_grant is None or other_grant['expires']<=self.clock():
144
+ db.execute('DELETE FROM saas_slots WHERE user_id=? AND platform=?',(user_id,other))
145
+ else:
146
+ wanted=data['account'].get('page_id') if data['platform']=='instagram' else data['account']['id']
147
+ have=other_grant['payload']['account'].get('page_id') if other=='instagram' else other_grant['payload']['account']['id']
148
+ if wanted!=have: raise HTTPException(409,'page_mismatch: Facebook and Instagram must share the same Page.')
149
+ if data.get('legacy_id'):
150
+ from socialctl.saas.migration import legacy_record
151
+ old=legacy_record(self,db,data['legacy_id'],data['legacy_hash'],data['platform'])
152
+ if old['payload']['account']!=data['account']:
153
+ raise HTTPException(409,'connection_changed: Review the current connection.')
154
+ data['grant']=old['payload']['grant']
155
+ if data['existing_grant']:
156
+ grant_id=data['existing_grant']
157
+ existing=self.vault.get(db,'saas-grant',grant_id)
158
+ if existing is None or existing['expires']<=self.clock():
159
+ raise HTTPException(409,'connection_inactive: This SocialCli connection expired after inactivity.')
160
+ else:
161
+ grant_id=secrets.token_urlsafe(24)
162
+ self.vault.put(db,'saas-grant',grant_id,{'user_id':user_id,'platform':data['platform'],
163
+ 'account':data['account'],'grant':data['grant']},expires=self.clock()+INACTIVITY_SECONDS)
164
+ if brand is None:
165
+ db.execute('INSERT INTO saas_brands VALUES(?,?,?)',(user_id,secrets.token_urlsafe(24),data['brand']))
166
+ self.access.activate_trial(db,user_id,self.clock())
167
+ self.allowed(db,user_id)
168
+ db.execute('INSERT OR REPLACE INTO saas_slots VALUES(?,?,?)',(user_id,data['platform'],grant_id))
169
+ self.vault.put(db,'saas-capability',connection_id,{'user_id':user_id,'installation_id':data['installation_id'],
170
+ 'platform':data['platform'],'grant_id':grant_id,'account':data['account']},
171
+ expires=self.clock()+INSTALLATION_SECONDS,credential_hash=candidate['credential_hash'])
172
+ self.vault.delete(db,'saas-candidate',connection_id)
173
+ if data.get('legacy_id'):
174
+ self.vault.delete(db,'connection',data['legacy_id'])
175
+ db.execute("DELETE FROM legacy_allowance WHERE kind='connection' AND id=?",(data['legacy_id'],))
176
+ if current_id and current_id!=grant_id:
177
+ self.vault.delete(db,'saas-grant',current_id)
178
+ return {'version':1,'status':'active'}
179
+
180
+
181
+ def install_connection_routes(app,settings,metadata,sessions,providers,clock):
182
+ vault=metadata.vault
183
+ connections=Connections(metadata,app.state.saas_installations,providers,clock)
184
+ app.state.saas_connections=connections
185
+ from socialctl.saas.meta_user import install_meta_user_route
186
+ install_meta_user_route(app, connections, clock)
187
+ csrf,valid_form=app.state.saas_csrf,app.state.saas_valid_form
188
+
189
+ @app.post('/api/saas/v1/installations/{installation_id}/authorizations',status_code=201)
190
+ def start(installation_id:str,body:OAuthRequest,request:Request):
191
+ if body.platform not in settings.providers:
192
+ raise HTTPException(503,'platform_unavailable: This platform is not configured.')
193
+ try:
194
+ providers.scopes(body.platform,management=body.management,analytics=body.analytics)
195
+ except ValueError:
196
+ raise HTTPException(400,'These permissions are not available for this platform.') from None
197
+ auth_id=secrets.token_urlsafe(24)
198
+ with vault.transaction() as db:
199
+ installation=connections.installations.require(db,installation_id,request)
200
+ connections.require_platform(db,installation_id,body.platform)
201
+ user_id=installation['payload']['user_id']
202
+ connections.allowed(db,user_id,new=True);connections.brand(db,user_id,body.brand)
203
+ if not metadata.admit(db,'oauth-installation:'+installation_id,clock(),limit=5):
204
+ raise HTTPException(429,'Too many connection attempts. Wait a minute.')
205
+ current=db.execute('SELECT grant_id FROM saas_slots WHERE user_id=? AND platform=?',(user_id,body.platform)).fetchone()
206
+ data={'platform':body.platform,'status':'pending','management':body.management,'analytics':body.analytics,
207
+ 'saas':{'user_id':user_id,'installation_id':installation_id,'brand':body.brand,'expected_grant':current['grant_id'] if current else None}}
208
+ vault.put(db,'authorization',auth_id,data,expires=clock()+OAUTH_SECONDS,credential_hash=body.poll_challenge)
209
+ return {'version':1,'authorization_id':auth_id,'browser_url':settings.public_url+'/connect/'+auth_id,
210
+ 'expires_in':OAUTH_SECONDS,'poll_interval':2}
211
+
212
+ @app.post('/api/saas/v1/installations/{installation_id}/attach',status_code=201)
213
+ def attach(installation_id:str,body:AttachRequest,request:Request):
214
+ with vault.transaction() as db:
215
+ installation=connections.installations.require(db,installation_id,request)
216
+ connections.require_platform(db,installation_id,body.platform)
217
+ user_id=installation['payload']['user_id'];connections.allowed(db,user_id);connections.brand(db,user_id,body.brand)
218
+ slot=db.execute('SELECT grant_id FROM saas_slots WHERE user_id=? AND platform=?',(user_id,body.platform)).fetchone()
219
+ grant=vault.get(db,'saas-grant',slot['grant_id']) if slot else None
220
+ if grant is None or grant['expires']<=clock():
221
+ raise HTTPException(404,'No current connection is available for this platform.')
222
+ result=connections.candidate(db,installation_id,user_id,body.platform,body.brand,
223
+ grant['payload']['account'],existing=slot['grant_id'],expected=slot['grant_id'])
224
+ result['granted_scopes']=grant['payload']['grant']['granted_scopes']
225
+ return result
226
+
227
+ @app.get('/account/connect/{auth_id}',response_class=HTMLResponse)
228
+ def choose(auth_id:str,request:Request):
229
+ user=sessions.require_user(request)
230
+ with vault.transaction() as db:
231
+ record=connections.authorization(db,auth_id,user=user);data=record['payload']
232
+ if data['status']!='ready':
233
+ raise HTTPException(409,'This account selection is unavailable. Return to your assistant.')
234
+ choices=''
235
+ for index,account in enumerate(data['result']['accounts']):
236
+ choices+=f'<label for="choice-{index}"><input id="choice-{index}" name="account_id" type="radio" value="{escape(account["id"],quote=True)}" required> {escape(account.get("name",account["id"]))} <small>({escape(account["id"])})</small></label>'
237
+ replacement='<p class="status-notice">The existing connection stays active until your assistant finishes installing this replacement.</p>' if data['saas']['expected_grant'] else ''
238
+ from socialctl.connection_service.landing import PROVIDERS
239
+ name = PROVIDERS[data['platform']][0]
240
+ return HTMLResponse(pages.page('Choose your account · ' + name,f'''<p class="eyebrow">REVIEW YOUR CONNECTION</p><h1 id="page-title">Choose your {name} account</h1>
241
+ <p class="description">Connect one {name} account to {escape(data['saas']['brand'])}. Only choose an account you own or manage.</p>{replacement}
242
+ <form method="post" action="/account/connect/{escape(auth_id,quote=True)}" class="form-stack">{pages.hidden('csrf',csrf(request,'select:'+auth_id))}{choices}<button class="primary-button" type="submit">Confirm this account</button></form><p class="expiry-note">Confirming does not publish content.</p>''',platform=data['platform']))
243
+
244
+ @app.post('/account/connect/{auth_id}',response_class=HTMLResponse)
245
+ async def select(auth_id:str,request:Request):
246
+ form=await read_form(request,settings.public_url);valid_form(request,form,'select:'+auth_id)
247
+ user=await asyncio.to_thread(sessions.require_user,request)
248
+ def commit():
249
+ with vault.transaction() as db:
250
+ record=connections.authorization(db,auth_id,user=user);data=record['payload']
251
+ if data['status']!='ready': raise HTTPException(409,'This account selection has already been used.')
252
+ connections.allowed(db,user.id,new=True)
253
+ account=next((a for a in data['result']['accounts'] if a['id']==form.get('account_id')),None)
254
+ if account is None: raise HTTPException(400,'Choose an account authorized by the provider.')
255
+ grant=json.loads(json.dumps(data['result']['grant']))
256
+ if data['platform']=='instagram' and account.get('access_token'):
257
+ # Broader User credentials remain encrypted server-side.
258
+ # Ordinary publishing still receives the selected Page token.
259
+ grant['meta_user_grant']=json.loads(json.dumps(data['result']['grant']))
260
+ if account.get('access_token'): grant['access_token']=account['access_token']
261
+ owner=data['saas']
262
+ result=connections.candidate(db,owner['installation_id'],user.id,data['platform'],owner['brand'],account,
263
+ grant=grant,expected=owner['expected_grant'])
264
+ data.pop('result',None);data['selection']=result;data['status']='selected'
265
+ vault.put(db,'authorization',auth_id,data,expires=record['expires'],credential_hash=record['credential_hash'])
266
+ return data['platform']
267
+ platform = await asyncio.to_thread(commit)
268
+ from socialctl.connection_service.landing import PROVIDERS
269
+ name = PROVIDERS[platform][0]
270
+ return HTMLResponse(pages.page('Account confirmed · ' + name,f'<p class="eyebrow">{name.upper()} ACCOUNT CONFIRMED</p><h1 id="page-title">Return to your assistant</h1><p class="description">You can close this page and continue with your assistant.</p><p class="button-note">Keep the SocialCli command running while it finishes connecting your {name} account. No content was published.</p><a class="secondary-button" href="/account/">My Account</a>',platform=platform))
271
+
272
+ @app.post('/api/saas/v1/authorizations/{auth_id}/poll')
273
+ def poll(auth_id:str,request:Request):
274
+ with vault.read_transaction() as db:
275
+ data=connections.authorization(db,auth_id,request)['payload']
276
+ return data['selection'] if data['status']=='selected' else {'version':1,'status':data['status']}
277
+
278
+ @app.delete('/api/saas/v1/authorizations/{auth_id}')
279
+ def cancel(auth_id:str,request:Request):
280
+ with vault.transaction() as db:
281
+ data=connections.authorization(db,auth_id,request)['payload']
282
+ if data.get('selection'): vault.delete(db,'saas-candidate',data['selection']['connection_id'])
283
+ vault.delete(db,'authorization',auth_id)
284
+ return {'version':1,'status':'cancelled'}
285
+
286
+ @app.post('/api/saas/v1/connections/{connection_id}/activate')
287
+ def activate(connection_id:str,request:Request):
288
+ return connections.activate(connection_id,request)
289
+
290
+ @app.get('/api/saas/v1/connections/{connection_id}')
291
+ def status(connection_id:str,request:Request):
292
+ with vault.read_transaction() as db:
293
+ cap=connections.capability(db,connection_id,request,require_access=False);data=cap['payload']
294
+ grant=vault.get(db,'saas-grant',data['grant_id'])
295
+ if grant is None or grant['expires']<=clock():
296
+ raise HTTPException(409,'connection_inactive: This SocialCli connection expired after inactivity.')
297
+ decision=connections.access.decision(db,data['user_id'],clock())
298
+ renewal_state=renewal.state(grant['payload'],clock())
299
+ return {'version':1,'platform':data['platform'],'account':data['account'],
300
+ 'expires_at':grant['payload']['grant']['expires_at'],'granted_scopes':grant['payload']['grant']['granted_scopes'],
301
+ 'connection_expires_at':cap['expires'],'needs_reconnect':renewal_state=='reconnect_required',
302
+ 'renewal_status':renewal_state,
303
+ 'data_cleanup_required':renewal_state=='reconnect_required',
304
+ 'access':{'allowed':decision.allowed,'reason':decision.reason,'access_until':decision.access_until}}
305
+
306
+ @app.delete('/api/saas/v1/connections/{connection_id}')
307
+ def disconnect(connection_id:str,request:Request):
308
+ with vault.transaction() as db:
309
+ record=connections.capability(db,connection_id,request,require_slot=False,require_access=False);data=record['payload']
310
+ job=app.state.saas_lifecycle.disconnect_grant(db,data['user_id'],data['platform'],data['grant_id'])
311
+ from socialctl.saas import revocation
312
+ if job:
313
+ from socialctl.saas.budget import WorkBudget
314
+ revocation.run_revocations(metadata,providers,clock,user_id=data['user_id'],budget=WorkBudget(20))
315
+ state=revocation.status(metadata,job)
316
+ return {'version':1,'status':'disconnected','provider_authorization_revoked':state=='complete',
317
+ 'provider_revocation_status':state}
318
+
319
+ @app.post('/api/saas/v1/connections/{connection_id}/token')
320
+ def token(connection_id:str,request:Request,refresh:bool=True):
321
+ lease=None
322
+ with vault.transaction() as db:
323
+ cap=connections.capability(db,connection_id,request);data=cap['payload']
324
+ connections.allowed(db,data['user_id'])
325
+ record=vault.get(db,'saas-grant',data['grant_id'])
326
+ if record is None or record['expires']<=clock():
327
+ raise HTTPException(409,'connection_inactive: This SocialCli connection expired after inactivity.')
328
+ if data['platform'] not in settings.providers: raise HTTPException(503,'platform_unavailable: Try again later.')
329
+ grant=record['payload']['grant']
330
+ renewal.check(record['payload'],clock(),refresh)
331
+ if grant['expires_at']<=clock()+60:
332
+ lease=secrets.token_urlsafe(24)
333
+ renewal.clear(record['payload'])
334
+ record['payload']['refresh_lease']=lease
335
+ record['payload']['refresh_started_at']=clock()
336
+ vault.put(db,'saas-grant',data['grant_id'],record['payload'],expires=record['expires'])
337
+ if lease:
338
+ try:
339
+ renewed=providers.refresh(data['platform'],grant)
340
+ except Exception as error:
341
+ code=error.code if isinstance(error,RefreshFailure) else 'renewal_uncertain'
342
+ with vault.transaction() as db:
343
+ current=vault.get(db,'saas-grant',data['grant_id'])
344
+ if current is None or current['payload'].get('refresh_lease')!=lease:
345
+ raise HTTPException(409,'connection_changed: Review the current connection.') from None
346
+ renewal.record_failure(current['payload'],code,clock())
347
+ vault.put(db,'saas-grant',data['grant_id'],current['payload'],expires=current['expires'])
348
+ renewal.reject(code)
349
+ with vault.transaction() as db:
350
+ current=vault.get(db,'saas-grant',data['grant_id'])
351
+ changed=current is None or current['payload'].get('refresh_lease')!=lease
352
+ if changed:
353
+ # The old queued token may already be revoked or invalidated
354
+ # by rotation. Preserve a late token under a separate job.
355
+ revoked=data['platform']=='youtube' and db.execute(
356
+ "SELECT 1 FROM saas_deletion_tombstones WHERE (kind='user' AND id=?) OR (kind='grant' AND id=?)",
357
+ (data['user_id'],data['grant_id'])).fetchone()
358
+ if revoked:
359
+ from socialctl.saas import revocation
360
+ revocation.enqueue(metadata,db,data['grant_id']+':refresh:'+lease,
361
+ {'user_id':data['user_id'],'platform':'youtube','grant':renewed},clock())
362
+ else:
363
+ renewal.clear(current['payload']);current['payload']['grant']=renewed
364
+ vault.put(db,'saas-grant',data['grant_id'],current['payload'],expires=current['expires'])
365
+ grant=renewed
366
+ if changed:
367
+ raise HTTPException(409,'connection_changed: Review the current connection.')
368
+ with vault.transaction() as db:
369
+ connections.capability(db,connection_id,request);connections.allowed(db,data['user_id'])
370
+ current=vault.get(db,'saas-grant',data['grant_id'])
371
+ if current is None: raise HTTPException(409,'connection_changed: Review the current connection.')
372
+ vault.put(db,'saas-grant',data['grant_id'],current['payload'],expires=clock()+INACTIVITY_SECONDS)
373
+ return {'version':1,'platform':data['platform'],'account':data['account'],'access_token':grant['access_token'],
374
+ 'expires_at':grant['expires_at'],'granted_scopes':grant['granted_scopes']}