basyx commited on
Commit
e1104b3
·
verified ·
1 Parent(s): c91c7db

Upload 236 files

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .env.example +55 -0
  2. README.md +168 -0
  3. app/api/social.py +298 -0
  4. app/container.py +69 -4
  5. app/core/config.py +117 -8
  6. app/core/logger.py +53 -6
  7. app/mcp/server.py +4 -0
  8. app/mcp/tools/social.py +216 -0
  9. app/security/middleware.py +36 -0
  10. app/security/policy.py +37 -1
  11. app/security/rate_limit.py +25 -0
  12. app/security/scopes.py +28 -0
  13. app/social/__init__.py +5 -0
  14. app/social/database.py +90 -0
  15. app/social/domain/__init__.py +12 -0
  16. app/social/domain/capabilities.py +46 -0
  17. app/social/domain/enums.py +68 -0
  18. app/social/domain/errors.py +93 -0
  19. app/social/domain/models.py +24 -0
  20. app/social/domain/retry.py +22 -0
  21. app/social/domain/state_machine.py +26 -0
  22. app/social/migrations/0001_social_foundation_postgres.sql +257 -0
  23. app/social/migrations/0002_social_rls.sql +90 -0
  24. app/social/migrations/0003_social_integrity_postgres.sql +187 -0
  25. app/social/migrations/0004_youtube_media_assets.sql +28 -0
  26. app/social/models.py +343 -0
  27. app/social/oauth/__init__.py +4 -0
  28. app/social/oauth/base.py +19 -0
  29. app/social/oauth/encryption.py +39 -0
  30. app/social/oauth/state.py +56 -0
  31. app/social/providers/__init__.py +3 -0
  32. app/social/providers/base.py +91 -0
  33. app/social/providers/facebook.py +111 -0
  34. app/social/providers/instagram.py +108 -0
  35. app/social/providers/linkedin.py +15 -0
  36. app/social/providers/meta.py +3 -0
  37. app/social/providers/meta_graph.py +115 -0
  38. app/social/providers/oauth.py +39 -0
  39. app/social/providers/registry.py +43 -0
  40. app/social/providers/telegram.py +13 -0
  41. app/social/providers/tiktok.py +1131 -0
  42. app/social/providers/whatsapp.py +13 -0
  43. app/social/providers/x.py +15 -0
  44. app/social/providers/youtube.py +630 -0
  45. app/social/repositories/__init__.py +6 -0
  46. app/social/repositories/accounts.py +91 -0
  47. app/social/repositories/assets.py +59 -0
  48. app/social/repositories/jobs.py +287 -0
  49. app/social/repositories/posts.py +310 -0
  50. app/social/repositories/tokens.py +90 -0
.env.example CHANGED
@@ -30,3 +30,58 @@ AUTH_DEFAULT_PROCESSING_BYTES_PER_DAY=107374182400
30
  AUTH_TRUST_PROXY_HEADERS=true
31
  # Required only when running the standalone stdio MCP transport with auth enabled.
32
  MCP_STDIO_API_KEY=
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  AUTH_TRUST_PROXY_HEADERS=true
31
  # Required only when running the standalone stdio MCP transport with auth enabled.
32
  MCP_STDIO_API_KEY=
33
+
34
+ # Social Automation Foundation. Apply all SQL files under app/social/migrations/
35
+ # to Supabase/Postgres before enabling social writes in production. The existing
36
+ # DATABASE_URL remains a local-development fallback only.
37
+ SOCIAL_ENABLED=true
38
+ # Example: postgresql+asyncpg://postgres:password@db.example:5432/postgres
39
+ SOCIAL_DATABASE_URL=
40
+ SOCIAL_AUTO_MIGRATE=false
41
+ SOCIAL_WORKER_ENABLED=true
42
+ SOCIAL_SCHEDULER_INTERVAL_SECONDS=30
43
+ SOCIAL_JOB_STALE_AFTER_SECONDS=900
44
+ SOCIAL_PUBLISH_RETRY_LIMIT=5
45
+ SOCIAL_OAUTH_REQUESTS_PER_HOUR=30
46
+ SOCIAL_PUBLISH_REQUESTS_PER_MINUTE=30
47
+ SOCIAL_SCHEDULE_REQUESTS_PER_MINUTE=60
48
+ SOCIAL_ANALYTICS_REQUESTS_PER_MINUTE=120
49
+ # Required for OAuth state/PKCE and encrypted local token fallback. Generate at
50
+ # least 32 random bytes, for example: openssl rand -base64 32
51
+ SOCIAL_OAUTH_ENCRYPTION_KEY=
52
+ SUPABASE_URL=
53
+ SUPABASE_SERVICE_ROLE_KEY=
54
+ SUPABASE_VAULT_ENABLED=false
55
+ SOCIAL_OAUTH_REDIRECT_BASE_URL=
56
+ GOOGLE_CLIENT_ID=
57
+ GOOGLE_CLIENT_SECRET=
58
+ # YouTube Data API v3 worker controls. Keep the client secret backend-only.
59
+ # Upload chunks must be a multiple of 262144 bytes (256 KiB).
60
+ YOUTUBE_UPLOAD_CHUNK_BYTES=8388608
61
+ YOUTUBE_MAX_CONCURRENT_UPLOADS=2
62
+ YOUTUBE_REQUEST_TIMEOUT_SECONDS=60
63
+ YOUTUBE_PROCESSING_POLL_SECONDS=30
64
+ META_CLIENT_ID=
65
+ META_CLIENT_SECRET=
66
+ TIKTOK_CLIENT_KEY=
67
+ TIKTOK_CLIENT_SECRET=
68
+ # Exact backend callback registered under TikTok Login Kit. It must be:
69
+ # https://<api-host>/v1/social/accounts/tiktok/callback
70
+ TIKTOK_REDIRECT_URI=
71
+ # Fail closed. Set true only after Content Posting API Direct Post approval and
72
+ # video.publish authorization are confirmed for this TikTok application.
73
+ TIKTOK_DIRECT_POST_ENABLED=false
74
+ # TikTok FILE_UPLOAD chunks: 5,000,000 through 64,000,000 bytes.
75
+ TIKTOK_UPLOAD_CHUNK_BYTES=10000000
76
+ TIKTOK_REQUEST_TIMEOUT_SECONDS=60
77
+ TIKTOK_PROCESSING_POLL_SECONDS=30
78
+ # Test-only opt-in. Normal CI must keep this false; dedicated credentials and
79
+ # explicit publish consent are documented in social-tiktok-production-readiness.md.
80
+ RUN_TIKTOK_INTEGRATION_TESTS=false
81
+ LINKEDIN_CLIENT_ID=
82
+ LINKEDIN_CLIENT_SECRET=
83
+ X_CLIENT_ID=
84
+ X_CLIENT_SECRET=
85
+ TELEGRAM_BOT_TOKEN=
86
+ WHATSAPP_CLIENT_ID=
87
+ WHATSAPP_CLIENT_SECRET=
README.md CHANGED
@@ -124,6 +124,101 @@ Use one Uvicorn process in a CPU Space. FFmpeg and Whisper concurrency is manage
124
 
125
  For durable keys, audit records, and rate aggregates, attach Hugging Face persistent storage and set `DATABASE_URL=sqlite+aiosqlite:////data/mediarouter.db`. The default `./data/mediarouter.db` is appropriate locally but follows the Space filesystem lifecycle. The security layer uses SQLAlchemy so a future external database migration does not change authentication contracts; this release includes and supports the `aiosqlite` driver.
126
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
  ## Authentication and authorization
128
 
129
  MediaRouter uses stateless opaque API keys. There are no passwords, login sessions, cookies, or JWTs. Except for the public endpoints below, every REST and MCP request must send:
@@ -1084,6 +1179,53 @@ When the upstream binary property is `data`, use an expression body:
1084
 
1085
  For large files, prefer n8n's multipart binary option or raw binary body; JSON Base64 temporarily expands data by roughly 33%.
1086
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1087
  ## Configuration
1088
 
1089
  | Variable | Default | Purpose |
@@ -1118,6 +1260,32 @@ For large files, prefer n8n's multipart binary option or raw binary body; JSON B
1118
  | `AUTH_DEFAULT_PROCESSING_BYTES_PER_DAY` | `107374182400` | Default daily uploaded processing bytes |
1119
  | `AUTH_TRUST_PROXY_HEADERS` | `true` | Use first `X-Forwarded-For` address for audits behind HF/Vercel |
1120
  | `MCP_STDIO_API_KEY` | empty | Existing API key required by authenticated standalone stdio MCP |
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1121
 
1122
  ## Logging and safety
1123
 
 
124
 
125
  For durable keys, audit records, and rate aggregates, attach Hugging Face persistent storage and set `DATABASE_URL=sqlite+aiosqlite:////data/mediarouter.db`. The default `./data/mediarouter.db` is appropriate locally but follows the Space filesystem lifecycle. The security layer uses SQLAlchemy so a future external database migration does not change authentication contracts; this release includes and supports the `aiosqlite` driver.
126
 
127
+ ## Connect the Vercel frontend to Hugging Face
128
+
129
+ The Next.js frontend is an authenticated backend-for-frontend (BFF): browser requests go to its same-origin `/api/backend/*` route, and only that server-side route attaches a backend API key. The browser must never receive an API key. All FFmpeg, Whisper, yt-dlp, uploads, and media processing remain in the Hugging Face Space.
130
+
131
+ For the current hosted backend, the public origin is:
132
+
133
+ ```text
134
+ https://basyx-mediarouter.hf.space
135
+ ```
136
+
137
+ First verify that the Space has started successfully. This endpoint is public and must return HTTP `200` with `"status": "healthy"` before the frontend can connect:
138
+
139
+ ```bash
140
+ curl -i https://basyx-mediarouter.hf.space/health
141
+ ```
142
+
143
+ ### Hugging Face Space secrets
144
+
145
+ Create the bootstrap administrator locally, save its plaintext `API_KEY` in a password manager, and add **only** the three generated `AUTH_BOOTSTRAP_*` values to **Hugging Face Space → Settings → Secrets**. Enter each value in the value field only: no quotes, no `NAME=`, and no line breaks. Never commit these values.
146
+
147
+ ```text
148
+ AUTH_BOOTSTRAP_KEY_HASH=<64-character lowercase SHA-256 hash>
149
+ AUTH_BOOTSTRAP_KEY_PREFIX=mp_live_<first-eight-secret-characters>
150
+ AUTH_BOOTSTRAP_ENVIRONMENT=live
151
+ ```
152
+
153
+ The bootstrap fields are optional after the first successful start. If a Space fails at startup with `Bootstrap key hash or prefix is malformed`, remove duplicate or stale `AUTH_BOOTSTRAP_*` entries from both the Space **Variables** and **Secrets** panels, restart once, then recreate the three exact values above as Secrets. A blank hash and blank prefix deliberately disable bootstrap creation; a non-empty value must match the format exactly.
154
+
155
+ Recommended backend deployment settings are:
156
+
157
+ ```env
158
+ BASE_URL=https://basyx-mediarouter.hf.space
159
+ DATABASE_URL=sqlite+aiosqlite:////data/mediarouter.db
160
+ AUTH_ROLE_SCOPES={}
161
+ MCP_STDIO_API_KEY=<a-valid-mp_live-or-mp_test-key-if-stdio-MCP-is-enabled>
162
+ ```
163
+
164
+ `DATABASE_URL` uses `/data` so keys, audit logs, and rate-limit state survive only when Hugging Face persistent storage is attached. `BASE_URL` is the public Hugging Face origin, not the Vercel frontend URL.
165
+
166
+ ### Vercel environment variables
167
+
168
+ Import this repository with `frontend` as the Vercel root directory. Configure the following values for **Production** and for **Preview** if preview deployments need to connect to the backend. Redeploy after changing an environment variable.
169
+
170
+ ```env
171
+ # Server-only backend connection and credential. Never use NEXT_PUBLIC_ for a key.
172
+ MEDIAROUTER_API_URL=https://basyx-mediarouter.hf.space
173
+ MEDIAROUTER_MCP_URL=https://basyx-mediarouter.hf.space
174
+ MEDIAROUTER_API_TOKEN=mp_live_<saved-bootstrap-admin-key-or-role-key>
175
+ MEDIAROUTER_API_TIMEOUT=120000
176
+
177
+ # Auth.js / OAuth (server only)
178
+ AUTH_SECRET=<output-of-openssl-rand-base64-32>
179
+ NEXTAUTH_URL=https://<your-vercel-project>.vercel.app
180
+ AUTH_GITHUB_ID=<github-oauth-client-id>
181
+ AUTH_GITHUB_SECRET=<github-oauth-client-secret>
182
+ # Optional: configure both values to enable Google sign-in.
183
+ AUTH_GOOGLE_ID=
184
+ AUTH_GOOGLE_SECRET=
185
+ AUTH_SESSION_MAX_AGE=28800
186
+
187
+ # Human-role mapping. Put your own OAuth email in the Admin list.
188
+ AUTH_USER_ROLES={}
189
+ AUTH_ADMIN_EMAILS=<your-verified-oauth-email>
190
+ AUTH_DEVELOPER_EMAILS=
191
+ AUTH_OPERATOR_EMAILS=
192
+ AUTH_VIEWER_EMAILS=
193
+ AUTH_DEFAULT_ROLE=Viewer
194
+
195
+ # Public, non-secret display and capability settings.
196
+ NEXT_PUBLIC_API_URL=https://basyx-mediarouter.hf.space
197
+ NEXT_PUBLIC_MCP_URL=https://basyx-mediarouter.hf.space
198
+ NEXT_PUBLIC_APP_NAME=MediaRouter
199
+ NEXT_PUBLIC_ENABLE_MCP=true
200
+ NEXT_PUBLIC_ENABLE_MARKETPLACE=true
201
+ ```
202
+
203
+ `MEDIAROUTER_API_TOKEN` is the server-only fallback for every human role. In a least-privilege production setup, create dedicated backend role keys after bootstrap and replace it with the appropriate keys instead:
204
+
205
+ ```env
206
+ MEDIAROUTER_ADMIN_API_TOKEN=mp_live_<admin-key>
207
+ MEDIAROUTER_DEVELOPER_API_TOKEN=mp_live_<developer-key>
208
+ MEDIAROUTER_OPERATOR_API_TOKEN=mp_live_<operator-key>
209
+ MEDIAROUTER_VIEWER_API_TOKEN=mp_live_<viewer-key>
210
+ ```
211
+
212
+ GitHub’s production callback URL is `https://<your-vercel-project>.vercel.app/api/auth/callback/github`; Google’s is the equivalent `/api/auth/callback/google`. Generate `AUTH_SECRET` with `openssl rand -base64 32`. Never put API keys, OAuth client secrets, or `AUTH_SECRET` in a `NEXT_PUBLIC_*` variable.
213
+
214
+ ### Verify the complete connection
215
+
216
+ 1. Confirm `https://basyx-mediarouter.hf.space/health` returns `200`.
217
+ 2. Redeploy Vercel after its environment variables are set.
218
+ 3. Sign in using an OAuth account assigned to `AUTH_ADMIN_EMAILS`.
219
+ 4. Visit the frontend `/api/backend/health` while signed in. It should proxy the healthy backend response.
220
+ 5. If it returns `Backend authentication unavailable`, set `MEDIAROUTER_API_TOKEN` or the token matching the signed-in user’s role. If it returns `401`, the backend key is expired, disabled, revoked, or not the key represented by the Space bootstrap hash.
221
+
222
  ## Authentication and authorization
223
 
224
  MediaRouter uses stateless opaque API keys. There are no passwords, login sessions, cookies, or JWTs. Except for the public endpoints below, every REST and MCP request must send:
 
1179
 
1180
  For large files, prefer n8n's multipart binary option or raw binary body; JSON Base64 temporarily expands data by roughly 33%.
1181
 
1182
+ ## Social Automation: YouTube Phase 2
1183
+
1184
+ MediaRouter now publishes YouTube videos through the shared `SocialService → SocialPublisher → YouTubeProvider` pipeline. REST, frontend, MCP, TypeScript/Python SDKs, and the n8n Social node use the same typed post, durable job, OAuth/token, validation, retry, and status-reconciliation implementation; none contains Google publishing logic or receives Google credentials.
1185
+
1186
+ YouTube is implemented with the official YouTube Data API v3. It supports OAuth + PKCE, stable channel discovery, encrypted TokenService credentials, registered MediaRouter video outputs, typed YouTube metadata including the required audience declaration, resumable chunked upload, crash-safe external-video reconciliation, status polling, deletion, and supported video statistics. `GET /v1/social/providers` reports YouTube as implemented and reports the other requested providers as registered/unimplemented. Apply all social migrations in numeric order through `0004_youtube_media_assets.sql` before enabling production writes.
1187
+
1188
+ Google setup, exact redirect URI, scope, output registration, metadata, scheduling, retries, quotas, analytics limits, and troubleshooting are documented in [docs/social-youtube.md](docs/social-youtube.md). The implementation report and live-test status are in [docs/social-youtube-production-readiness.md](docs/social-youtube-production-readiness.md). No staging credentials were supplied, so live Google verification is **NOT VERIFIED**.
1189
+
1190
+ ## Social Automation: TikTok Phase 4
1191
+
1192
+ TikTok Direct Post video publishing now uses the same SocialService, durable
1193
+ SocialJob, TokenService, scheduler, retry, idempotency, and media-variant
1194
+ pipeline. It calls only the official TikTok Content Posting API: creator info,
1195
+ video initialization, sequential `FILE_UPLOAD` chunks, and publish-status
1196
+ reconciliation. TikTok Direct Post is fail-closed behind
1197
+ `TIKTOK_DIRECT_POST_ENABLED=false` and requires an explicit OAuth reconnect with
1198
+ the approved `video.publish` scope. MediaRouter scheduling is supported;
1199
+ TikTok-native scheduling and deletion are not advertised. See
1200
+ [docs/social-tiktok-publishing.md](docs/social-tiktok-publishing.md) for
1201
+ approval, media requirements, metadata, retries, idempotency, and integration
1202
+ usage. Live TikTok verification requires a dedicated approved app and test
1203
+ creator and is not part of normal CI.
1204
+
1205
+ Phase 4C adds explicit `video.list` authorization and official Display API
1206
+ video analytics, tenant/security hardening, credential redaction, optional live
1207
+ integration coverage, and a classified readiness audit. See
1208
+ [docs/social-tiktok-production-readiness.md](docs/social-tiktok-production-readiness.md).
1209
+ Live TikTok, Postgres RLS, and Docker verification remain environment-dependent
1210
+ and must not be inferred from configuration alone.
1211
+
1212
+ ### Social quick discovery
1213
+
1214
+ ```bash
1215
+ curl -H "Authorization: Bearer $MEDIAROUTER_API_KEY" \
1216
+ https://your-space.hf.space/v1/social/providers
1217
+ ```
1218
+
1219
+ Register a completed MediaRouter output, then create a typed YouTube draft with a one-time idempotency key:
1220
+
1221
+ ```bash
1222
+ curl -X POST https://your-space.hf.space/v1/social/posts \
1223
+ -H "Authorization: Bearer $MEDIAROUTER_API_KEY" \
1224
+ -H "Idempotency-Key: $(openssl rand -hex 16)" \
1225
+ -H "Content-Type: application/json" \
1226
+ -d '{"media_asset_id":"<registered_social_asset_id>","publish_mode":"draft","targets":[{"social_account_id":"<youtube_account_id>","youtube":{"title":"Example","description":"YouTube copy","privacy_status":"private","made_for_kids":false}}]}'
1227
+ ```
1228
+
1229
  ## Configuration
1230
 
1231
  | Variable | Default | Purpose |
 
1260
  | `AUTH_DEFAULT_PROCESSING_BYTES_PER_DAY` | `107374182400` | Default daily uploaded processing bytes |
1261
  | `AUTH_TRUST_PROXY_HEADERS` | `true` | Use first `X-Forwarded-For` address for audits behind HF/Vercel |
1262
  | `MCP_STDIO_API_KEY` | empty | Existing API key required by authenticated standalone stdio MCP |
1263
+ | `SOCIAL_ENABLED` | `true` | Enable the additive social domain; existing media APIs remain independent |
1264
+ | `SOCIAL_DATABASE_URL` | `DATABASE_URL` | Async SQLAlchemy URL; use Supabase/Postgres in production |
1265
+ | `SOCIAL_AUTO_MIGRATE` | `false` | Local/test metadata creation only; never use for production migration management |
1266
+ | `SOCIAL_WORKER_ENABLED` | `true` | Run durable scheduler/publisher claim loop when schema is ready |
1267
+ | `SOCIAL_SCHEDULER_INTERVAL_SECONDS` | `30` | Scheduler polling interval |
1268
+ | `SOCIAL_JOB_STALE_AFTER_SECONDS` | `900` | Recover an interrupted active job after this worker-lease period |
1269
+ | `SOCIAL_PUBLISH_RETRY_LIMIT` | `5` | Maximum provider publishing attempts |
1270
+ | `SOCIAL_OAUTH_ENCRYPTION_KEY` | empty | Required secret for PKCE state and local encrypted token fallback |
1271
+ | `SOCIAL_OAUTH_REDIRECT_BASE_URL` | empty | Public backend origin used to build exact provider callback URLs |
1272
+ | `SUPABASE_VAULT_ENABLED` | `false` | Store provider tokens as Supabase Vault secret references |
1273
+ | `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` | empty | Backend-only YouTube OAuth application credentials |
1274
+ | `YOUTUBE_UPLOAD_CHUNK_BYTES` | `8388608` | Resumable upload chunk size; must be a multiple of 256 KiB |
1275
+ | `YOUTUBE_MAX_CONCURRENT_UPLOADS` | `2` | Conservative per-process YouTube upload concurrency |
1276
+ | `YOUTUBE_REQUEST_TIMEOUT_SECONDS` | `60` | Per-request YouTube Data API timeout |
1277
+ | `YOUTUBE_PROCESSING_POLL_SECONDS` | `30` | Delay between server-side processing reconciliation polls |
1278
+ | `META_CLIENT_ID`, `META_CLIENT_SECRET` | empty | Backend-only Facebook/Instagram OAuth application credentials |
1279
+ | `TIKTOK_CLIENT_KEY`, `TIKTOK_CLIENT_SECRET` | empty | Backend-only TikTok Login Kit application credentials |
1280
+ | `TIKTOK_REDIRECT_URI` | empty | Exact backend-owned TikTok callback registered in Login Kit (`https://<api-host>/v1/social/accounts/tiktok/callback`) |
1281
+ | `TIKTOK_DIRECT_POST_ENABLED` | `false` | Fail-closed Direct Post gate; enable only after TikTok Content Posting approval |
1282
+ | `TIKTOK_UPLOAD_CHUNK_BYTES` | `10000000` | Sequential FILE_UPLOAD chunk target (5–64 MB; final chunk may be up to 128 MB) |
1283
+ | `TIKTOK_REQUEST_TIMEOUT_SECONDS` | `60` | TikTok OAuth, Content Posting, and upload request timeout |
1284
+ | `TIKTOK_PROCESSING_POLL_SECONDS` | `30` | Delay between official publish-status reconciliation polls |
1285
+ | `LINKEDIN_CLIENT_ID`, `LINKEDIN_CLIENT_SECRET` | empty | Backend-only LinkedIn OAuth credentials |
1286
+ | `X_CLIENT_ID`, `X_CLIENT_SECRET` | empty | Backend-only X OAuth credentials |
1287
+ | `TELEGRAM_BOT_TOKEN` | empty | Backend-only Telegram bot credential foundation |
1288
+ | `WHATSAPP_CLIENT_ID`, `WHATSAPP_CLIENT_SECRET` | empty | Backend-only WhatsApp Business credentials |
1289
 
1290
  ## Logging and safety
1291
 
app/api/social.py ADDED
@@ -0,0 +1,298 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Annotated
4
+
5
+ from fastapi import APIRouter, Header, Query, Request, Response, status
6
+
7
+ from app.social.schemas.accounts import (
8
+ SocialAccountConnectRequest,
9
+ SocialAccountView,
10
+ SocialConnectResponse,
11
+ SocialPublishOptionsView,
12
+ SocialProviderView,
13
+ )
14
+ from app.social.schemas.assets import (
15
+ SocialMediaAssetRegister,
16
+ SocialMediaAssetView,
17
+ )
18
+ from app.social.schemas.jobs import SocialJobView
19
+ from app.social.schemas.posts import SocialPostCreate, SocialPostView
20
+ from app.social.schemas.scheduling import SocialScheduleCreate, SocialScheduleView
21
+
22
+ router = APIRouter(prefix="/v1/social", tags=["social automation"])
23
+
24
+
25
+ def _social(request: Request):
26
+ service = request.app.state.container.social
27
+ service.ensure_ready()
28
+ return service
29
+
30
+
31
+ def _identity(request: Request) -> tuple[str, str]:
32
+ context = request.state.auth
33
+ # Phase 1 isolation: the authenticated API-key identity is the tenant
34
+ # boundary until the backend gains a native workspace membership model.
35
+ return context.api_key_id, context.api_key_id
36
+
37
+
38
+ async def _audit(
39
+ request: Request,
40
+ event_type: str,
41
+ *,
42
+ provider: str | None = None,
43
+ account_id: str | None = None,
44
+ post_id: str | None = None,
45
+ job_id: str | None = None,
46
+ ) -> None:
47
+ workspace_id, user_id = _identity(request)
48
+ await request.app.state.container.social.audit.record(
49
+ workspace_id=workspace_id,
50
+ event_type=event_type,
51
+ api_key_id=user_id,
52
+ request_id=request.state.request_id,
53
+ provider=provider,
54
+ social_account_id=account_id,
55
+ social_post_id=post_id,
56
+ social_job_id=job_id,
57
+ )
58
+
59
+
60
+ @router.get("/providers", response_model=list[SocialProviderView])
61
+ async def list_providers(request: Request) -> list[SocialProviderView]:
62
+ return request.app.state.container.social.accounts.list_providers()
63
+
64
+
65
+ @router.get(
66
+ "/providers/{provider}/capabilities", response_model=SocialProviderView
67
+ )
68
+ async def provider_capabilities(request: Request, provider: str) -> SocialProviderView:
69
+ return request.app.state.container.social.accounts.get_provider(provider)
70
+
71
+
72
+ @router.get("/assets", response_model=list[SocialMediaAssetView])
73
+ async def list_social_media_assets(
74
+ request: Request,
75
+ offset: int = Query(default=0, ge=0),
76
+ limit: int = Query(default=100, ge=1, le=500),
77
+ ) -> list[SocialMediaAssetView]:
78
+ workspace_id, _ = _identity(request)
79
+ return await _social(request).media_assets.list(workspace_id, offset=offset, limit=limit)
80
+
81
+
82
+ @router.post("/assets", response_model=SocialMediaAssetView, status_code=status.HTTP_201_CREATED)
83
+ async def register_social_media_asset(
84
+ request: Request, payload: SocialMediaAssetRegister
85
+ ) -> SocialMediaAssetView:
86
+ workspace_id, _ = _identity(request)
87
+ return await _social(request).media_assets.register(workspace_id, payload)
88
+
89
+
90
+ @router.get("/accounts", response_model=list[SocialAccountView])
91
+ async def list_accounts(
92
+ request: Request,
93
+ offset: int = Query(default=0, ge=0),
94
+ limit: int = Query(default=100, ge=1, le=500),
95
+ ) -> list[SocialAccountView]:
96
+ workspace_id, _ = _identity(request)
97
+ return await _social(request).accounts.list(
98
+ workspace_id, offset=offset, limit=limit
99
+ )
100
+
101
+
102
+ @router.get("/accounts/{account_id}", response_model=SocialAccountView)
103
+ async def get_account(request: Request, account_id: str) -> SocialAccountView:
104
+ workspace_id, _ = _identity(request)
105
+ return await _social(request).accounts.get(workspace_id, account_id)
106
+
107
+
108
+ @router.get(
109
+ "/accounts/{account_id}/publish-options",
110
+ response_model=SocialPublishOptionsView,
111
+ )
112
+ async def get_account_publish_options(
113
+ request: Request, account_id: str
114
+ ) -> SocialPublishOptionsView:
115
+ workspace_id, _ = _identity(request)
116
+ return await _social(request).publishing.publish_options(
117
+ workspace_id, account_id
118
+ )
119
+
120
+
121
+ @router.post(
122
+ "/accounts/{provider}/connect", response_model=SocialConnectResponse
123
+ )
124
+ async def connect_account(
125
+ request: Request, provider: str, payload: SocialAccountConnectRequest
126
+ ) -> SocialConnectResponse:
127
+ workspace_id, user_id = _identity(request)
128
+ result = await _social(request).oauth.connect(
129
+ provider=provider,
130
+ workspace_id=workspace_id,
131
+ user_id=user_id,
132
+ payload=payload,
133
+ )
134
+ await _audit(request, "SOCIAL_ACCOUNT_CONNECTION_STARTED", provider=provider)
135
+ return result
136
+
137
+
138
+ @router.get(
139
+ "/accounts/{provider}/callback",
140
+ response_model=SocialAccountView,
141
+ include_in_schema=True,
142
+ )
143
+ async def oauth_callback(
144
+ request: Request,
145
+ provider: str,
146
+ state: Annotated[
147
+ str, Query(min_length=32, max_length=255, pattern=r"^[A-Za-z0-9_-]+$")
148
+ ],
149
+ code: Annotated[str | None, Query(min_length=1, max_length=4096)] = None,
150
+ error: Annotated[str | None, Query(max_length=128)] = None,
151
+ ) -> SocialAccountView:
152
+ # This provider-facing route is authenticated by a short-lived, single-use
153
+ # state record. Tenant/user identifiers are never accepted from the query.
154
+ social = request.app.state.container.social
155
+ social.ensure_ready()
156
+ if error or not code:
157
+ await social.oauth.callback_denied(provider=provider, state=state)
158
+ raise AssertionError("OAuth callback denial should raise a social error")
159
+ return await social.oauth.callback(provider=provider, state=state, code=code)
160
+
161
+
162
+ @router.post("/accounts/{account_id}/refresh", response_model=SocialAccountView)
163
+ async def refresh_account(request: Request, account_id: str) -> SocialAccountView:
164
+ workspace_id, _ = _identity(request)
165
+ result = await _social(request).oauth.refresh(
166
+ workspace_id=workspace_id, account_id=account_id
167
+ )
168
+ await _audit(request, "SOCIAL_ACCOUNT_REAUTHORIZED", account_id=account_id)
169
+ return result
170
+
171
+
172
+ @router.delete("/accounts/{account_id}", status_code=status.HTTP_204_NO_CONTENT)
173
+ async def disconnect_account(request: Request, account_id: str) -> Response:
174
+ workspace_id, _ = _identity(request)
175
+ account = await _social(request).accounts.get(workspace_id, account_id)
176
+ await request.app.state.container.social.accounts.disconnect(workspace_id, account_id)
177
+ await _audit(
178
+ request,
179
+ "SOCIAL_ACCOUNT_DISCONNECTED",
180
+ provider=account.provider.value,
181
+ account_id=account_id,
182
+ )
183
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
184
+
185
+
186
+ @router.post(
187
+ "/posts", response_model=SocialPostView, status_code=status.HTTP_201_CREATED
188
+ )
189
+ async def create_post(
190
+ request: Request,
191
+ payload: SocialPostCreate,
192
+ idempotency_key: str | None = Header(
193
+ default=None, alias="Idempotency-Key", min_length=8, max_length=255
194
+ ),
195
+ ) -> SocialPostView:
196
+ workspace_id, user_id = _identity(request)
197
+ result = await _social(request).publishing.create(
198
+ workspace_id=workspace_id,
199
+ user_id=user_id,
200
+ payload=payload,
201
+ idempotency_key=idempotency_key,
202
+ )
203
+ await _audit(request, "SOCIAL_POST_CREATED", post_id=result.id)
204
+ return result
205
+
206
+
207
+ @router.get("/posts", response_model=list[SocialPostView])
208
+ async def list_posts(
209
+ request: Request,
210
+ offset: int = Query(default=0, ge=0),
211
+ limit: int = Query(default=100, ge=1, le=500),
212
+ ) -> list[SocialPostView]:
213
+ workspace_id, _ = _identity(request)
214
+ return await _social(request).publishing.list(
215
+ workspace_id, offset=offset, limit=limit
216
+ )
217
+
218
+
219
+ @router.get("/posts/{post_id}", response_model=SocialPostView)
220
+ async def get_post(request: Request, post_id: str) -> SocialPostView:
221
+ workspace_id, _ = _identity(request)
222
+ return await _social(request).publishing.get(workspace_id, post_id)
223
+
224
+
225
+ @router.delete("/posts/{post_id}", status_code=status.HTTP_204_NO_CONTENT)
226
+ async def delete_post(request: Request, post_id: str) -> Response:
227
+ workspace_id, _ = _identity(request)
228
+ await _social(request).publishing.delete(workspace_id, post_id)
229
+ await _audit(request, "SOCIAL_POST_DELETED", post_id=post_id)
230
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
231
+
232
+
233
+ @router.post(
234
+ "/posts/{post_id}/publish",
235
+ response_model=list[SocialJobView],
236
+ status_code=status.HTTP_202_ACCEPTED,
237
+ )
238
+ async def publish_post(
239
+ request: Request,
240
+ post_id: str,
241
+ idempotency_key: str = Header(alias="Idempotency-Key", min_length=8, max_length=255),
242
+ ) -> list[SocialJobView]:
243
+ workspace_id, _ = _identity(request)
244
+ jobs = await _social(request).publishing.queue(
245
+ workspace_id, post_id, idempotency_key=idempotency_key
246
+ )
247
+ return jobs
248
+
249
+
250
+ @router.post("/posts/{post_id}/schedule", response_model=SocialScheduleView)
251
+ async def schedule_post(
252
+ request: Request, post_id: str, payload: SocialScheduleCreate
253
+ ) -> SocialScheduleView:
254
+ workspace_id, _ = _identity(request)
255
+ await _social(request).publishing.validate_post_targets(workspace_id, post_id)
256
+ schedule = await _social(request).scheduling.schedule(
257
+ workspace_id, post_id, payload
258
+ )
259
+ await _audit(request, "SOCIAL_SCHEDULE_CREATED", post_id=post_id)
260
+ return schedule
261
+
262
+
263
+ @router.post("/posts/{post_id}/cancel", response_model=SocialPostView)
264
+ async def cancel_post(request: Request, post_id: str) -> SocialPostView:
265
+ workspace_id, _ = _identity(request)
266
+ result = await _social(request).publishing.cancel(workspace_id, post_id)
267
+ await _audit(request, "SOCIAL_POST_CANCELLED", post_id=post_id)
268
+ return result
269
+
270
+
271
+ @router.get("/jobs", response_model=list[SocialJobView])
272
+ async def list_jobs(
273
+ request: Request,
274
+ offset: int = Query(default=0, ge=0),
275
+ limit: int = Query(default=100, ge=1, le=500),
276
+ ) -> list[SocialJobView]:
277
+ workspace_id, _ = _identity(request)
278
+ return await _social(request).jobs.list(
279
+ workspace_id, offset=offset, limit=limit
280
+ )
281
+
282
+
283
+ @router.get("/jobs/{job_id}", response_model=SocialJobView)
284
+ async def get_job(request: Request, job_id: str) -> SocialJobView:
285
+ workspace_id, _ = _identity(request)
286
+ return await _social(request).jobs.get(workspace_id, job_id)
287
+
288
+
289
+ @router.get("/accounts/{account_id}/analytics")
290
+ async def account_analytics(request: Request, account_id: str) -> dict[str, object]:
291
+ workspace_id, _ = _identity(request)
292
+ return await _social(request).analytics.account(workspace_id, account_id)
293
+
294
+
295
+ @router.get("/posts/{post_id}/analytics")
296
+ async def post_analytics(request: Request, post_id: str) -> dict[str, object]:
297
+ workspace_id, _ = _identity(request)
298
+ return await _social(request).analytics.post(workspace_id, post_id)
app/container.py CHANGED
@@ -3,6 +3,10 @@ from __future__ import annotations
3
  from dataclasses import dataclass
4
 
5
  from app.core.config import Settings
 
 
 
 
6
  from app.services.cleanup import CleanupService
7
  from app.services.downloader import Downloader
8
  from app.services.ffmpeg_service import FFmpegService
@@ -12,10 +16,25 @@ from app.services.media_service import MediaProcessor
12
  from app.services.validator import MediaValidator
13
  from app.services.whisper_service import WhisperService
14
  from app.services.ytdlp_service import YTDLPService
15
- from app.security.audit import AuditService
16
- from app.security.database import SecurityDatabase
17
- from app.security.rate_limit import APIKeyRateLimiter
18
- from app.security.service import APIKeyService
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  from app.templates.executor import OperationExecutor, TemplateExecutor
20
  from app.templates.loader import TemplateLoader
21
  from app.templates.registry import TemplateRegistry
@@ -40,6 +59,7 @@ class Container:
40
  api_keys: APIKeyService
41
  rate_limiter: APIKeyRateLimiter
42
  audit: AuditService
 
43
 
44
 
45
  def build_container(settings: Settings) -> Container:
@@ -47,6 +67,8 @@ def build_container(settings: Settings) -> Container:
47
  api_keys = APIKeyService(security_database, settings)
48
  rate_limiter = APIKeyRateLimiter(security_database)
49
  audit = AuditService(security_database)
 
 
50
  cleanup = CleanupService(settings)
51
  validator = MediaValidator(settings)
52
  downloader = Downloader(settings, validator)
@@ -54,6 +76,48 @@ def build_container(settings: Settings) -> Container:
54
  ffmpeg = FFmpegService(settings)
55
  ffprobe = FFprobeService(settings)
56
  whisper = WhisperService(settings)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  resolver = InputResolver(settings, cleanup, downloader, ytdlp, validator)
58
  processor = MediaProcessor(
59
  settings, resolver, cleanup, validator, ffmpeg, ffprobe, ytdlp, whisper
@@ -81,4 +145,5 @@ def build_container(settings: Settings) -> Container:
81
  api_keys=api_keys,
82
  rate_limiter=rate_limiter,
83
  audit=audit,
 
84
  )
 
3
  from dataclasses import dataclass
4
 
5
  from app.core.config import Settings
6
+ from app.security.audit import AuditService
7
+ from app.security.database import SecurityDatabase
8
+ from app.security.rate_limit import APIKeyRateLimiter
9
+ from app.security.service import APIKeyService
10
  from app.services.cleanup import CleanupService
11
  from app.services.downloader import Downloader
12
  from app.services.ffmpeg_service import FFmpegService
 
16
  from app.services.validator import MediaValidator
17
  from app.services.whisper_service import WhisperService
18
  from app.services.ytdlp_service import YTDLPService
19
+ from app.social.database import SocialDatabase
20
+ from app.social.oauth.encryption import TokenCipher
21
+ from app.social.oauth.state import OAuthStateService
22
+ from app.social.providers.registry import build_provider_registry
23
+ from app.social.repositories.accounts import AccountRepository
24
+ from app.social.repositories.assets import SocialMediaAssetRepository
25
+ from app.social.repositories.jobs import JobRepository
26
+ from app.social.repositories.posts import PostRepository
27
+ from app.social.repositories.tokens import TokenRepository
28
+ from app.social.services.account_service import AccountService
29
+ from app.social.services.analytics_service import AnalyticsService
30
+ from app.social.services.audit_service import SocialAuditService
31
+ from app.social.services.job_service import JobService
32
+ from app.social.services.media_asset_service import SocialMediaAssetService
33
+ from app.social.services.oauth_service import OAuthService
34
+ from app.social.services.publishing_service import PublishingService
35
+ from app.social.services.scheduling_service import SchedulingService
36
+ from app.social.services.social_service import SocialService
37
+ from app.social.services.token_service import TokenService
38
  from app.templates.executor import OperationExecutor, TemplateExecutor
39
  from app.templates.loader import TemplateLoader
40
  from app.templates.registry import TemplateRegistry
 
59
  api_keys: APIKeyService
60
  rate_limiter: APIKeyRateLimiter
61
  audit: AuditService
62
+ social: SocialService
63
 
64
 
65
  def build_container(settings: Settings) -> Container:
 
67
  api_keys = APIKeyService(security_database, settings)
68
  rate_limiter = APIKeyRateLimiter(security_database)
69
  audit = AuditService(security_database)
70
+ # Social publishing validates the same file-backed outputs as the normal
71
+ # media pipeline, so build those shared services before wiring Social.
72
  cleanup = CleanupService(settings)
73
  validator = MediaValidator(settings)
74
  downloader = Downloader(settings, validator)
 
76
  ffmpeg = FFmpegService(settings)
77
  ffprobe = FFprobeService(settings)
78
  whisper = WhisperService(settings)
79
+ social_database = SocialDatabase(settings)
80
+ providers = build_provider_registry(settings)
81
+ account_repository = AccountRepository(social_database)
82
+ post_repository = PostRepository(social_database)
83
+ token_cipher = TokenCipher(
84
+ settings.social_oauth_encryption_key.get_secret_value()
85
+ if settings.social_oauth_encryption_key
86
+ else None
87
+ )
88
+ job_repository = JobRepository(social_database, token_cipher)
89
+ token_service = TokenService(
90
+ TokenRepository(social_database),
91
+ settings,
92
+ token_cipher,
93
+ )
94
+ account_service = AccountService(account_repository, token_service, providers)
95
+ social_audit = SocialAuditService(social_database)
96
+ oauth_service = OAuthService(
97
+ settings,
98
+ providers,
99
+ OAuthStateService(social_database, token_service.cipher),
100
+ account_service,
101
+ social_audit,
102
+ )
103
+ social_media_assets = SocialMediaAssetService(
104
+ SocialMediaAssetRepository(social_database), cleanup, ffprobe, validator
105
+ )
106
+ social = SocialService(
107
+ settings=settings,
108
+ database=social_database,
109
+ accounts=account_service,
110
+ oauth=oauth_service,
111
+ publishing=PublishingService(
112
+ settings, post_repository, job_repository, account_repository, providers,
113
+ social_media_assets, oauth_service,
114
+ ),
115
+ scheduling=SchedulingService(post_repository, social_media_assets),
116
+ jobs=JobService(job_repository),
117
+ media_assets=social_media_assets,
118
+ analytics=AnalyticsService(social_database, account_repository, providers, oauth_service),
119
+ audit=social_audit,
120
+ )
121
  resolver = InputResolver(settings, cleanup, downloader, ytdlp, validator)
122
  processor = MediaProcessor(
123
  settings, resolver, cleanup, validator, ffmpeg, ffprobe, ytdlp, whisper
 
145
  api_keys=api_keys,
146
  rate_limiter=rate_limiter,
147
  audit=audit,
148
+ social=social,
149
  )
app/core/config.py CHANGED
@@ -1,7 +1,9 @@
1
  from __future__ import annotations
2
 
 
3
  from functools import lru_cache
4
  from pathlib import Path
 
5
 
6
  from pydantic import Field, SecretStr, field_validator
7
  from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -52,6 +54,59 @@ class Settings(BaseSettings):
52
  )
53
  auth_trust_proxy_headers: bool = True
54
  mcp_stdio_api_key: SecretStr | None = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
 
56
  @field_validator("whisper_model")
57
  @classmethod
@@ -74,14 +129,28 @@ class Settings(BaseSettings):
74
  self.temp_dir.mkdir(parents=True, exist_ok=True)
75
  self.output_dir.mkdir(parents=True, exist_ok=True)
76
  sqlite_prefixes = ("sqlite+aiosqlite:///", "sqlite:///")
77
- for prefix in sqlite_prefixes:
78
- if self.database_url.startswith(prefix):
79
- database_path = self.database_url.removeprefix(prefix)
80
- if database_path and database_path != ":memory:":
81
- Path(database_path).expanduser().resolve().parent.mkdir(
82
- parents=True, exist_ok=True
83
- )
84
- break
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
 
86
  @field_validator("auth_bootstrap_environment")
87
  @classmethod
@@ -91,6 +160,46 @@ class Settings(BaseSettings):
91
  raise ValueError("AUTH_BOOTSTRAP_ENVIRONMENT must be live or test")
92
  return normalized
93
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
 
95
  @lru_cache
96
  def get_settings() -> Settings:
 
1
  from __future__ import annotations
2
 
3
+ import re
4
  from functools import lru_cache
5
  from pathlib import Path
6
+ from urllib.parse import urlparse
7
 
8
  from pydantic import Field, SecretStr, field_validator
9
  from pydantic_settings import BaseSettings, SettingsConfigDict
 
54
  )
55
  auth_trust_proxy_headers: bool = True
56
  mcp_stdio_api_key: SecretStr | None = None
57
+ # Social Automation foundation. The existing database remains the default
58
+ # local store; production Supabase/Postgres deployments should set a
59
+ # dedicated async SQLAlchemy URL and apply the SQL migration out-of-band.
60
+ social_enabled: bool = True
61
+ social_database_url: str = ""
62
+ social_auto_migrate: bool = False
63
+ social_worker_enabled: bool = True
64
+ social_scheduler_interval_seconds: int = Field(default=30, ge=5, le=3600)
65
+ social_job_stale_after_seconds: int = Field(default=900, ge=60, le=86_400)
66
+ social_publish_retry_limit: int = Field(default=5, ge=0, le=20)
67
+ social_oauth_requests_per_hour: int = Field(default=30, ge=1, le=100_000)
68
+ social_publish_requests_per_minute: int = Field(default=30, ge=1, le=100_000)
69
+ social_schedule_requests_per_minute: int = Field(default=60, ge=1, le=100_000)
70
+ social_analytics_requests_per_minute: int = Field(default=120, ge=1, le=100_000)
71
+ social_oauth_encryption_key: SecretStr | None = None
72
+ supabase_url: str = ""
73
+ supabase_service_role_key: SecretStr | None = None
74
+ supabase_vault_enabled: bool = False
75
+ google_client_id: str = ""
76
+ google_client_secret: SecretStr | None = None
77
+ youtube_upload_chunk_bytes: int = Field(default=8 * 1024 * 1024, ge=256 * 1024)
78
+ youtube_max_concurrent_uploads: int = Field(default=2, ge=1, le=32)
79
+ youtube_request_timeout_seconds: float = Field(default=60.0, gt=0, le=600)
80
+ youtube_processing_poll_seconds: int = Field(default=30, ge=5, le=3600)
81
+ meta_client_id: str = ""
82
+ meta_client_secret: SecretStr | None = None
83
+ # META_APP_* is the public configuration contract. META_CLIENT_* remains
84
+ # supported for deployments created during the social foundation phase.
85
+ meta_app_id: str = ""
86
+ meta_app_secret: SecretStr | None = None
87
+ meta_graph_api_version: str = "v25.0"
88
+ tiktok_client_key: str = ""
89
+ tiktok_client_secret: SecretStr | None = None
90
+ # Exact, backend-owned OAuth callback registered in TikTok Login Kit.
91
+ # This is intentionally separate from the secret and is never a frontend
92
+ # configuration value.
93
+ tiktok_redirect_uri: str = ""
94
+ # Direct Post requires TikTok Content Posting approval and an audited app.
95
+ # Keep it fail-closed until an operator has confirmed that access.
96
+ tiktok_direct_post_enabled: bool = False
97
+ tiktok_upload_chunk_bytes: int = Field(
98
+ default=10_000_000, ge=5_000_000, le=64_000_000
99
+ )
100
+ tiktok_request_timeout_seconds: float = Field(default=60.0, gt=0, le=600)
101
+ tiktok_processing_poll_seconds: int = Field(default=30, ge=5, le=3600)
102
+ linkedin_client_id: str = ""
103
+ linkedin_client_secret: SecretStr | None = None
104
+ x_client_id: str = ""
105
+ x_client_secret: SecretStr | None = None
106
+ telegram_bot_token: SecretStr | None = None
107
+ whatsapp_client_id: str = ""
108
+ whatsapp_client_secret: SecretStr | None = None
109
+ social_oauth_redirect_base_url: str = ""
110
 
111
  @field_validator("whisper_model")
112
  @classmethod
 
129
  self.temp_dir.mkdir(parents=True, exist_ok=True)
130
  self.output_dir.mkdir(parents=True, exist_ok=True)
131
  sqlite_prefixes = ("sqlite+aiosqlite:///", "sqlite:///")
132
+ for url in {self.database_url, self.resolved_social_database_url}:
133
+ for prefix in sqlite_prefixes:
134
+ if url.startswith(prefix):
135
+ database_path = url.removeprefix(prefix)
136
+ if database_path and database_path != ":memory:":
137
+ Path(database_path).expanduser().resolve().parent.mkdir(
138
+ parents=True, exist_ok=True
139
+ )
140
+ break
141
+
142
+ @property
143
+ def resolved_social_database_url(self) -> str:
144
+ """Use an explicit social database when configured, otherwise local DB."""
145
+ return self.social_database_url.strip() or self.database_url
146
+
147
+ @property
148
+ def resolved_meta_app_id(self) -> str:
149
+ return self.meta_app_id.strip() or self.meta_client_id.strip()
150
+
151
+ @property
152
+ def resolved_meta_app_secret(self) -> SecretStr | None:
153
+ return self.meta_app_secret or self.meta_client_secret
154
 
155
  @field_validator("auth_bootstrap_environment")
156
  @classmethod
 
160
  raise ValueError("AUTH_BOOTSTRAP_ENVIRONMENT must be live or test")
161
  return normalized
162
 
163
+ @field_validator("youtube_upload_chunk_bytes")
164
+ @classmethod
165
+ def validate_youtube_chunk_size(cls, value: int) -> int:
166
+ # Google resumable uploads require every non-final chunk to be aligned
167
+ # to 256 KiB. Keeping the constraint at configuration time avoids a
168
+ # late failure after an upload session was already created.
169
+ if value % (256 * 1024):
170
+ raise ValueError("YOUTUBE_UPLOAD_CHUNK_BYTES must be a multiple of 262144")
171
+ return value
172
+
173
+ @field_validator("meta_graph_api_version")
174
+ @classmethod
175
+ def validate_meta_graph_api_version(cls, value: str) -> str:
176
+ normalized = value.strip()
177
+ if not re.fullmatch(r"v[0-9]+\.[0-9]+", normalized):
178
+ raise ValueError("META_GRAPH_API_VERSION must use the form vNN.N")
179
+ return normalized
180
+
181
+ @field_validator("tiktok_redirect_uri")
182
+ @classmethod
183
+ def validate_tiktok_redirect_uri(cls, value: str) -> str:
184
+ normalized = value.strip()
185
+ if not normalized:
186
+ return ""
187
+ parsed = urlparse(normalized)
188
+ local_hosts = {"localhost", "127.0.0.1", "::1"}
189
+ if (
190
+ not parsed.netloc
191
+ or parsed.username
192
+ or parsed.password
193
+ or parsed.query
194
+ or parsed.fragment
195
+ or parsed.path != "/v1/social/accounts/tiktok/callback"
196
+ or (parsed.scheme != "https" and parsed.hostname not in local_hosts)
197
+ ):
198
+ raise ValueError(
199
+ "TIKTOK_REDIRECT_URI must be the HTTPS MediaRouter TikTok callback URI"
200
+ )
201
+ return normalized
202
+
203
 
204
  @lru_cache
205
  def get_settings() -> Settings:
app/core/logger.py CHANGED
@@ -3,6 +3,7 @@ from __future__ import annotations
3
  import contextvars
4
  import json
5
  import logging
 
6
  import sys
7
  from datetime import datetime, timezone
8
  from typing import Any, TextIO
@@ -16,29 +17,75 @@ class JsonFormatter(logging.Formatter):
16
  """One-line structured JSON logs suitable for container log collectors."""
17
 
18
  _standard = set(logging.makeLogRecord({}).__dict__) | {"message", "asctime"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
 
20
  def format(self, record: logging.LogRecord) -> str:
21
  payload: dict[str, Any] = {
22
  "timestamp": datetime.now(timezone.utc).isoformat(),
23
  "level": record.levelname,
24
  "logger": record.name,
25
- "message": record.getMessage(),
26
  "request_id": getattr(record, "request_id", request_id_context.get()),
27
  }
28
  for key, value in record.__dict__.items():
29
  if key not in self._standard and not key.startswith("_"):
30
- payload[key] = self._json_safe(value)
31
  if record.exc_info:
32
- payload["exception"] = self.formatException(record.exc_info)
 
 
33
  return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
34
 
35
- @staticmethod
36
- def _json_safe(value: Any) -> Any:
 
 
 
 
 
 
 
 
 
 
 
37
  try:
38
  json.dumps(value)
39
  return value
40
  except (TypeError, ValueError):
41
- return str(value)
 
 
 
 
 
 
 
 
 
 
 
 
 
42
 
43
 
44
  def configure_logging(stream: TextIO | None = None) -> None:
 
3
  import contextvars
4
  import json
5
  import logging
6
+ import re
7
  import sys
8
  from datetime import datetime, timezone
9
  from typing import Any, TextIO
 
17
  """One-line structured JSON logs suitable for container log collectors."""
18
 
19
  _standard = set(logging.makeLogRecord({}).__dict__) | {"message", "asctime"}
20
+ _sensitive_keys = frozenset(
21
+ {
22
+ "access_token",
23
+ "refresh_token",
24
+ "id_token",
25
+ "authorization",
26
+ "client_secret",
27
+ "secret",
28
+ "password",
29
+ "api_key",
30
+ "credential",
31
+ "cookie",
32
+ }
33
+ )
34
+ _bearer = re.compile(r"(?i)\bbearer\s+[A-Za-z0-9._~+/=-]+")
35
+ _assigned_secret = re.compile(
36
+ r"(?i)(access[_-]?token|refresh[_-]?token|id[_-]?token|client[_-]?secret|"
37
+ r"authorization|api[_-]?key|password|secret|credential)"
38
+ r"([\"']?\s*[:=]\s*[\"']?)([^\"'\s,&}]+)"
39
+ )
40
 
41
  def format(self, record: logging.LogRecord) -> str:
42
  payload: dict[str, Any] = {
43
  "timestamp": datetime.now(timezone.utc).isoformat(),
44
  "level": record.levelname,
45
  "logger": record.name,
46
+ "message": self._redact_text(record.getMessage()),
47
  "request_id": getattr(record, "request_id", request_id_context.get()),
48
  }
49
  for key, value in record.__dict__.items():
50
  if key not in self._standard and not key.startswith("_"):
51
+ payload[key] = self._json_safe(value, key=key)
52
  if record.exc_info:
53
+ payload["exception"] = self._redact_text(
54
+ self.formatException(record.exc_info)
55
+ )
56
  return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
57
 
58
+ @classmethod
59
+ def _json_safe(cls, value: Any, *, key: str | None = None) -> Any:
60
+ if key is not None and cls._is_sensitive_key(key):
61
+ return "[REDACTED]"
62
+ if isinstance(value, dict):
63
+ return {
64
+ str(item_key): cls._json_safe(item, key=str(item_key))
65
+ for item_key, item in value.items()
66
+ }
67
+ if isinstance(value, (list, tuple, set)):
68
+ return [cls._json_safe(item) for item in value]
69
+ if isinstance(value, str):
70
+ return cls._redact_text(value)
71
  try:
72
  json.dumps(value)
73
  return value
74
  except (TypeError, ValueError):
75
+ return cls._redact_text(str(value))
76
+
77
+ @classmethod
78
+ def _is_sensitive_key(cls, key: str) -> bool:
79
+ normalized = key.strip().lower().replace("-", "_")
80
+ return any(part in normalized for part in cls._sensitive_keys)
81
+
82
+ @classmethod
83
+ def _redact_text(cls, value: str) -> str:
84
+ redacted = cls._bearer.sub("Bearer [REDACTED]", value)
85
+ return cls._assigned_secret.sub(
86
+ lambda match: f"{match.group(1)}{match.group(2)}[REDACTED]",
87
+ redacted,
88
+ )
89
 
90
 
91
  def configure_logging(stream: TextIO | None = None) -> None:
app/mcp/server.py CHANGED
@@ -19,6 +19,7 @@ from app.mcp.resources import register_resources
19
  from app.mcp.tools.audio import register_audio_tools
20
  from app.mcp.tools.image import register_image_tools
21
  from app.mcp.tools.probe import register_probe_tools
 
22
  from app.mcp.tools.system import register_system_tools
23
  from app.mcp.tools.templates import register_template_tools
24
  from app.mcp.tools.video import register_video_tools
@@ -56,6 +57,7 @@ def create_mcp_server(container: Container) -> FastMCP[Any]:
56
  register_probe_tools(server, registry)
57
  register_system_tools(server, registry)
58
  register_template_tools(server, registry)
 
59
  register_resources(server, registry)
60
  register_prompts(server)
61
  return server
@@ -70,6 +72,7 @@ async def run_server(transport: Literal["stdio", "streamable-http"] = "stdio") -
70
  server = create_mcp_server(container)
71
  await container.security_database.initialize()
72
  await container.api_keys.ensure_bootstrap_admin()
 
73
  await worker.start()
74
  try:
75
  if transport == "stdio":
@@ -116,6 +119,7 @@ async def run_server(transport: Literal["stdio", "streamable-http"] = "stdio") -
116
  await uvicorn.Server(config).serve()
117
  finally:
118
  await worker.stop()
 
119
  await container.security_database.close()
120
 
121
 
 
19
  from app.mcp.tools.audio import register_audio_tools
20
  from app.mcp.tools.image import register_image_tools
21
  from app.mcp.tools.probe import register_probe_tools
22
+ from app.mcp.tools.social import register_social_tools
23
  from app.mcp.tools.system import register_system_tools
24
  from app.mcp.tools.templates import register_template_tools
25
  from app.mcp.tools.video import register_video_tools
 
57
  register_probe_tools(server, registry)
58
  register_system_tools(server, registry)
59
  register_template_tools(server, registry)
60
+ register_social_tools(server, registry)
61
  register_resources(server, registry)
62
  register_prompts(server)
63
  return server
 
72
  server = create_mcp_server(container)
73
  await container.security_database.initialize()
74
  await container.api_keys.ensure_bootstrap_admin()
75
+ await container.social.initialize()
76
  await worker.start()
77
  try:
78
  if transport == "stdio":
 
119
  await uvicorn.Server(config).serve()
120
  finally:
121
  await worker.stop()
122
+ await container.social.close()
123
  await container.security_database.close()
124
 
125
 
app/mcp/tools/social.py ADDED
@@ -0,0 +1,216 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Literal
4
+
5
+ from mcp.server.fastmcp import FastMCP
6
+
7
+ from app.mcp.registry import MCPRegistry
8
+ from app.security.context import auth_context
9
+ from app.social.schemas.posts import SocialPostCreate
10
+ from app.social.schemas.assets import SocialMediaAssetRegister
11
+ from app.social.schemas.scheduling import SocialScheduleCreate
12
+
13
+
14
+ def register_social_tools(server: FastMCP[Any], registry: MCPRegistry) -> None:
15
+ """Register thin MCP transports over the shared SocialService facade."""
16
+
17
+ def identity() -> str:
18
+ context = auth_context.get()
19
+ if context is None: # _execute normally rejects this first.
20
+ return ""
21
+ return context.api_key_id
22
+
23
+ @server.tool(
24
+ name="social.list_providers",
25
+ description="List registered social providers and their explicit capabilities.",
26
+ )
27
+ async def social_list_providers() -> dict[str, Any]:
28
+ async def action() -> dict[str, Any]:
29
+ items = registry.container.social.accounts.list_providers()
30
+ return {"providers": [item.model_dump(mode="json") for item in items]}
31
+
32
+ return await registry.run_metadata_tool(
33
+ "social.list_providers", action, required_scope="social:accounts:read"
34
+ )
35
+
36
+ @server.tool(
37
+ name="social.get_capabilities",
38
+ description="Get capabilities and implementation status for one provider.",
39
+ )
40
+ async def social_get_capabilities(provider: str) -> dict[str, Any]:
41
+ async def action() -> dict[str, Any]:
42
+ item = registry.container.social.accounts.get_provider(provider)
43
+ return {"provider": item.model_dump(mode="json")}
44
+
45
+ return await registry.run_metadata_tool(
46
+ "social.get_capabilities", action, required_scope="social:accounts:read"
47
+ )
48
+
49
+ @server.tool(
50
+ name="social.list_media_assets",
51
+ description="List workspace-owned MediaRouter outputs registered for social publishing.",
52
+ )
53
+ async def social_list_media_assets(offset: int = 0, limit: int = 100) -> dict[str, Any]:
54
+ async def action() -> dict[str, Any]:
55
+ social = registry.container.social
56
+ social.ensure_ready()
57
+ items = await social.media_assets.list(identity(), offset=offset, limit=limit)
58
+ return {"assets": [item.model_dump(mode="json") for item in items]}
59
+
60
+ return await registry.run_metadata_tool(
61
+ "social.list_media_assets", action, required_scope="assets:read"
62
+ )
63
+
64
+ @server.tool(
65
+ name="social.register_media_asset",
66
+ description="Register an existing MediaRouter output UUID and filename for tenant-scoped social publishing.",
67
+ )
68
+ async def social_register_media_asset(request_id: str, filename: str) -> dict[str, Any]:
69
+ async def action() -> dict[str, Any]:
70
+ social = registry.container.social
71
+ social.ensure_ready()
72
+ item = await social.media_assets.register(
73
+ identity(),
74
+ SocialMediaAssetRegister(request_id=request_id, filename=filename),
75
+ )
76
+ return {"asset": item.model_dump(mode="json")}
77
+
78
+ return await registry.run_metadata_tool(
79
+ "social.register_media_asset", action, required_scope="assets:write"
80
+ )
81
+
82
+ @server.tool(name="social.list_accounts", description="List connected social accounts.")
83
+ async def social_list_accounts(offset: int = 0, limit: int = 100) -> dict[str, Any]:
84
+ async def action() -> dict[str, Any]:
85
+ social = registry.container.social
86
+ social.ensure_ready()
87
+ items = await social.accounts.list(identity(), offset=offset, limit=limit)
88
+ return {"accounts": [item.model_dump(mode="json") for item in items]}
89
+
90
+ return await registry.run_metadata_tool(
91
+ "social.list_accounts", action, required_scope="social:accounts:read"
92
+ )
93
+
94
+ @server.tool(name="social.get_account", description="Get a tenant-owned social account.")
95
+ async def social_get_account(account_id: str) -> dict[str, Any]:
96
+ async def action() -> dict[str, Any]:
97
+ social = registry.container.social
98
+ social.ensure_ready()
99
+ item = await social.accounts.get(identity(), account_id)
100
+ return {"account": item.model_dump(mode="json")}
101
+
102
+ return await registry.run_metadata_tool(
103
+ "social.get_account", action, required_scope="social:accounts:read"
104
+ )
105
+
106
+ @server.tool(name="social.create_post", description="Create a typed multi-target social post.")
107
+ async def social_create_post(
108
+ payload: dict[str, Any], idempotency_key: str | None = None
109
+ ) -> dict[str, Any]:
110
+ async def action() -> dict[str, Any]:
111
+ social = registry.container.social
112
+ social.ensure_ready()
113
+ user_id = identity()
114
+ item = await social.publishing.create(
115
+ workspace_id=user_id,
116
+ user_id=user_id,
117
+ payload=SocialPostCreate.model_validate(payload),
118
+ idempotency_key=idempotency_key,
119
+ )
120
+ await social.audit.record(
121
+ workspace_id=user_id,
122
+ api_key_id=user_id,
123
+ event_type="SOCIAL_POST_CREATED",
124
+ social_post_id=item.id,
125
+ )
126
+ return {"post": item.model_dump(mode="json")}
127
+
128
+ return await registry.run_metadata_tool(
129
+ "social.create_post", action, required_scope="social:posts:write"
130
+ )
131
+
132
+ @server.tool(name="social.publish_post", description="Queue an idempotent social post publication.")
133
+ async def social_publish_post(post_id: str, idempotency_key: str) -> dict[str, Any]:
134
+ async def action() -> dict[str, Any]:
135
+ social = registry.container.social
136
+ social.ensure_ready()
137
+ jobs = await social.publishing.queue(
138
+ identity(), post_id, idempotency_key=idempotency_key
139
+ )
140
+ return {"jobs": [job.model_dump(mode="json") for job in jobs]}
141
+
142
+ return await registry.run_metadata_tool(
143
+ "social.publish_post", action, required_scope="social:posts:publish"
144
+ )
145
+
146
+ @server.tool(name="social.schedule_post", description="Schedule a social post using an aware timestamp and IANA timezone.")
147
+ async def social_schedule_post(
148
+ post_id: str, scheduled_at: str, timezone: str
149
+ ) -> dict[str, Any]:
150
+ async def action() -> dict[str, Any]:
151
+ social = registry.container.social
152
+ social.ensure_ready()
153
+ schedule = await social.scheduling.schedule(
154
+ identity(),
155
+ post_id,
156
+ SocialScheduleCreate(
157
+ scheduled_at=scheduled_at, # type: ignore[arg-type]
158
+ timezone=timezone,
159
+ ),
160
+ )
161
+ return {"schedule": schedule.model_dump(mode="json")}
162
+
163
+ return await registry.run_metadata_tool(
164
+ "social.schedule_post", action, required_scope="social:schedules:write"
165
+ )
166
+
167
+ @server.tool(name="social.cancel_post", description="Cancel a scheduled or active social post.")
168
+ async def social_cancel_post(post_id: str) -> dict[str, Any]:
169
+ async def action() -> dict[str, Any]:
170
+ social = registry.container.social
171
+ social.ensure_ready()
172
+ item = await social.publishing.cancel(identity(), post_id)
173
+ return {"post": item.model_dump(mode="json")}
174
+
175
+ return await registry.run_metadata_tool(
176
+ "social.cancel_post", action, required_scope="social:posts:write"
177
+ )
178
+
179
+ @server.tool(name="social.get_post", description="Get a multi-target social post and target states.")
180
+ async def social_get_post(post_id: str) -> dict[str, Any]:
181
+ async def action() -> dict[str, Any]:
182
+ social = registry.container.social
183
+ social.ensure_ready()
184
+ item = await social.publishing.get(identity(), post_id)
185
+ return {"post": item.model_dump(mode="json")}
186
+
187
+ return await registry.run_metadata_tool(
188
+ "social.get_post", action, required_scope="social:posts:read"
189
+ )
190
+
191
+ @server.tool(name="social.get_job", description="Get a durable social publishing job.")
192
+ async def social_get_job(job_id: str) -> dict[str, Any]:
193
+ async def action() -> dict[str, Any]:
194
+ social = registry.container.social
195
+ social.ensure_ready()
196
+ item = await social.jobs.get(identity(), job_id)
197
+ return {"job": item.model_dump(mode="json")}
198
+
199
+ return await registry.run_metadata_tool(
200
+ "social.get_job", action, required_scope="social:posts:read"
201
+ )
202
+
203
+ @server.tool(name="social.get_analytics", description="Get normalized metrics for an account or post.")
204
+ async def social_get_analytics(
205
+ resource: Literal["account", "post"], resource_id: str
206
+ ) -> dict[str, Any]:
207
+ async def action() -> dict[str, Any]:
208
+ social = registry.container.social
209
+ social.ensure_ready()
210
+ if resource == "account":
211
+ return await social.analytics.account(identity(), resource_id)
212
+ return await social.analytics.post(identity(), resource_id)
213
+
214
+ return await registry.run_metadata_tool(
215
+ "social.get_analytics", action, required_scope="social:analytics:read"
216
+ )
app/security/middleware.py CHANGED
@@ -68,6 +68,7 @@ class APIKeyAuthenticationMiddleware(BaseHTTPMiddleware):
68
  uploaded_bytes=bytes_uploaded,
69
  )
70
  self.api_keys.authorize(context, required_scope)
 
71
  await self.api_keys.mark_used(context)
72
  http_auth_token = http_auth_applied.set(True)
73
  response = await call_next(request)
@@ -120,6 +121,41 @@ class APIKeyAuthenticationMiddleware(BaseHTTPMiddleware):
120
  bytes_downloaded,
121
  )
122
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  @staticmethod
124
  def _bearer_token(header: str | None) -> str:
125
  if not header:
 
68
  uploaded_bytes=bytes_uploaded,
69
  )
70
  self.api_keys.authorize(context, required_scope)
71
+ await self._apply_social_rate_limit(request, context)
72
  await self.api_keys.mark_used(context)
73
  http_auth_token = http_auth_applied.set(True)
74
  response = await call_next(request)
 
121
  bytes_downloaded,
122
  )
123
 
124
+ async def _apply_social_rate_limit(
125
+ self, request: Request, context: AuthContext
126
+ ) -> None:
127
+ path = request.url.path
128
+ if not path.startswith("/v1/social"):
129
+ return
130
+ if "/analytics" in path:
131
+ await self.rate_limiter.acquire_category(
132
+ context,
133
+ "social_analytics",
134
+ limit=self.settings.social_analytics_requests_per_minute,
135
+ window_seconds=60,
136
+ )
137
+ elif path.endswith("/connect") or path.endswith("/callback") or path.endswith("/refresh"):
138
+ await self.rate_limiter.acquire_category(
139
+ context,
140
+ "social_oauth",
141
+ limit=self.settings.social_oauth_requests_per_hour,
142
+ window_seconds=3600,
143
+ )
144
+ elif path.endswith("/publish"):
145
+ await self.rate_limiter.acquire_category(
146
+ context,
147
+ "social_publish",
148
+ limit=self.settings.social_publish_requests_per_minute,
149
+ window_seconds=60,
150
+ )
151
+ elif path.endswith("/schedule"):
152
+ await self.rate_limiter.acquire_category(
153
+ context,
154
+ "social_schedule",
155
+ limit=self.settings.social_schedule_requests_per_minute,
156
+ window_seconds=60,
157
+ )
158
+
159
  @staticmethod
160
  def _bearer_token(header: str | None) -> str:
161
  if not header:
app/security/policy.py CHANGED
@@ -14,13 +14,26 @@ class ScopePolicy:
14
 
15
  @staticmethod
16
  def is_public(request: Request) -> bool:
17
- return request.method == "GET" and request.url.path in PUBLIC_GET_PATHS
 
 
 
 
 
 
 
 
 
 
 
18
 
19
  async def required_scope(self, request: Request) -> str | None:
20
  path = request.url.path
21
  method = request.method
22
  if path == "/v1/auth/context":
23
  return None
 
 
24
  if path.startswith("/mcp"):
25
  return await self._mcp_scope(request)
26
  if path.startswith("/v1/api-keys") or path.startswith("/v1/audit-logs"):
@@ -53,6 +66,28 @@ class ScopePolicy:
53
  return "system:read"
54
  return "admin"
55
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  @staticmethod
57
  async def _mcp_scope(request: Request) -> str:
58
  if request.method != "POST":
@@ -76,6 +111,7 @@ class ScopePolicy:
76
  "operations:execute",
77
  "jobs:create",
78
  "mcp:execute",
 
79
  }
80
 
81
  @staticmethod
 
14
 
15
  @staticmethod
16
  def is_public(request: Request) -> bool:
17
+ path = request.url.path
18
+ callback_segments = tuple(segment for segment in path.split("/") if segment)
19
+ return request.method == "GET" and (
20
+ path in PUBLIC_GET_PATHS
21
+ # OAuth callbacks deliberately rely on a single-use, short-lived
22
+ # state record instead of an API key that a provider cannot send.
23
+ # Keep this exemption exact: no nested route may accidentally
24
+ # inherit callback's public status.
25
+ or callback_segments[:3] == ("v1", "social", "accounts")
26
+ and len(callback_segments) == 5
27
+ and callback_segments[-1] == "callback"
28
+ )
29
 
30
  async def required_scope(self, request: Request) -> str | None:
31
  path = request.url.path
32
  method = request.method
33
  if path == "/v1/auth/context":
34
  return None
35
+ if path.startswith("/v1/social"):
36
+ return self._social_scope(path, method)
37
  if path.startswith("/mcp"):
38
  return await self._mcp_scope(request)
39
  if path.startswith("/v1/api-keys") or path.startswith("/v1/audit-logs"):
 
66
  return "system:read"
67
  return "admin"
68
 
69
+ @staticmethod
70
+ def _social_scope(path: str, method: str) -> str:
71
+ if "/assets" in path:
72
+ return "assets:read" if method == "GET" else "assets:write"
73
+ if "/analytics" in path:
74
+ return "social:analytics:read"
75
+ if "/jobs" in path:
76
+ return "social:posts:read"
77
+ if "/accounts" in path:
78
+ return "social:accounts:read" if method == "GET" else "social:accounts:write"
79
+ if "/posts" in path:
80
+ if method == "GET":
81
+ return "social:posts:read"
82
+ if path.endswith("/publish"):
83
+ return "social:posts:publish"
84
+ if path.endswith("/schedule"):
85
+ return "social:schedules:write"
86
+ if path.endswith("/cancel"):
87
+ return "social:posts:write"
88
+ return "social:posts:write"
89
+ return "social:accounts:read"
90
+
91
  @staticmethod
92
  async def _mcp_scope(request: Request) -> str:
93
  if request.method != "POST":
 
111
  "operations:execute",
112
  "jobs:create",
113
  "mcp:execute",
114
+ "social:posts:publish",
115
  }
116
 
117
  @staticmethod
app/security/rate_limit.py CHANGED
@@ -36,6 +36,7 @@ class APIKeyRateLimiter:
36
  self._uploads: dict[str, deque[float]] = defaultdict(deque)
37
  self._concurrent: dict[str, int] = defaultdict(int)
38
  self._daily_bytes: dict[tuple[str, str], int] = defaultdict(int)
 
39
 
40
  async def acquire(
41
  self,
@@ -96,6 +97,30 @@ class APIKeyRateLimiter:
96
  async with self._lock:
97
  self._concurrent[api_key_id] = max(0, self._concurrent[api_key_id] - 1)
98
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
  @staticmethod
100
  def _prune(values: deque[float], cutoff: float) -> None:
101
  while values and values[0] <= cutoff:
 
36
  self._uploads: dict[str, deque[float]] = defaultdict(deque)
37
  self._concurrent: dict[str, int] = defaultdict(int)
38
  self._daily_bytes: dict[tuple[str, str], int] = defaultdict(int)
39
+ self._categories: dict[tuple[str, str], deque[float]] = defaultdict(deque)
40
 
41
  async def acquire(
42
  self,
 
97
  async with self._lock:
98
  self._concurrent[api_key_id] = max(0, self._concurrent[api_key_id] - 1)
99
 
100
+ async def acquire_category(
101
+ self,
102
+ context: AuthContext,
103
+ category: str,
104
+ *,
105
+ limit: int,
106
+ window_seconds: int,
107
+ ) -> None:
108
+ """Reserve an independent social-operation bucket for a key.
109
+
110
+ Generic API limits still apply in middleware. These smaller buckets
111
+ prevent OAuth, publishing, scheduling, and analytics traffic from
112
+ starving each other when the social subsystem is enabled.
113
+ """
114
+ now = time.time()
115
+ key = (context.api_key_id, category)
116
+ async with self._lock:
117
+ values = self._categories[key]
118
+ self._prune(values, now - window_seconds)
119
+ if len(values) >= limit:
120
+ raise RateLimitError(max(1, math.ceil(values[0] + window_seconds - now)))
121
+ values.append(now)
122
+ await self._record(context.api_key_id, category, 1, 0, window_seconds)
123
+
124
  @staticmethod
125
  def _prune(values: deque[float], cutoff: float) -> None:
126
  while values and values[0] <= cutoff:
app/security/scopes.py CHANGED
@@ -17,6 +17,14 @@ ALL_SCOPES = frozenset(
17
  "mcp:read",
18
  "mcp:execute",
19
  "system:read",
 
 
 
 
 
 
 
 
20
  "admin",
21
  }
22
  )
@@ -37,6 +45,14 @@ DEFAULT_ROLE_SCOPES: dict[str, frozenset[str]] = {
37
  "mcp:read",
38
  "mcp:execute",
39
  "system:read",
 
 
 
 
 
 
 
 
40
  }
41
  ),
42
  "operator": frozenset(
@@ -52,6 +68,14 @@ DEFAULT_ROLE_SCOPES: dict[str, frozenset[str]] = {
52
  "mcp:read",
53
  "mcp:execute",
54
  "system:read",
 
 
 
 
 
 
 
 
55
  }
56
  ),
57
  "viewer": frozenset(
@@ -62,6 +86,10 @@ DEFAULT_ROLE_SCOPES: dict[str, frozenset[str]] = {
62
  "assets:read",
63
  "mcp:read",
64
  "system:read",
 
 
 
 
65
  }
66
  ),
67
  }
 
17
  "mcp:read",
18
  "mcp:execute",
19
  "system:read",
20
+ "social:accounts:read",
21
+ "social:accounts:write",
22
+ "social:posts:read",
23
+ "social:posts:write",
24
+ "social:posts:publish",
25
+ "social:schedules:read",
26
+ "social:schedules:write",
27
+ "social:analytics:read",
28
  "admin",
29
  }
30
  )
 
45
  "mcp:read",
46
  "mcp:execute",
47
  "system:read",
48
+ "social:accounts:read",
49
+ "social:accounts:write",
50
+ "social:posts:read",
51
+ "social:posts:write",
52
+ "social:posts:publish",
53
+ "social:schedules:read",
54
+ "social:schedules:write",
55
+ "social:analytics:read",
56
  }
57
  ),
58
  "operator": frozenset(
 
68
  "mcp:read",
69
  "mcp:execute",
70
  "system:read",
71
+ "social:accounts:read",
72
+ "social:accounts:write",
73
+ "social:posts:read",
74
+ "social:posts:write",
75
+ "social:posts:publish",
76
+ "social:schedules:read",
77
+ "social:schedules:write",
78
+ "social:analytics:read",
79
  }
80
  ),
81
  "viewer": frozenset(
 
86
  "assets:read",
87
  "mcp:read",
88
  "system:read",
89
+ "social:accounts:read",
90
+ "social:posts:read",
91
+ "social:schedules:read",
92
+ "social:analytics:read",
93
  }
94
  ),
95
  }
app/social/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ """MediaRouter Social Automation bounded domain."""
2
+
3
+ from app.social.services.social_service import SocialService
4
+
5
+ __all__ = ["SocialService"]
app/social/database.py ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import AsyncIterator
4
+ from contextlib import asynccontextmanager
5
+
6
+ from sqlalchemy import event, inspect, text
7
+ from sqlalchemy.ext.asyncio import (
8
+ AsyncEngine,
9
+ AsyncSession,
10
+ async_sessionmaker,
11
+ create_async_engine,
12
+ )
13
+
14
+ from app.core.config import Settings
15
+ from app.social.models import SocialBase
16
+
17
+ REQUIRED_SOCIAL_TABLES = frozenset(
18
+ {
19
+ "social_accounts",
20
+ "social_account_tokens",
21
+ "social_account_capabilities",
22
+ "media_variants",
23
+ "social_media_assets",
24
+ "social_campaigns",
25
+ "social_posts",
26
+ "social_post_targets",
27
+ "social_post_media",
28
+ "social_schedules",
29
+ "social_jobs",
30
+ "social_job_attempts",
31
+ "oauth_states",
32
+ "social_webhook_events",
33
+ "social_post_metrics",
34
+ "social_audit_events",
35
+ }
36
+ )
37
+
38
+
39
+ class SocialDatabase:
40
+ """Social persistence with migration-only production schema changes."""
41
+
42
+ def __init__(self, settings: Settings) -> None:
43
+ self.settings = settings
44
+ self.database_url = settings.resolved_social_database_url
45
+ self.engine: AsyncEngine = create_async_engine(self.database_url, pool_pre_ping=True)
46
+ if self.database_url.startswith("sqlite"):
47
+ event.listen(self.engine.sync_engine, "connect", self._configure_sqlite)
48
+ self.session_factory = async_sessionmaker(self.engine, expire_on_commit=False, class_=AsyncSession)
49
+
50
+ @staticmethod
51
+ def _configure_sqlite(dbapi_connection: object, _record: object) -> None:
52
+ cursor = dbapi_connection.cursor() # type: ignore[attr-defined]
53
+ cursor.execute("PRAGMA foreign_keys=ON")
54
+ cursor.execute("PRAGMA busy_timeout=5000")
55
+ cursor.close()
56
+
57
+ async def initialize(self) -> None:
58
+ if self.settings.social_auto_migrate:
59
+ async with self.engine.begin() as connection:
60
+ await connection.run_sync(SocialBase.metadata.create_all)
61
+
62
+ async def schema_ready(self) -> bool:
63
+ """Check the complete Phase 1 schema without changing the database."""
64
+ async with self.engine.connect() as connection:
65
+ tables = await connection.run_sync(
66
+ lambda sync: set(inspect(sync).get_table_names())
67
+ )
68
+ return REQUIRED_SOCIAL_TABLES.issubset(tables)
69
+
70
+ async def missing_tables(self) -> list[str]:
71
+ """Return absent required tables for an actionable startup warning."""
72
+ async with self.engine.connect() as connection:
73
+ tables = await connection.run_sync(
74
+ lambda sync: set(inspect(sync).get_table_names())
75
+ )
76
+ return sorted(REQUIRED_SOCIAL_TABLES - tables)
77
+
78
+ async def close(self) -> None:
79
+ await self.engine.dispose()
80
+
81
+ @asynccontextmanager
82
+ async def session(self, workspace_id: str | None = None) -> AsyncIterator[AsyncSession]:
83
+ async with self.session_factory() as session:
84
+ if workspace_id and self.database_url.startswith(("postgresql", "postgres")):
85
+ # RLS policies read this transaction-local tenant identity.
86
+ await session.execute(
87
+ text("select set_config('app.workspace_id', :workspace_id, true)"),
88
+ {"workspace_id": workspace_id},
89
+ )
90
+ yield session
app/social/domain/__init__.py ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from app.social.domain.capabilities import ProviderCapabilities
2
+ from app.social.domain.enums import ConnectionStrategy, JobStatus, PostStatus, Provider
3
+ from app.social.domain.errors import SocialError
4
+
5
+ __all__ = [
6
+ "ConnectionStrategy",
7
+ "JobStatus",
8
+ "PostStatus",
9
+ "Provider",
10
+ "ProviderCapabilities",
11
+ "SocialError",
12
+ ]
app/social/domain/capabilities.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pydantic import BaseModel, ConfigDict, Field
4
+
5
+ from app.social.domain.enums import ConnectionStrategy, Provider
6
+
7
+
8
+ class ProviderCapabilities(BaseModel):
9
+ """Provider metadata consumed by every transport and UI."""
10
+
11
+ model_config = ConfigDict(extra="forbid")
12
+
13
+ provider: Provider
14
+ connection_strategy: ConnectionStrategy
15
+ video: bool = False
16
+ video_upload: bool = False
17
+ video_status: bool = False
18
+ channel_metadata: bool = False
19
+ image: bool = False
20
+ carousel: bool = False
21
+ direct_publish: bool = False
22
+ draft_upload: bool = False
23
+ scheduled_publish: bool = False
24
+ native_scheduling: bool = False
25
+ analytics: bool = False
26
+ delete_post: bool = False
27
+ personal_publishing: bool = False
28
+ organization_publishing: bool = False
29
+ implementation_status: str = "foundation"
30
+ account_types: list[str] = Field(default_factory=list)
31
+ required_scopes: list[str] = Field(default_factory=list)
32
+ optional_scopes: list[str] = Field(default_factory=list)
33
+ # Publishing access is requested only after an explicit user action. This
34
+ # prevents provider-product approval scopes from being silently added to a
35
+ # foundation/account-discovery connection.
36
+ publishing_required_scopes: list[str] = Field(default_factory=list)
37
+ # Additional authorization is always opt-in. These scopes are never
38
+ # appended to a normal OAuth connection request.
39
+ analytics_required_scopes: list[str] = Field(default_factory=list)
40
+ # Transport-neutral metadata consumed by dynamic clients. Provider-owned
41
+ # runtime choices are fetched from the account publish-options endpoint.
42
+ publish_metadata_schema: dict[str, object] = Field(default_factory=dict)
43
+
44
+ @property
45
+ def publish_supported(self) -> bool:
46
+ return self.direct_publish or self.draft_upload
app/social/domain/enums.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from enum import StrEnum
4
+
5
+
6
+ class Provider(StrEnum):
7
+ YOUTUBE = "youtube"
8
+ FACEBOOK = "facebook"
9
+ INSTAGRAM = "instagram"
10
+ TIKTOK = "tiktok"
11
+ X = "x"
12
+ LINKEDIN = "linkedin"
13
+ TELEGRAM = "telegram"
14
+ WHATSAPP = "whatsapp"
15
+
16
+
17
+ class ConnectionStrategy(StrEnum):
18
+ OAUTH = "oauth"
19
+ TOKEN_BOT = "token_bot"
20
+ BUSINESS_API = "business_api"
21
+
22
+
23
+ class AccountStatus(StrEnum):
24
+ PENDING = "pending"
25
+ CONNECTED = "connected"
26
+ REAUTH_REQUIRED = "reauth_required"
27
+ DISCONNECTED = "disconnected"
28
+ ERROR = "error"
29
+
30
+
31
+ class PostStatus(StrEnum):
32
+ DRAFT = "draft"
33
+ SCHEDULED = "scheduled"
34
+ QUEUED = "queued"
35
+ PREPARING = "preparing"
36
+ PROCESSING = "processing"
37
+ UPLOADING = "uploading"
38
+ PUBLISHING = "publishing"
39
+ PUBLISHED = "published"
40
+ PARTIAL_SUCCESS = "partial_success"
41
+ RETRYING = "retrying"
42
+ FAILED = "failed"
43
+ CANCELLED = "cancelled"
44
+
45
+
46
+ class JobStatus(StrEnum):
47
+ DRAFT = "draft"
48
+ SCHEDULED = "scheduled"
49
+ QUEUED = "queued"
50
+ PREPARING = "preparing"
51
+ PROCESSING = "processing"
52
+ UPLOADING = "uploading"
53
+ PUBLISHING = "publishing"
54
+ PUBLISHED = "published"
55
+ RETRYING = "retrying"
56
+ FAILED = "failed"
57
+ CANCELLED = "cancelled"
58
+
59
+
60
+ class PublishMode(StrEnum):
61
+ NOW = "now"
62
+ SCHEDULE = "schedule"
63
+ DRAFT = "draft"
64
+
65
+
66
+ TERMINAL_JOB_STATUSES = frozenset(
67
+ {JobStatus.PUBLISHED, JobStatus.FAILED, JobStatus.CANCELLED}
68
+ )
app/social/domain/errors.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from app.core.exceptions import MediaAPIError
4
+
5
+
6
+ class SocialError(MediaAPIError):
7
+ code = "SOCIAL_ERROR"
8
+ status_code = 400
9
+
10
+
11
+ class SocialProviderUnavailableError(SocialError):
12
+ code = "SOCIAL_PROVIDER_UNAVAILABLE"
13
+ status_code = 503
14
+
15
+
16
+ class SocialProviderNotImplementedError(SocialError):
17
+ code = "SOCIAL_PROVIDER_NOT_IMPLEMENTED"
18
+ status_code = 501
19
+
20
+
21
+ class SocialAccountNotFoundError(SocialError):
22
+ code = "SOCIAL_ACCOUNT_NOT_FOUND"
23
+ status_code = 404
24
+
25
+
26
+ class SocialAccountDisconnectedError(SocialError):
27
+ code = "SOCIAL_ACCOUNT_DISCONNECTED"
28
+ status_code = 409
29
+
30
+
31
+ class SocialReauthRequiredError(SocialError):
32
+ code = "SOCIAL_REAUTH_REQUIRED"
33
+ status_code = 401
34
+
35
+
36
+ class SocialPermissionDeniedError(SocialError):
37
+ code = "SOCIAL_PERMISSION_DENIED"
38
+ status_code = 403
39
+
40
+
41
+ class SocialCapabilityUnsupportedError(SocialError):
42
+ code = "SOCIAL_CAPABILITY_UNSUPPORTED"
43
+ status_code = 422
44
+
45
+
46
+ class SocialMediaInvalidError(SocialError):
47
+ code = "SOCIAL_MEDIA_INVALID"
48
+ status_code = 422
49
+
50
+
51
+ class SocialRateLimitedError(SocialError):
52
+ code = "SOCIAL_RATE_LIMITED"
53
+ status_code = 429
54
+
55
+
56
+ class SocialProviderQuotaError(SocialError):
57
+ code = "SOCIAL_PROVIDER_QUOTA_EXCEEDED"
58
+ status_code = 429
59
+
60
+
61
+ class SocialPostNotFoundError(SocialError):
62
+ code = "SOCIAL_POST_NOT_FOUND"
63
+ status_code = 404
64
+
65
+
66
+ class SocialJobNotFoundError(SocialError):
67
+ code = "SOCIAL_JOB_NOT_FOUND"
68
+ status_code = 404
69
+
70
+
71
+ class SocialPublishFailedError(SocialError):
72
+ code = "SOCIAL_PUBLISH_FAILED"
73
+ status_code = 422
74
+
75
+
76
+ class SocialJobFailedError(SocialError):
77
+ code = "SOCIAL_JOB_FAILED"
78
+ status_code = 422
79
+
80
+
81
+ class SocialIdempotencyConflictError(SocialError):
82
+ code = "SOCIAL_IDEMPOTENCY_CONFLICT"
83
+ status_code = 409
84
+
85
+
86
+ class SocialOAuthStateError(SocialError):
87
+ code = "SOCIAL_OAUTH_STATE_INVALID"
88
+ status_code = 400
89
+
90
+
91
+ class SocialTransitionError(SocialError):
92
+ code = "SOCIAL_INVALID_STATE_TRANSITION"
93
+ status_code = 409
app/social/domain/models.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from app.social.models import (
2
+ MediaVariant,
3
+ SocialMediaAsset,
4
+ OAuthState,
5
+ SocialAccount,
6
+ SocialAccountCapability,
7
+ SocialAccountToken,
8
+ SocialCampaign,
9
+ SocialJob,
10
+ SocialJobAttempt,
11
+ SocialPost,
12
+ SocialPostMedia,
13
+ SocialPostMetric,
14
+ SocialPostTarget,
15
+ SocialSchedule,
16
+ SocialWebhookEvent,
17
+ )
18
+
19
+ __all__ = [
20
+ "MediaVariant", "SocialMediaAsset", "OAuthState", "SocialAccount", "SocialAccountCapability",
21
+ "SocialAccountToken", "SocialCampaign", "SocialJob", "SocialJobAttempt",
22
+ "SocialPost", "SocialPostMedia", "SocialPostMetric", "SocialPostTarget",
23
+ "SocialSchedule", "SocialWebhookEvent",
24
+ ]
app/social/domain/retry.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ RETRYABLE_STATUS_CODES = frozenset({429, 500, 502, 503, 504})
6
+ BACKOFF_SECONDS = (0, 10, 30, 120, 600)
7
+
8
+
9
+ @dataclass(frozen=True, slots=True)
10
+ class RetryDecision:
11
+ retryable: bool
12
+ refresh_token_first: bool = False
13
+ delay_seconds: int = 0
14
+
15
+
16
+ def classify_retry(*, status_code: int | None = None, network_error: bool = False, attempt: int = 1) -> RetryDecision:
17
+ delay = BACKOFF_SECONDS[min(max(attempt - 1, 0), len(BACKOFF_SECONDS) - 1)]
18
+ if network_error or status_code in RETRYABLE_STATUS_CODES:
19
+ return RetryDecision(True, delay_seconds=delay)
20
+ if status_code == 401:
21
+ return RetryDecision(attempt <= 1, refresh_token_first=True, delay_seconds=0)
22
+ return RetryDecision(False)
app/social/domain/state_machine.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from app.social.domain.enums import JobStatus
4
+ from app.social.domain.errors import SocialTransitionError
5
+
6
+ ALLOWED_TRANSITIONS: dict[JobStatus, frozenset[JobStatus]] = {
7
+ JobStatus.DRAFT: frozenset({JobStatus.SCHEDULED, JobStatus.QUEUED, JobStatus.CANCELLED}),
8
+ JobStatus.SCHEDULED: frozenset({JobStatus.QUEUED, JobStatus.CANCELLED}),
9
+ JobStatus.QUEUED: frozenset({JobStatus.PREPARING, JobStatus.CANCELLED}),
10
+ JobStatus.PREPARING: frozenset({JobStatus.PROCESSING, JobStatus.UPLOADING, JobStatus.RETRYING, JobStatus.FAILED, JobStatus.CANCELLED}),
11
+ JobStatus.PROCESSING: frozenset({JobStatus.UPLOADING, JobStatus.RETRYING, JobStatus.FAILED, JobStatus.CANCELLED}),
12
+ JobStatus.UPLOADING: frozenset({JobStatus.PUBLISHING, JobStatus.RETRYING, JobStatus.FAILED, JobStatus.CANCELLED}),
13
+ JobStatus.PUBLISHING: frozenset({JobStatus.PUBLISHED, JobStatus.RETRYING, JobStatus.FAILED, JobStatus.CANCELLED}),
14
+ JobStatus.RETRYING: frozenset({JobStatus.PREPARING, JobStatus.PROCESSING, JobStatus.UPLOADING, JobStatus.PUBLISHING, JobStatus.FAILED, JobStatus.CANCELLED}),
15
+ JobStatus.PUBLISHED: frozenset(),
16
+ JobStatus.FAILED: frozenset(),
17
+ JobStatus.CANCELLED: frozenset(),
18
+ }
19
+
20
+
21
+ def validate_transition(current: str | JobStatus, target: str | JobStatus) -> JobStatus:
22
+ source = JobStatus(current)
23
+ destination = JobStatus(target)
24
+ if destination not in ALLOWED_TRANSITIONS[source]:
25
+ raise SocialTransitionError(f"Cannot transition social job from {source} to {destination}.")
26
+ return destination
app/social/migrations/0001_social_foundation_postgres.sql ADDED
@@ -0,0 +1,257 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -- MediaRouter Social Automation foundation (PostgreSQL / Supabase)
2
+ -- Apply with the normal deployment migration process. Application startup
3
+ -- intentionally does not mutate production schema.
4
+
5
+ begin;
6
+ create extension if not exists pgcrypto;
7
+
8
+ create table if not exists social_accounts (
9
+ id text primary key default gen_random_uuid()::text,
10
+ workspace_id text not null,
11
+ provider text not null check (provider in ('youtube','facebook','instagram','tiktok','x','linkedin','telegram','whatsapp')),
12
+ account_type text not null,
13
+ external_account_id text not null,
14
+ username text,
15
+ display_name text,
16
+ avatar_url text,
17
+ status text not null default 'pending',
18
+ metadata jsonb not null default '{}'::jsonb,
19
+ created_at timestamptz not null default now(),
20
+ updated_at timestamptz not null default now(),
21
+ last_synced_at timestamptz,
22
+ constraint uq_social_account_workspace_provider_external unique (workspace_id, provider, external_account_id)
23
+ );
24
+ create index if not exists ix_social_accounts_workspace_id on social_accounts(workspace_id);
25
+ create index if not exists ix_social_accounts_provider_status on social_accounts(provider, status);
26
+
27
+ create table if not exists social_account_tokens (
28
+ id text primary key default gen_random_uuid()::text,
29
+ social_account_id text not null references social_accounts(id) on delete cascade,
30
+ access_token_secret_id text,
31
+ refresh_token_secret_id text,
32
+ encrypted_payload text,
33
+ expires_at timestamptz,
34
+ scopes jsonb not null default '[]'::jsonb,
35
+ token_type text,
36
+ last_refreshed_at timestamptz,
37
+ revoked_at timestamptz,
38
+ created_at timestamptz not null default now(),
39
+ updated_at timestamptz not null default now(),
40
+ constraint uq_social_account_token unique (social_account_id),
41
+ constraint ck_social_token_storage check (
42
+ encrypted_payload is not null or access_token_secret_id is not null or revoked_at is not null
43
+ )
44
+ );
45
+ create index if not exists ix_social_account_tokens_expires_at on social_account_tokens(expires_at);
46
+
47
+ create table if not exists social_account_capabilities (
48
+ id text primary key default gen_random_uuid()::text,
49
+ social_account_id text not null references social_accounts(id) on delete cascade,
50
+ capability text not null,
51
+ enabled boolean not null default false,
52
+ metadata jsonb not null default '{}'::jsonb,
53
+ updated_at timestamptz not null default now(),
54
+ constraint uq_social_account_capability unique (social_account_id, capability)
55
+ );
56
+ create index if not exists ix_social_account_capabilities_account on social_account_capabilities(social_account_id);
57
+
58
+ create table if not exists media_variants (
59
+ id text primary key default gen_random_uuid()::text,
60
+ workspace_id text not null,
61
+ source_asset_id text not null,
62
+ asset_reference text,
63
+ template_id text,
64
+ platform text,
65
+ width integer,
66
+ height integer,
67
+ duration_seconds double precision,
68
+ codec text,
69
+ container text,
70
+ bitrate bigint,
71
+ file_size bigint,
72
+ metadata jsonb not null default '{}'::jsonb,
73
+ created_at timestamptz not null default now()
74
+ );
75
+ create index if not exists ix_media_variants_workspace_source on media_variants(workspace_id, source_asset_id);
76
+ create index if not exists ix_media_variants_platform on media_variants(platform);
77
+
78
+ create table if not exists social_campaigns (
79
+ id text primary key default gen_random_uuid()::text,
80
+ workspace_id text not null,
81
+ name text not null,
82
+ description text,
83
+ status text not null default 'draft',
84
+ metadata jsonb not null default '{}'::jsonb,
85
+ created_at timestamptz not null default now(),
86
+ updated_at timestamptz not null default now()
87
+ );
88
+ create index if not exists ix_social_campaigns_workspace_id on social_campaigns(workspace_id);
89
+
90
+ create table if not exists social_posts (
91
+ id text primary key default gen_random_uuid()::text,
92
+ workspace_id text not null,
93
+ campaign_id text references social_campaigns(id) on delete set null,
94
+ media_asset_id text not null,
95
+ source_variant_id text references media_variants(id) on delete set null,
96
+ status text not null default 'draft',
97
+ publish_mode text not null default 'draft' check (publish_mode in ('now','schedule','draft')),
98
+ idempotency_key text,
99
+ request_fingerprint char(64),
100
+ metadata jsonb not null default '{}'::jsonb,
101
+ created_by text,
102
+ created_at timestamptz not null default now(),
103
+ updated_at timestamptz not null default now(),
104
+ published_at timestamptz,
105
+ constraint uq_social_posts_workspace_idempotency unique (workspace_id, idempotency_key)
106
+ );
107
+ create index if not exists ix_social_posts_workspace_created on social_posts(workspace_id, created_at desc);
108
+ create index if not exists ix_social_posts_status on social_posts(status);
109
+
110
+ create table if not exists social_post_targets (
111
+ id text primary key default gen_random_uuid()::text,
112
+ social_post_id text not null references social_posts(id) on delete cascade,
113
+ social_account_id text not null references social_accounts(id) on delete restrict,
114
+ provider text not null,
115
+ status text not null default 'draft',
116
+ caption jsonb not null default '{}'::jsonb,
117
+ platform_metadata jsonb not null default '{}'::jsonb,
118
+ external_post_id text,
119
+ external_url text,
120
+ error_code text,
121
+ error_message text,
122
+ published_at timestamptz,
123
+ created_at timestamptz not null default now(),
124
+ updated_at timestamptz not null default now(),
125
+ constraint uq_social_post_target unique (social_post_id, social_account_id)
126
+ );
127
+ create index if not exists ix_social_post_targets_post on social_post_targets(social_post_id);
128
+ create index if not exists ix_social_post_targets_account on social_post_targets(social_account_id);
129
+ create index if not exists ix_social_post_targets_status on social_post_targets(status);
130
+
131
+ create table if not exists social_post_media (
132
+ id text primary key default gen_random_uuid()::text,
133
+ social_post_id text not null references social_posts(id) on delete cascade,
134
+ media_variant_id text references media_variants(id) on delete set null,
135
+ media_asset_id text,
136
+ position integer not null default 0,
137
+ kind text not null default 'video',
138
+ metadata jsonb not null default '{}'::jsonb,
139
+ created_at timestamptz not null default now()
140
+ );
141
+ create index if not exists ix_social_post_media_post on social_post_media(social_post_id);
142
+
143
+ create table if not exists social_schedules (
144
+ id text primary key default gen_random_uuid()::text,
145
+ social_post_id text not null references social_posts(id) on delete cascade,
146
+ scheduled_at timestamptz not null,
147
+ timezone text not null,
148
+ status text not null default 'scheduled',
149
+ created_at timestamptz not null default now(),
150
+ updated_at timestamptz not null default now(),
151
+ constraint uq_social_schedule_post unique (social_post_id)
152
+ );
153
+ create index if not exists ix_social_schedules_due on social_schedules(status, scheduled_at);
154
+
155
+ create table if not exists social_jobs (
156
+ id text primary key default gen_random_uuid()::text,
157
+ workspace_id text not null,
158
+ social_post_id text not null references social_posts(id) on delete cascade,
159
+ social_post_target_id text references social_post_targets(id) on delete cascade,
160
+ provider text,
161
+ status text not null default 'queued',
162
+ attempt_count integer not null default 0,
163
+ max_attempts integer not null default 5,
164
+ next_attempt_at timestamptz,
165
+ idempotency_key text,
166
+ error_code text,
167
+ error_message text,
168
+ payload jsonb not null default '{}'::jsonb,
169
+ created_at timestamptz not null default now(),
170
+ started_at timestamptz,
171
+ completed_at timestamptz,
172
+ updated_at timestamptz not null default now(),
173
+ constraint uq_social_jobs_workspace_idempotency unique (workspace_id, idempotency_key)
174
+ );
175
+ create index if not exists ix_social_jobs_workspace_status on social_jobs(workspace_id, status);
176
+ create index if not exists ix_social_jobs_next_attempt on social_jobs(status, next_attempt_at);
177
+ create index if not exists ix_social_jobs_post on social_jobs(social_post_id);
178
+
179
+ create table if not exists social_job_attempts (
180
+ id text primary key default gen_random_uuid()::text,
181
+ social_job_id text not null references social_jobs(id) on delete cascade,
182
+ attempt_number integer not null,
183
+ status text not null,
184
+ error_code text,
185
+ error_message text,
186
+ provider_request_id text,
187
+ started_at timestamptz not null default now(),
188
+ completed_at timestamptz,
189
+ constraint uq_social_job_attempt_number unique (social_job_id, attempt_number)
190
+ );
191
+ create index if not exists ix_social_job_attempts_job on social_job_attempts(social_job_id, attempt_number);
192
+
193
+ create table if not exists oauth_states (
194
+ id text primary key default gen_random_uuid()::text,
195
+ state text not null unique,
196
+ provider text not null,
197
+ workspace_id text not null,
198
+ user_id text,
199
+ redirect_uri text not null,
200
+ code_verifier_encrypted text,
201
+ expires_at timestamptz not null,
202
+ used_at timestamptz,
203
+ created_at timestamptz not null default now()
204
+ );
205
+ create index if not exists ix_oauth_states_expires_at on oauth_states(expires_at);
206
+ create index if not exists ix_oauth_states_workspace on oauth_states(workspace_id);
207
+
208
+ create table if not exists social_webhook_events (
209
+ id text primary key default gen_random_uuid()::text,
210
+ provider text not null,
211
+ event_type text not null,
212
+ external_event_id text not null,
213
+ workspace_id text,
214
+ payload jsonb not null default '{}'::jsonb,
215
+ received_at timestamptz not null default now(),
216
+ processed_at timestamptz,
217
+ status text not null default 'received',
218
+ error_message text,
219
+ constraint uq_social_webhook_provider_external unique (provider, external_event_id)
220
+ );
221
+ create index if not exists ix_social_webhook_events_status on social_webhook_events(status);
222
+ create index if not exists ix_social_webhook_events_received on social_webhook_events(received_at);
223
+
224
+ create table if not exists social_post_metrics (
225
+ id text primary key default gen_random_uuid()::text,
226
+ social_post_id text not null references social_posts(id) on delete cascade,
227
+ social_post_target_id text references social_post_targets(id) on delete cascade,
228
+ provider text not null,
229
+ views bigint,
230
+ impressions bigint,
231
+ likes bigint,
232
+ comments bigint,
233
+ shares bigint,
234
+ engagement_rate double precision,
235
+ published_at timestamptz,
236
+ retrieved_at timestamptz not null default now(),
237
+ raw_metrics jsonb not null default '{}'::jsonb
238
+ );
239
+ create index if not exists ix_social_post_metrics_target_retrieved on social_post_metrics(social_post_target_id, retrieved_at desc);
240
+
241
+ create table if not exists social_audit_events (
242
+ id text primary key default gen_random_uuid()::text,
243
+ workspace_id text not null,
244
+ api_key_id text,
245
+ event_type text not null,
246
+ provider text,
247
+ social_account_id text,
248
+ social_post_id text,
249
+ social_job_id text,
250
+ request_id text,
251
+ metadata jsonb not null default '{}'::jsonb,
252
+ created_at timestamptz not null default now()
253
+ );
254
+ create index if not exists ix_social_audit_events_workspace_created on social_audit_events(workspace_id, created_at desc);
255
+ create index if not exists ix_social_audit_events_type on social_audit_events(event_type);
256
+
257
+ commit;
app/social/migrations/0002_social_rls.sql ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -- Tenant isolation policies for the social bounded domain.
2
+ -- The API starts each Postgres transaction with:
3
+ -- set_config('app.workspace_id', '<authorized workspace>', true)
4
+ -- Supabase service-role workers bypass RLS. Never expose that credential.
5
+
6
+ begin;
7
+
8
+ do $$
9
+ declare
10
+ table_name text;
11
+ begin
12
+ foreach table_name in array array[
13
+ 'social_accounts', 'media_variants', 'social_campaigns', 'social_posts',
14
+ 'social_jobs', 'social_webhook_events', 'social_audit_events'
15
+ ] loop
16
+ execute format('alter table %I enable row level security', table_name);
17
+ execute format('drop policy if exists social_workspace_isolation on %I', table_name);
18
+ execute format(
19
+ 'create policy social_workspace_isolation on %I using (workspace_id = current_setting(''app.workspace_id'', true)) with check (workspace_id = current_setting(''app.workspace_id'', true))',
20
+ table_name
21
+ );
22
+ end loop;
23
+ end $$;
24
+
25
+ -- OAuth states are never a user-facing resource. The provider callback has no
26
+ -- authenticated workspace context, so its random, expiring, single-use state
27
+ -- token is the isolation boundary. Enabling RLS here would reject a valid
28
+ -- callback before it can atomically consume that state.
29
+ alter table oauth_states disable row level security;
30
+
31
+ alter table social_account_tokens enable row level security;
32
+ drop policy if exists social_token_workspace_isolation on social_account_tokens;
33
+ create policy social_token_workspace_isolation on social_account_tokens
34
+ using (exists (
35
+ select 1 from social_accounts a
36
+ where a.id = social_account_tokens.social_account_id
37
+ and a.workspace_id = current_setting('app.workspace_id', true)
38
+ )) with check (exists (
39
+ select 1 from social_accounts a
40
+ where a.id = social_account_tokens.social_account_id
41
+ and a.workspace_id = current_setting('app.workspace_id', true)
42
+ ));
43
+
44
+ alter table social_account_capabilities enable row level security;
45
+ drop policy if exists social_capability_workspace_isolation on social_account_capabilities;
46
+ create policy social_capability_workspace_isolation on social_account_capabilities
47
+ using (exists (
48
+ select 1 from social_accounts a
49
+ where a.id = social_account_capabilities.social_account_id
50
+ and a.workspace_id = current_setting('app.workspace_id', true)
51
+ )) with check (exists (
52
+ select 1 from social_accounts a
53
+ where a.id = social_account_capabilities.social_account_id
54
+ and a.workspace_id = current_setting('app.workspace_id', true)
55
+ ));
56
+
57
+ do $$
58
+ declare
59
+ table_name text;
60
+ begin
61
+ foreach table_name in array array[
62
+ 'social_post_targets', 'social_post_media', 'social_schedules', 'social_post_metrics'
63
+ ] loop
64
+ execute format('alter table %I enable row level security', table_name);
65
+ execute format('drop policy if exists social_post_child_isolation on %I', table_name);
66
+ execute format(
67
+ 'create policy social_post_child_isolation on %I using (exists (select 1 from social_posts p where p.id = %I.social_post_id and p.workspace_id = current_setting(''app.workspace_id'', true))) with check (exists (select 1 from social_posts p where p.id = %I.social_post_id and p.workspace_id = current_setting(''app.workspace_id'', true)))',
68
+ table_name, table_name, table_name
69
+ );
70
+ end loop;
71
+ end $$;
72
+
73
+ alter table social_job_attempts enable row level security;
74
+ drop policy if exists social_job_attempt_workspace_isolation on social_job_attempts;
75
+ create policy social_job_attempt_workspace_isolation on social_job_attempts
76
+ using (exists (
77
+ select 1 from social_jobs j
78
+ where j.id = social_job_attempts.social_job_id
79
+ and j.workspace_id = current_setting('app.workspace_id', true)
80
+ )) with check (exists (
81
+ select 1 from social_jobs j
82
+ where j.id = social_job_attempts.social_job_id
83
+ and j.workspace_id = current_setting('app.workspace_id', true)
84
+ ));
85
+
86
+ commit;
87
+
88
+ -- Reversal (intentionally explicit; execute only during a controlled rollback):
89
+ -- ALTER TABLE <table> DISABLE ROW LEVEL SECURITY; DROP POLICY ...;
90
+ -- This migration never drops customer data.
app/social/migrations/0003_social_integrity_postgres.sql ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -- Social foundation hardening. Apply after 0001 and 0002.
2
+ -- This migration is additive: it adds database-side timestamp maintenance and
3
+ -- tenant relationship checks without changing or deleting customer data.
4
+
5
+ begin;
6
+
7
+ create or replace function mediarouter_social_touch_updated_at()
8
+ returns trigger
9
+ language plpgsql
10
+ as $$
11
+ begin
12
+ new.updated_at = now();
13
+ return new;
14
+ end;
15
+ $$;
16
+
17
+ do $$
18
+ declare
19
+ table_name text;
20
+ begin
21
+ foreach table_name in array array[
22
+ 'social_accounts', 'social_account_tokens', 'social_account_capabilities',
23
+ 'social_campaigns', 'social_posts', 'social_post_targets',
24
+ 'social_schedules', 'social_jobs'
25
+ ] loop
26
+ execute format(
27
+ 'drop trigger if exists mediarouter_social_touch_updated_at on %I',
28
+ table_name
29
+ );
30
+ execute format(
31
+ 'create trigger mediarouter_social_touch_updated_at before update on %I for each row execute function mediarouter_social_touch_updated_at()',
32
+ table_name
33
+ );
34
+ end loop;
35
+ end;
36
+ $$;
37
+
38
+ -- SQLAlchemy enums are represented as text for compatibility with the
39
+ -- existing schema. These constraints keep direct SQL writes within the same
40
+ -- state vocabulary enforced by the application state machine.
41
+ do $$
42
+ begin
43
+ if not exists (select 1 from pg_constraint where conname = 'ck_social_accounts_status') then
44
+ alter table social_accounts add constraint ck_social_accounts_status
45
+ check (status in ('pending', 'connected', 'reauth_required', 'disconnected', 'error'));
46
+ end if;
47
+ if not exists (select 1 from pg_constraint where conname = 'ck_social_posts_status') then
48
+ alter table social_posts add constraint ck_social_posts_status
49
+ check (status in ('draft', 'scheduled', 'queued', 'preparing', 'processing', 'uploading', 'publishing', 'published', 'partial_success', 'retrying', 'failed', 'cancelled'));
50
+ end if;
51
+ if not exists (select 1 from pg_constraint where conname = 'ck_social_post_targets_status') then
52
+ alter table social_post_targets add constraint ck_social_post_targets_status
53
+ check (status in ('draft', 'scheduled', 'queued', 'preparing', 'processing', 'uploading', 'publishing', 'published', 'partial_success', 'retrying', 'failed', 'cancelled'));
54
+ end if;
55
+ if not exists (select 1 from pg_constraint where conname = 'ck_social_schedules_status') then
56
+ alter table social_schedules add constraint ck_social_schedules_status
57
+ check (status in ('scheduled', 'queued', 'cancelled'));
58
+ end if;
59
+ if not exists (select 1 from pg_constraint where conname = 'ck_social_jobs_status') then
60
+ alter table social_jobs add constraint ck_social_jobs_status
61
+ check (status in ('draft', 'scheduled', 'queued', 'preparing', 'processing', 'uploading', 'publishing', 'published', 'retrying', 'failed', 'cancelled'));
62
+ end if;
63
+ if not exists (select 1 from pg_constraint where conname = 'ck_social_jobs_attempt_bounds') then
64
+ alter table social_jobs add constraint ck_social_jobs_attempt_bounds
65
+ check (attempt_count >= 0 and max_attempts >= 0);
66
+ end if;
67
+ end;
68
+ $$;
69
+
70
+ create or replace function mediarouter_social_assert_workspace_integrity()
71
+ returns trigger
72
+ language plpgsql
73
+ as $$
74
+ declare
75
+ post_workspace text;
76
+ related_workspace text;
77
+ related_provider text;
78
+ related_post_id text;
79
+ begin
80
+ if tg_op = 'UPDATE' and tg_table_name in ('social_posts', 'social_jobs')
81
+ and new.workspace_id is distinct from old.workspace_id then
82
+ raise exception 'social workspace cannot be reassigned'
83
+ using errcode = '23514';
84
+ end if;
85
+
86
+ if tg_table_name = 'social_posts' then
87
+ if new.campaign_id is not null then
88
+ select workspace_id into related_workspace
89
+ from social_campaigns where id = new.campaign_id;
90
+ if related_workspace is distinct from new.workspace_id then
91
+ raise exception 'social post campaign must belong to the same workspace'
92
+ using errcode = '23503';
93
+ end if;
94
+ end if;
95
+ if new.source_variant_id is not null then
96
+ select workspace_id into related_workspace
97
+ from media_variants where id = new.source_variant_id;
98
+ if related_workspace is distinct from new.workspace_id then
99
+ raise exception 'social post variant must belong to the same workspace'
100
+ using errcode = '23503';
101
+ end if;
102
+ end if;
103
+ elsif tg_table_name = 'social_post_targets' then
104
+ select workspace_id into post_workspace
105
+ from social_posts where id = new.social_post_id;
106
+ select workspace_id, provider into related_workspace, related_provider
107
+ from social_accounts where id = new.social_account_id;
108
+ if post_workspace is null or related_workspace is distinct from post_workspace then
109
+ raise exception 'social post target account must belong to the post workspace'
110
+ using errcode = '23503';
111
+ end if;
112
+ if new.provider is distinct from related_provider then
113
+ raise exception 'social post target provider must match its account'
114
+ using errcode = '23514';
115
+ end if;
116
+ elsif tg_table_name = 'social_post_media' and new.media_variant_id is not null then
117
+ select workspace_id into post_workspace
118
+ from social_posts where id = new.social_post_id;
119
+ select workspace_id into related_workspace
120
+ from media_variants where id = new.media_variant_id;
121
+ if post_workspace is null or related_workspace is distinct from post_workspace then
122
+ raise exception 'social post media variant must belong to the post workspace'
123
+ using errcode = '23503';
124
+ end if;
125
+ elsif tg_table_name = 'social_jobs' then
126
+ select workspace_id into post_workspace
127
+ from social_posts where id = new.social_post_id;
128
+ if post_workspace is distinct from new.workspace_id then
129
+ raise exception 'social job must belong to the post workspace'
130
+ using errcode = '23503';
131
+ end if;
132
+ if new.social_post_target_id is not null then
133
+ select social_post_id, provider into related_post_id, related_provider
134
+ from social_post_targets where id = new.social_post_target_id;
135
+ if related_post_id is distinct from new.social_post_id then
136
+ raise exception 'social job target must belong to the social post'
137
+ using errcode = '23503';
138
+ end if;
139
+ if new.provider is not null and new.provider is distinct from related_provider then
140
+ raise exception 'social job provider must match its target'
141
+ using errcode = '23514';
142
+ end if;
143
+ end if;
144
+ elsif tg_table_name = 'social_post_metrics' then
145
+ if new.social_post_target_id is not null then
146
+ select social_post_id, provider into related_post_id, related_provider
147
+ from social_post_targets where id = new.social_post_target_id;
148
+ if related_post_id is distinct from new.social_post_id then
149
+ raise exception 'social metric target must belong to the social post'
150
+ using errcode = '23503';
151
+ end if;
152
+ if new.provider is distinct from related_provider then
153
+ raise exception 'social metric provider must match its target'
154
+ using errcode = '23514';
155
+ end if;
156
+ end if;
157
+ end if;
158
+ return new;
159
+ end;
160
+ $$;
161
+
162
+ drop trigger if exists mediarouter_social_post_workspace_integrity on social_posts;
163
+ create trigger mediarouter_social_post_workspace_integrity
164
+ before insert or update of workspace_id, campaign_id, source_variant_id on social_posts
165
+ for each row execute function mediarouter_social_assert_workspace_integrity();
166
+
167
+ drop trigger if exists mediarouter_social_target_workspace_integrity on social_post_targets;
168
+ create trigger mediarouter_social_target_workspace_integrity
169
+ before insert or update of social_post_id, social_account_id, provider on social_post_targets
170
+ for each row execute function mediarouter_social_assert_workspace_integrity();
171
+
172
+ drop trigger if exists mediarouter_social_post_media_workspace_integrity on social_post_media;
173
+ create trigger mediarouter_social_post_media_workspace_integrity
174
+ before insert or update of social_post_id, media_variant_id on social_post_media
175
+ for each row execute function mediarouter_social_assert_workspace_integrity();
176
+
177
+ drop trigger if exists mediarouter_social_job_workspace_integrity on social_jobs;
178
+ create trigger mediarouter_social_job_workspace_integrity
179
+ before insert or update of workspace_id, social_post_id, social_post_target_id, provider on social_jobs
180
+ for each row execute function mediarouter_social_assert_workspace_integrity();
181
+
182
+ drop trigger if exists mediarouter_social_metric_workspace_integrity on social_post_metrics;
183
+ create trigger mediarouter_social_metric_workspace_integrity
184
+ before insert or update of social_post_id, social_post_target_id, provider on social_post_metrics
185
+ for each row execute function mediarouter_social_assert_workspace_integrity();
186
+
187
+ commit;
app/social/migrations/0004_youtube_media_assets.sql ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -- YouTube Phase 2: durable, tenant-scoped references to MediaRouter outputs
2
+ -- plus confidential durable state for resumable Google upload sessions.
3
+ -- This migration is additive and does not rewrite Phase 1 records.
4
+
5
+ begin;
6
+
7
+ create table if not exists social_media_assets (
8
+ id text primary key default gen_random_uuid()::text,
9
+ workspace_id text not null,
10
+ request_id text not null,
11
+ filename text not null,
12
+ mime_type text not null,
13
+ file_size bigint not null,
14
+ metadata jsonb not null default '{}'::jsonb,
15
+ created_at timestamptz not null default now(),
16
+ constraint uq_social_media_asset_workspace_output unique (workspace_id, request_id, filename)
17
+ );
18
+ create index if not exists ix_social_media_assets_workspace_id on social_media_assets(workspace_id);
19
+
20
+ alter table social_media_assets enable row level security;
21
+ drop policy if exists social_workspace_isolation on social_media_assets;
22
+ create policy social_workspace_isolation on social_media_assets
23
+ using (workspace_id = current_setting('app.workspace_id', true))
24
+ with check (workspace_id = current_setting('app.workspace_id', true));
25
+
26
+ alter table social_jobs add column if not exists provider_state_encrypted text;
27
+
28
+ commit;
app/social/models.py ADDED
@@ -0,0 +1,343 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timezone
4
+ from uuid import uuid4
5
+
6
+ from sqlalchemy import (
7
+ JSON,
8
+ BigInteger,
9
+ DateTime,
10
+ ForeignKey,
11
+ Index,
12
+ Integer,
13
+ String,
14
+ Text,
15
+ UniqueConstraint,
16
+ )
17
+ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
18
+
19
+
20
+ def utcnow() -> datetime:
21
+ return datetime.now(timezone.utc)
22
+
23
+
24
+ def new_id() -> str:
25
+ return str(uuid4())
26
+
27
+
28
+ class SocialBase(DeclarativeBase):
29
+ """Separate metadata keeps production schema changes migration-only."""
30
+
31
+
32
+ class SocialAccount(SocialBase):
33
+ __tablename__ = "social_accounts"
34
+ __table_args__ = (
35
+ UniqueConstraint("workspace_id", "provider", "external_account_id", name="uq_social_account_workspace_provider_external"),
36
+ Index("ix_social_accounts_workspace_id", "workspace_id"),
37
+ Index("ix_social_accounts_provider_status", "provider", "status"),
38
+ )
39
+ id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
40
+ workspace_id: Mapped[str] = mapped_column(String(120), nullable=False)
41
+ provider: Mapped[str] = mapped_column(String(32), nullable=False)
42
+ account_type: Mapped[str] = mapped_column(String(64), nullable=False)
43
+ external_account_id: Mapped[str] = mapped_column(String(255), nullable=False)
44
+ username: Mapped[str | None] = mapped_column(String(255))
45
+ display_name: Mapped[str | None] = mapped_column(String(255))
46
+ avatar_url: Mapped[str | None] = mapped_column(String(2048))
47
+ status: Mapped[str] = mapped_column(String(32), nullable=False, default="pending")
48
+ metadata_json: Mapped[dict[str, object]] = mapped_column("metadata", JSON, nullable=False, default=dict)
49
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utcnow)
50
+ updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utcnow, onupdate=utcnow)
51
+ last_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
52
+
53
+
54
+ class SocialAccountToken(SocialBase):
55
+ __tablename__ = "social_account_tokens"
56
+ __table_args__ = (
57
+ UniqueConstraint("social_account_id", name="uq_social_account_token"),
58
+ Index("ix_social_account_tokens_expires_at", "expires_at"),
59
+ )
60
+ id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
61
+ social_account_id: Mapped[str] = mapped_column(String(36), ForeignKey("social_accounts.id", ondelete="CASCADE"), nullable=False)
62
+ access_token_secret_id: Mapped[str | None] = mapped_column(String(255))
63
+ refresh_token_secret_id: Mapped[str | None] = mapped_column(String(255))
64
+ encrypted_payload: Mapped[str | None] = mapped_column(Text)
65
+ expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
66
+ scopes: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list)
67
+ token_type: Mapped[str | None] = mapped_column(String(64))
68
+ last_refreshed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
69
+ revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
70
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utcnow)
71
+ updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utcnow, onupdate=utcnow)
72
+
73
+
74
+ class SocialAccountCapability(SocialBase):
75
+ __tablename__ = "social_account_capabilities"
76
+ __table_args__ = (
77
+ UniqueConstraint("social_account_id", "capability", name="uq_social_account_capability"),
78
+ Index("ix_social_account_capabilities_account", "social_account_id"),
79
+ )
80
+ id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
81
+ social_account_id: Mapped[str] = mapped_column(String(36), ForeignKey("social_accounts.id", ondelete="CASCADE"), nullable=False)
82
+ capability: Mapped[str] = mapped_column(String(100), nullable=False)
83
+ enabled: Mapped[bool] = mapped_column(nullable=False, default=False)
84
+ metadata_json: Mapped[dict[str, object]] = mapped_column("metadata", JSON, nullable=False, default=dict)
85
+ updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utcnow, onupdate=utcnow)
86
+
87
+
88
+ class MediaVariant(SocialBase):
89
+ __tablename__ = "media_variants"
90
+ __table_args__ = (
91
+ Index("ix_media_variants_workspace_source", "workspace_id", "source_asset_id"),
92
+ Index("ix_media_variants_platform", "platform"),
93
+ )
94
+ id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
95
+ workspace_id: Mapped[str] = mapped_column(String(120), nullable=False)
96
+ source_asset_id: Mapped[str] = mapped_column(String(255), nullable=False)
97
+ asset_reference: Mapped[str | None] = mapped_column(String(2048))
98
+ template_id: Mapped[str | None] = mapped_column(String(255))
99
+ platform: Mapped[str | None] = mapped_column(String(32))
100
+ width: Mapped[int | None] = mapped_column(Integer)
101
+ height: Mapped[int | None] = mapped_column(Integer)
102
+ duration_seconds: Mapped[float | None] = mapped_column()
103
+ codec: Mapped[str | None] = mapped_column(String(64))
104
+ container: Mapped[str | None] = mapped_column(String(64))
105
+ bitrate: Mapped[int | None] = mapped_column(BigInteger)
106
+ file_size: Mapped[int | None] = mapped_column(BigInteger)
107
+ metadata_json: Mapped[dict[str, object]] = mapped_column("metadata", JSON, nullable=False, default=dict)
108
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utcnow)
109
+
110
+
111
+ class SocialMediaAsset(SocialBase):
112
+ """A workspace-owned reference to an output published by MediaRouter.
113
+
114
+ The media processor remains the source of truth for files. This table
115
+ only records a tenant binding and immutable download locator so social
116
+ workers can validate and stream the exact output without trusting a
117
+ caller-supplied filesystem path or URL.
118
+ """
119
+
120
+ __tablename__ = "social_media_assets"
121
+ __table_args__ = (
122
+ UniqueConstraint("workspace_id", "request_id", "filename", name="uq_social_media_asset_workspace_output"),
123
+ Index("ix_social_media_assets_workspace_id", "workspace_id"),
124
+ )
125
+ id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
126
+ workspace_id: Mapped[str] = mapped_column(String(120), nullable=False)
127
+ request_id: Mapped[str] = mapped_column(String(36), nullable=False)
128
+ filename: Mapped[str] = mapped_column(String(255), nullable=False)
129
+ mime_type: Mapped[str] = mapped_column(String(255), nullable=False)
130
+ file_size: Mapped[int] = mapped_column(BigInteger, nullable=False)
131
+ metadata_json: Mapped[dict[str, object]] = mapped_column("metadata", JSON, nullable=False, default=dict)
132
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utcnow)
133
+
134
+
135
+ class SocialCampaign(SocialBase):
136
+ __tablename__ = "social_campaigns"
137
+ __table_args__ = (Index("ix_social_campaigns_workspace_id", "workspace_id"),)
138
+ id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
139
+ workspace_id: Mapped[str] = mapped_column(String(120), nullable=False)
140
+ name: Mapped[str] = mapped_column(String(255), nullable=False)
141
+ description: Mapped[str | None] = mapped_column(Text)
142
+ status: Mapped[str] = mapped_column(String(32), nullable=False, default="draft")
143
+ metadata_json: Mapped[dict[str, object]] = mapped_column("metadata", JSON, nullable=False, default=dict)
144
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utcnow)
145
+ updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utcnow, onupdate=utcnow)
146
+
147
+
148
+ class SocialPost(SocialBase):
149
+ __tablename__ = "social_posts"
150
+ __table_args__ = (
151
+ UniqueConstraint("workspace_id", "idempotency_key", name="uq_social_posts_workspace_idempotency"),
152
+ Index("ix_social_posts_workspace_created", "workspace_id", "created_at"),
153
+ Index("ix_social_posts_status", "status"),
154
+ )
155
+ id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
156
+ workspace_id: Mapped[str] = mapped_column(String(120), nullable=False)
157
+ campaign_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("social_campaigns.id", ondelete="SET NULL"))
158
+ media_asset_id: Mapped[str] = mapped_column(String(255), nullable=False)
159
+ source_variant_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("media_variants.id", ondelete="SET NULL"))
160
+ status: Mapped[str] = mapped_column(String(32), nullable=False, default="draft")
161
+ publish_mode: Mapped[str] = mapped_column(String(32), nullable=False, default="draft")
162
+ idempotency_key: Mapped[str | None] = mapped_column(String(255))
163
+ request_fingerprint: Mapped[str | None] = mapped_column(String(64))
164
+ metadata_json: Mapped[dict[str, object]] = mapped_column("metadata", JSON, nullable=False, default=dict)
165
+ created_by: Mapped[str | None] = mapped_column(String(120))
166
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utcnow)
167
+ updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utcnow, onupdate=utcnow)
168
+ published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
169
+
170
+
171
+ class SocialPostTarget(SocialBase):
172
+ __tablename__ = "social_post_targets"
173
+ __table_args__ = (
174
+ UniqueConstraint("social_post_id", "social_account_id", name="uq_social_post_target"),
175
+ Index("ix_social_post_targets_post", "social_post_id"),
176
+ Index("ix_social_post_targets_account", "social_account_id"),
177
+ Index("ix_social_post_targets_status", "status"),
178
+ )
179
+ id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
180
+ social_post_id: Mapped[str] = mapped_column(String(36), ForeignKey("social_posts.id", ondelete="CASCADE"), nullable=False)
181
+ social_account_id: Mapped[str] = mapped_column(String(36), ForeignKey("social_accounts.id", ondelete="RESTRICT"), nullable=False)
182
+ provider: Mapped[str] = mapped_column(String(32), nullable=False)
183
+ status: Mapped[str] = mapped_column(String(32), nullable=False, default="draft")
184
+ caption_json: Mapped[dict[str, object]] = mapped_column("caption", JSON, nullable=False, default=dict)
185
+ platform_metadata: Mapped[dict[str, object]] = mapped_column(JSON, nullable=False, default=dict)
186
+ external_post_id: Mapped[str | None] = mapped_column(String(255))
187
+ external_url: Mapped[str | None] = mapped_column(String(2048))
188
+ error_code: Mapped[str | None] = mapped_column(String(100))
189
+ error_message: Mapped[str | None] = mapped_column(Text)
190
+ published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
191
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utcnow)
192
+ updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utcnow, onupdate=utcnow)
193
+
194
+
195
+ class SocialPostMedia(SocialBase):
196
+ __tablename__ = "social_post_media"
197
+ __table_args__ = (Index("ix_social_post_media_post", "social_post_id"),)
198
+ id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
199
+ social_post_id: Mapped[str] = mapped_column(String(36), ForeignKey("social_posts.id", ondelete="CASCADE"), nullable=False)
200
+ media_variant_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("media_variants.id", ondelete="SET NULL"))
201
+ media_asset_id: Mapped[str | None] = mapped_column(String(255))
202
+ position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
203
+ kind: Mapped[str] = mapped_column(String(32), nullable=False, default="video")
204
+ metadata_json: Mapped[dict[str, object]] = mapped_column("metadata", JSON, nullable=False, default=dict)
205
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utcnow)
206
+
207
+
208
+ class SocialSchedule(SocialBase):
209
+ __tablename__ = "social_schedules"
210
+ __table_args__ = (
211
+ UniqueConstraint("social_post_id", name="uq_social_schedule_post"),
212
+ Index("ix_social_schedules_due", "status", "scheduled_at"),
213
+ )
214
+ id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
215
+ social_post_id: Mapped[str] = mapped_column(String(36), ForeignKey("social_posts.id", ondelete="CASCADE"), nullable=False)
216
+ scheduled_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
217
+ timezone: Mapped[str] = mapped_column(String(100), nullable=False)
218
+ status: Mapped[str] = mapped_column(String(32), nullable=False, default="scheduled")
219
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utcnow)
220
+ updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utcnow, onupdate=utcnow)
221
+
222
+
223
+ class SocialJob(SocialBase):
224
+ __tablename__ = "social_jobs"
225
+ __table_args__ = (
226
+ UniqueConstraint("workspace_id", "idempotency_key", name="uq_social_jobs_workspace_idempotency"),
227
+ Index("ix_social_jobs_workspace_status", "workspace_id", "status"),
228
+ Index("ix_social_jobs_next_attempt", "status", "next_attempt_at"),
229
+ Index("ix_social_jobs_post", "social_post_id"),
230
+ )
231
+ id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
232
+ workspace_id: Mapped[str] = mapped_column(String(120), nullable=False)
233
+ social_post_id: Mapped[str] = mapped_column(String(36), ForeignKey("social_posts.id", ondelete="CASCADE"), nullable=False)
234
+ social_post_target_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("social_post_targets.id", ondelete="CASCADE"))
235
+ provider: Mapped[str | None] = mapped_column(String(32))
236
+ status: Mapped[str] = mapped_column(String(32), nullable=False, default="queued")
237
+ attempt_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
238
+ max_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=5)
239
+ next_attempt_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
240
+ idempotency_key: Mapped[str | None] = mapped_column(String(255))
241
+ error_code: Mapped[str | None] = mapped_column(String(100))
242
+ error_message: Mapped[str | None] = mapped_column(Text)
243
+ payload_json: Mapped[dict[str, object]] = mapped_column("payload", JSON, nullable=False, default=dict)
244
+ # Provider resumable-session URLs are bearer-like credentials. They must
245
+ # survive a worker restart but must never be present in job REST/MCP/SDK
246
+ # payloads, so they are encrypted separately from payload JSON.
247
+ provider_state_encrypted: Mapped[str | None] = mapped_column(Text)
248
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utcnow)
249
+ started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
250
+ completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
251
+ updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utcnow, onupdate=utcnow)
252
+
253
+
254
+ class SocialJobAttempt(SocialBase):
255
+ __tablename__ = "social_job_attempts"
256
+ __table_args__ = (
257
+ UniqueConstraint("social_job_id", "attempt_number", name="uq_social_job_attempt_number"),
258
+ Index("ix_social_job_attempts_job", "social_job_id", "attempt_number"),
259
+ )
260
+ id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
261
+ social_job_id: Mapped[str] = mapped_column(String(36), ForeignKey("social_jobs.id", ondelete="CASCADE"), nullable=False)
262
+ attempt_number: Mapped[int] = mapped_column(Integer, nullable=False)
263
+ status: Mapped[str] = mapped_column(String(32), nullable=False)
264
+ error_code: Mapped[str | None] = mapped_column(String(100))
265
+ error_message: Mapped[str | None] = mapped_column(Text)
266
+ provider_request_id: Mapped[str | None] = mapped_column(String(255))
267
+ started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utcnow)
268
+ completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
269
+
270
+
271
+ class OAuthState(SocialBase):
272
+ __tablename__ = "oauth_states"
273
+ __table_args__ = (
274
+ Index("ix_oauth_states_state", "state", unique=True),
275
+ Index("ix_oauth_states_expires_at", "expires_at"),
276
+ Index("ix_oauth_states_workspace", "workspace_id"),
277
+ )
278
+ id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
279
+ state: Mapped[str] = mapped_column(String(255), nullable=False)
280
+ provider: Mapped[str] = mapped_column(String(32), nullable=False)
281
+ workspace_id: Mapped[str] = mapped_column(String(120), nullable=False)
282
+ user_id: Mapped[str | None] = mapped_column(String(120))
283
+ redirect_uri: Mapped[str] = mapped_column(String(2048), nullable=False)
284
+ code_verifier_encrypted: Mapped[str | None] = mapped_column(Text)
285
+ expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
286
+ used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
287
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utcnow)
288
+
289
+
290
+ class SocialWebhookEvent(SocialBase):
291
+ __tablename__ = "social_webhook_events"
292
+ __table_args__ = (
293
+ UniqueConstraint("provider", "external_event_id", name="uq_social_webhook_provider_external"),
294
+ Index("ix_social_webhook_events_status", "status"),
295
+ Index("ix_social_webhook_events_received", "received_at"),
296
+ )
297
+ id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
298
+ provider: Mapped[str] = mapped_column(String(32), nullable=False)
299
+ event_type: Mapped[str] = mapped_column(String(100), nullable=False)
300
+ external_event_id: Mapped[str] = mapped_column(String(255), nullable=False)
301
+ workspace_id: Mapped[str | None] = mapped_column(String(120))
302
+ payload_json: Mapped[dict[str, object]] = mapped_column("payload", JSON, nullable=False, default=dict)
303
+ received_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utcnow)
304
+ processed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
305
+ status: Mapped[str] = mapped_column(String(32), nullable=False, default="received")
306
+ error_message: Mapped[str | None] = mapped_column(Text)
307
+
308
+
309
+ class SocialPostMetric(SocialBase):
310
+ __tablename__ = "social_post_metrics"
311
+ __table_args__ = (Index("ix_social_post_metrics_target_retrieved", "social_post_target_id", "retrieved_at"),)
312
+ id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
313
+ social_post_id: Mapped[str] = mapped_column(String(36), ForeignKey("social_posts.id", ondelete="CASCADE"), nullable=False)
314
+ social_post_target_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("social_post_targets.id", ondelete="CASCADE"))
315
+ provider: Mapped[str] = mapped_column(String(32), nullable=False)
316
+ views: Mapped[int | None] = mapped_column(BigInteger)
317
+ impressions: Mapped[int | None] = mapped_column(BigInteger)
318
+ likes: Mapped[int | None] = mapped_column(BigInteger)
319
+ comments: Mapped[int | None] = mapped_column(BigInteger)
320
+ shares: Mapped[int | None] = mapped_column(BigInteger)
321
+ engagement_rate: Mapped[float | None] = mapped_column()
322
+ published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
323
+ retrieved_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utcnow)
324
+ raw_metrics: Mapped[dict[str, object]] = mapped_column(JSON, nullable=False, default=dict)
325
+
326
+
327
+ class SocialAuditEvent(SocialBase):
328
+ __tablename__ = "social_audit_events"
329
+ __table_args__ = (
330
+ Index("ix_social_audit_events_workspace_created", "workspace_id", "created_at"),
331
+ Index("ix_social_audit_events_type", "event_type"),
332
+ )
333
+ id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
334
+ workspace_id: Mapped[str] = mapped_column(String(120), nullable=False)
335
+ api_key_id: Mapped[str | None] = mapped_column(String(36))
336
+ event_type: Mapped[str] = mapped_column(String(100), nullable=False)
337
+ provider: Mapped[str | None] = mapped_column(String(32))
338
+ social_account_id: Mapped[str | None] = mapped_column(String(36))
339
+ social_post_id: Mapped[str | None] = mapped_column(String(36))
340
+ social_job_id: Mapped[str | None] = mapped_column(String(36))
341
+ request_id: Mapped[str | None] = mapped_column(String(64))
342
+ metadata_json: Mapped[dict[str, object]] = mapped_column("metadata", JSON, nullable=False, default=dict)
343
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utcnow)
app/social/oauth/__init__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ from app.social.oauth.encryption import TokenCipher
2
+ from app.social.oauth.state import OAuthStateService
3
+
4
+ __all__ = ["OAuthStateService", "TokenCipher"]
app/social/oauth/base.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Protocol
4
+
5
+
6
+ class OAuthProvider(Protocol):
7
+ async def get_authorization_url(self, *, state: str, redirect_uri: str) -> str:
8
+ ...
9
+
10
+ async def exchange_code(
11
+ self, *, code: str, redirect_uri: str
12
+ ) -> dict[str, object]:
13
+ ...
14
+
15
+ async def refresh_token(self, token: dict[str, object]) -> dict[str, object]:
16
+ ...
17
+
18
+ async def revoke_token(self, token: dict[str, object]) -> None:
19
+ ...
app/social/oauth/encryption.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import hashlib
5
+ import json
6
+ from typing import Any
7
+
8
+ from app.social.domain.errors import SocialProviderUnavailableError
9
+
10
+
11
+ class TokenCipher:
12
+ """Narrow encryption boundary for non-Vault local development.
13
+
14
+ Imports cryptography lazily so provider discovery and existing media APIs
15
+ still start when social token storage is unused. Production should use
16
+ Supabase Vault references instead of this encrypted database fallback.
17
+ """
18
+
19
+ def __init__(self, key: str | None) -> None:
20
+ self._key = key
21
+
22
+ def _fernet(self):
23
+ if not self._key:
24
+ raise SocialProviderUnavailableError(
25
+ "SOCIAL_OAUTH_ENCRYPTION_KEY is required when Supabase Vault is disabled."
26
+ )
27
+ try:
28
+ from cryptography.fernet import Fernet
29
+ except ImportError as exc:
30
+ raise SocialProviderUnavailableError("The cryptography package is required for token storage.") from exc
31
+ digest = hashlib.sha256(self._key.encode("utf-8")).digest()
32
+ return Fernet(base64.urlsafe_b64encode(digest))
33
+
34
+ def encrypt(self, value: dict[str, Any]) -> str:
35
+ return self._fernet().encrypt(json.dumps(value, separators=(",", ":")).encode()).decode()
36
+
37
+ def decrypt(self, value: str) -> dict[str, Any]:
38
+ result = json.loads(self._fernet().decrypt(value.encode()).decode())
39
+ return result if isinstance(result, dict) else {}
app/social/oauth/state.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import secrets
4
+ from datetime import datetime, timedelta, timezone
5
+
6
+ from sqlalchemy import update
7
+
8
+ from app.social.database import SocialDatabase
9
+ from app.social.domain.errors import SocialOAuthStateError
10
+ from app.social.models import OAuthState
11
+ from app.social.oauth.encryption import TokenCipher
12
+
13
+
14
+ class OAuthStateService:
15
+ def __init__(self, database: SocialDatabase, cipher: TokenCipher) -> None:
16
+ self.database = database
17
+ self.cipher = cipher
18
+
19
+ async def create(self, *, provider: str, workspace_id: str, user_id: str, redirect_uri: str, ttl_seconds: int = 600) -> OAuthState:
20
+ record = OAuthState(
21
+ state=secrets.token_urlsafe(32), provider=provider, workspace_id=workspace_id,
22
+ user_id=user_id, redirect_uri=redirect_uri,
23
+ code_verifier_encrypted=self.cipher.encrypt({"verifier": secrets.token_urlsafe(48)}),
24
+ expires_at=datetime.now(timezone.utc) + timedelta(seconds=ttl_seconds),
25
+ )
26
+ async with self.database.session(workspace_id) as session:
27
+ session.add(record)
28
+ await session.commit()
29
+ await session.refresh(record)
30
+ return record
31
+
32
+ async def consume(self, *, state: str, provider: str) -> OAuthState:
33
+ now = datetime.now(timezone.utc)
34
+ async with self.database.session() as session:
35
+ record = await session.scalar(
36
+ update(OAuthState)
37
+ .where(
38
+ OAuthState.state == state,
39
+ OAuthState.provider == provider,
40
+ OAuthState.used_at.is_(None),
41
+ OAuthState.expires_at > now,
42
+ )
43
+ .values(used_at=now)
44
+ .returning(OAuthState)
45
+ )
46
+ if record is None:
47
+ raise SocialOAuthStateError("OAuth state is invalid, expired, or already used.")
48
+ await session.commit()
49
+ return record
50
+
51
+ def code_verifier(self, record: OAuthState) -> str | None:
52
+ if not record.code_verifier_encrypted:
53
+ return None
54
+ value = self.cipher.decrypt(record.code_verifier_encrypted)
55
+ verifier = value.get("verifier")
56
+ return str(verifier) if verifier else None
app/social/providers/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from app.social.providers.registry import ProviderRegistry, build_provider_registry
2
+
3
+ __all__ = ["ProviderRegistry", "build_provider_registry"]
app/social/providers/base.py ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC
4
+ from typing import Any
5
+
6
+ from app.social.domain.capabilities import ProviderCapabilities
7
+ from app.social.domain.errors import (
8
+ SocialCapabilityUnsupportedError,
9
+ SocialProviderNotImplementedError,
10
+ SocialPublishFailedError,
11
+ )
12
+
13
+
14
+ class SocialProviderAdapter(ABC):
15
+ """Common adapter contract; unsupported functions fail explicitly."""
16
+
17
+ capabilities: ProviderCapabilities
18
+ # Adapters opt out only when their official web OAuth contract does not
19
+ # define PKCE. OAuthService remains the single orchestration layer.
20
+ pkce_supported: bool = True
21
+ reconciliation_poll_seconds: int = 30
22
+
23
+ @property
24
+ def provider(self) -> str:
25
+ return self.capabilities.provider.value
26
+
27
+ async def get_authorization_url(
28
+ self,
29
+ *,
30
+ state: str,
31
+ redirect_uri: str,
32
+ code_challenge: str | None = None,
33
+ additional_scopes: list[str] | None = None,
34
+ ) -> str:
35
+ raise SocialProviderNotImplementedError(f"{self.provider} account connection is not implemented.")
36
+
37
+ async def exchange_code(
38
+ self, *, code: str, redirect_uri: str, code_verifier: str | None = None
39
+ ) -> dict[str, Any]:
40
+ raise SocialProviderNotImplementedError(f"{self.provider} OAuth exchange is not implemented.")
41
+
42
+ async def refresh_token(self, token: dict[str, Any]) -> dict[str, Any]:
43
+ raise SocialProviderNotImplementedError(f"{self.provider} token refresh is not implemented.")
44
+
45
+ async def revoke_token(self, token: dict[str, Any]) -> None:
46
+ raise SocialProviderNotImplementedError(f"{self.provider} token revocation is not implemented.")
47
+
48
+ async def get_account(self, token: dict[str, Any]) -> dict[str, Any]:
49
+ raise SocialProviderNotImplementedError(f"{self.provider} account discovery is not implemented.")
50
+
51
+ async def get_capabilities(self) -> ProviderCapabilities:
52
+ return self.capabilities
53
+
54
+ async def get_publish_options(self, token: dict[str, Any]) -> dict[str, Any]:
55
+ if not self.capabilities.publish_supported:
56
+ raise SocialCapabilityUnsupportedError(
57
+ f"{self.provider} publishing is unavailable."
58
+ )
59
+ raise SocialProviderNotImplementedError(
60
+ f"{self.provider} publish options are not implemented."
61
+ )
62
+
63
+ async def validate_media(self, media: dict[str, Any]) -> None:
64
+ if not self.capabilities.publish_supported:
65
+ raise SocialProviderNotImplementedError(f"{self.provider} publishing is not implemented.")
66
+
67
+ async def upload_media(self, token: dict[str, Any], media: dict[str, Any]) -> dict[str, Any]:
68
+ raise SocialProviderNotImplementedError(f"{self.provider} media upload is not implemented.")
69
+
70
+ async def publish(self, token: dict[str, Any], payload: dict[str, Any]) -> dict[str, Any]:
71
+ raise SocialProviderNotImplementedError(f"{self.provider} publishing is not implemented.")
72
+
73
+ async def get_publish_status(self, token: dict[str, Any], external_id: str) -> dict[str, Any]:
74
+ raise SocialProviderNotImplementedError(f"{self.provider} publish status is not implemented.")
75
+
76
+ def publish_failure(self, result: dict[str, Any]) -> Exception:
77
+ """Normalize an unsuccessful provider status without leaking its payload."""
78
+ del result
79
+ return SocialPublishFailedError(
80
+ f"{self.provider} publishing did not complete successfully."
81
+ )
82
+
83
+ async def delete_post(self, token: dict[str, Any], external_id: str) -> None:
84
+ if not self.capabilities.delete_post:
85
+ raise SocialCapabilityUnsupportedError(f"{self.provider} does not support post deletion.")
86
+ raise SocialProviderNotImplementedError(f"{self.provider} post deletion is not implemented.")
87
+
88
+ async def get_metrics(self, token: dict[str, Any], external_id: str) -> dict[str, Any]:
89
+ if not self.capabilities.analytics:
90
+ raise SocialCapabilityUnsupportedError(f"{self.provider} analytics are unavailable.")
91
+ raise SocialProviderNotImplementedError(f"{self.provider} analytics are not implemented.")
app/social/providers/facebook.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ from typing import Any
5
+
6
+ import httpx
7
+
8
+ from app.core.config import Settings
9
+ from app.social.domain.capabilities import ProviderCapabilities
10
+ from app.social.domain.enums import ConnectionStrategy, Provider
11
+ from app.social.providers.meta_graph import MetaGraphClient, insight_values
12
+ from app.social.providers.oauth import OAuthFoundationAdapter
13
+
14
+
15
+ class FacebookProvider(OAuthFoundationAdapter):
16
+
17
+ def __init__(
18
+ self, settings: Settings, *, http_client: httpx.AsyncClient | None = None
19
+ ) -> None:
20
+ self.client_id = settings.resolved_meta_app_id
21
+ self.authorization_endpoint = (
22
+ f"https://www.facebook.com/{settings.meta_graph_api_version}/dialog/oauth"
23
+ )
24
+ self._graph = MetaGraphClient(settings, http_client=http_client)
25
+ self.capabilities = ProviderCapabilities(
26
+ provider=Provider.FACEBOOK, connection_strategy=ConnectionStrategy.OAUTH,
27
+ implementation_status="registered",
28
+ account_types=["facebook_page"],
29
+ # The Graph endpoint and normalization are implemented, but these
30
+ # are opt-in scopes. A standard connection is never escalated.
31
+ analytics=True,
32
+ analytics_required_scopes=["pages_read_engagement", "read_insights"],
33
+ )
34
+
35
+ async def close(self) -> None:
36
+ await self._graph.close()
37
+
38
+ async def get_metrics(
39
+ self, token: dict[str, Any], external_id: str
40
+ ) -> dict[str, Any]:
41
+ """Read actual Page-post data using the official Graph API.
42
+
43
+ The compact fields request avoids deprecated aggregate guessing. A
44
+ missing field stays absent in the normalized response; zero is never
45
+ inferred from an empty Meta response.
46
+ """
47
+
48
+ payload = await self._graph.get(
49
+ external_id,
50
+ _token_mapping(token),
51
+ params={
52
+ "fields": (
53
+ "created_time,"
54
+ "insights.metric(post_impressions,post_engaged_users,"
55
+ "post_clicks,post_video_views),"
56
+ "reactions.limit(0).summary(true),"
57
+ "comments.limit(0).summary(true),shares"
58
+ )
59
+ },
60
+ )
61
+ insight_payload = payload.get("insights")
62
+ insights = insight_values(insight_payload) if isinstance(insight_payload, dict) else {}
63
+ normalized: dict[str, Any] = {
64
+ "status": "available",
65
+ "raw_metrics": {
66
+ "post_insights": insight_payload if isinstance(insight_payload, dict) else {},
67
+ "reactions": payload.get("reactions"),
68
+ "comments": payload.get("comments"),
69
+ "shares": payload.get("shares"),
70
+ },
71
+ }
72
+ _set_number(normalized, "impressions", insights.get("post_impressions"))
73
+ _set_number(normalized, "views", insights.get("post_video_views"))
74
+ _set_number(normalized, "engaged_users", insights.get("post_engaged_users"))
75
+ _set_number(normalized, "clicks", insights.get("post_clicks"))
76
+ _set_number(normalized, "likes", _summary_count(payload.get("reactions")))
77
+ _set_number(normalized, "comments", _summary_count(payload.get("comments")))
78
+ shares = payload.get("shares")
79
+ if isinstance(shares, dict):
80
+ _set_number(normalized, "shares", shares.get("count"))
81
+ if isinstance(payload.get("created_time"), str):
82
+ normalized["published_at"] = payload["created_time"]
83
+ if not any(
84
+ key in normalized
85
+ for key in ("impressions", "views", "engaged_users", "clicks", "likes", "comments", "shares")
86
+ ):
87
+ return {
88
+ "status": "unavailable",
89
+ "reason": "META_METRICS_NOT_AVAILABLE",
90
+ "raw_metrics": normalized["raw_metrics"],
91
+ }
92
+ return normalized
93
+
94
+
95
+ def _token_mapping(token: dict[str, Any]) -> Mapping[str, object]:
96
+ return token
97
+
98
+
99
+ def _summary_count(value: object) -> int | float | None:
100
+ if not isinstance(value, dict):
101
+ return None
102
+ summary = value.get("summary")
103
+ if not isinstance(summary, dict):
104
+ return None
105
+ count = summary.get("total_count")
106
+ return count if isinstance(count, (int, float)) and not isinstance(count, bool) else None
107
+
108
+
109
+ def _set_number(target: dict[str, Any], key: str, value: object) -> None:
110
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
111
+ target[key] = value
app/social/providers/instagram.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ from typing import Any
5
+
6
+ import httpx
7
+
8
+ from app.core.config import Settings
9
+ from app.social.domain.capabilities import ProviderCapabilities
10
+ from app.social.domain.enums import ConnectionStrategy, Provider
11
+ from app.social.providers.meta_graph import MetaGraphClient, insight_values
12
+ from app.social.providers.oauth import OAuthFoundationAdapter
13
+
14
+
15
+ class InstagramProvider(OAuthFoundationAdapter):
16
+
17
+ def __init__(
18
+ self, settings: Settings, *, http_client: httpx.AsyncClient | None = None
19
+ ) -> None:
20
+ self.client_id = settings.resolved_meta_app_id
21
+ self.authorization_endpoint = (
22
+ f"https://www.facebook.com/{settings.meta_graph_api_version}/dialog/oauth"
23
+ )
24
+ self._graph = MetaGraphClient(settings, http_client=http_client)
25
+ self.capabilities = ProviderCapabilities(
26
+ provider=Provider.INSTAGRAM, connection_strategy=ConnectionStrategy.OAUTH,
27
+ implementation_status="registered",
28
+ account_types=["instagram_professional_account"],
29
+ analytics=True,
30
+ analytics_required_scopes=["instagram_basic", "instagram_manage_insights"],
31
+ )
32
+
33
+ async def close(self) -> None:
34
+ await self._graph.close()
35
+
36
+ async def get_metrics(
37
+ self, token: dict[str, Any], external_id: str
38
+ ) -> dict[str, Any]:
39
+ """Fetch Instagram professional-media insights by product type.
40
+
41
+ Meta does not support one universal metrics set. Querying the media
42
+ object first prevents an unsupported-metric request for albums and
43
+ ensures Reels use their documented metric family.
44
+ """
45
+
46
+ media = await self._graph.get(
47
+ external_id,
48
+ _token_mapping(token),
49
+ params={"fields": "media_product_type,media_type,timestamp,permalink"},
50
+ )
51
+ product_type = str(media.get("media_product_type", "")).upper()
52
+ media_type = str(media.get("media_type", "")).upper()
53
+ metrics = _metrics_for(product_type, media_type)
54
+ if not metrics:
55
+ return {
56
+ "status": "unavailable",
57
+ "reason": "INSTAGRAM_MEDIA_TYPE_ANALYTICS_UNAVAILABLE",
58
+ "raw_metrics": {"media": media},
59
+ }
60
+ payload = await self._graph.get(
61
+ f"{external_id}/insights",
62
+ _token_mapping(token),
63
+ params={"metric": ",".join(metrics)},
64
+ )
65
+ values = insight_values(payload)
66
+ if not values:
67
+ # Meta documents an empty data set for unavailable data. It is not
68
+ # a numeric zero and must not be stored as one.
69
+ return {
70
+ "status": "unavailable",
71
+ "reason": "META_METRICS_NOT_AVAILABLE",
72
+ "raw_metrics": {"media": media, "insights": payload},
73
+ }
74
+ normalized: dict[str, Any] = {
75
+ "status": "available",
76
+ "raw_metrics": {"media": media, "insights": payload},
77
+ }
78
+ for key in ("views", "reach", "likes", "comments", "shares", "saved"):
79
+ _set_number(normalized, key, values.get(key))
80
+ if isinstance(media.get("timestamp"), str):
81
+ normalized["published_at"] = media["timestamp"]
82
+ if isinstance(media.get("permalink"), str):
83
+ normalized["url"] = media["permalink"]
84
+ return normalized
85
+
86
+
87
+ def _metrics_for(product_type: str, media_type: str) -> list[str]:
88
+ if product_type == "REELS":
89
+ return ["views", "reach", "likes", "comments", "shares", "saved"]
90
+ if product_type == "FEED":
91
+ # `views` is applicable only to playable feed video. Image posts use
92
+ # the common engagement/reach metrics.
93
+ metrics = ["reach", "likes", "comments", "shares", "saved"]
94
+ if media_type == "VIDEO":
95
+ metrics.insert(0, "views")
96
+ return metrics
97
+ # Meta documents no per-item insights for an album. Do not issue a request
98
+ # that would either fail or tempt callers to fabricate an aggregate.
99
+ return []
100
+
101
+
102
+ def _token_mapping(token: dict[str, Any]) -> Mapping[str, object]:
103
+ return token
104
+
105
+
106
+ def _set_number(target: dict[str, Any], key: str, value: object) -> None:
107
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
108
+ target[key] = value
app/social/providers/linkedin.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from app.core.config import Settings
2
+ from app.social.domain.capabilities import ProviderCapabilities
3
+ from app.social.domain.enums import ConnectionStrategy, Provider
4
+ from app.social.providers.oauth import OAuthFoundationAdapter
5
+
6
+
7
+ class LinkedInProvider(OAuthFoundationAdapter):
8
+ authorization_endpoint = "https://www.linkedin.com/oauth/v2/authorization"
9
+
10
+ def __init__(self, settings: Settings) -> None:
11
+ self.client_id = settings.linkedin_client_id
12
+ self.capabilities = ProviderCapabilities(
13
+ provider=Provider.LINKEDIN, connection_strategy=ConnectionStrategy.OAUTH,
14
+ implementation_status="registered", account_types=["member", "organization"],
15
+ )
app/social/providers/meta.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ """Shared Meta family marker; Facebook Pages and Instagram accounts stay distinct."""
2
+
3
+ META_PROVIDER_FAMILY = frozenset({"facebook", "instagram"})
app/social/providers/meta_graph.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Official Meta Graph API helpers shared by Meta analytics adapters.
2
+
3
+ Access tokens are sent as Authorization bearer credentials instead of URL
4
+ parameters. This prevents accidental credential capture by URL logging,
5
+ proxies, exceptions, or observability tooling.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Mapping
11
+ from typing import Any
12
+
13
+ import httpx
14
+
15
+ from app.core.config import Settings
16
+ from app.social.domain.errors import (
17
+ SocialPermissionDeniedError,
18
+ SocialProviderUnavailableError,
19
+ SocialRateLimitedError,
20
+ SocialReauthRequiredError,
21
+ )
22
+
23
+ _GRAPH_API_ROOT = "https://graph.facebook.com"
24
+ _AUTHENTICATION_ERROR_CODES = frozenset({102, 190})
25
+ _PERMISSION_ERROR_CODES = frozenset({10, 200, 299})
26
+ _RATE_LIMIT_ERROR_CODES = frozenset({4, 17, 32, 613})
27
+
28
+
29
+ class MetaGraphClient:
30
+ """Minimal Graph transport with normalized, non-sensitive errors."""
31
+
32
+ def __init__(
33
+ self, settings: Settings, *, http_client: httpx.AsyncClient | None = None
34
+ ) -> None:
35
+ self._version = settings.meta_graph_api_version
36
+ self._client = http_client or httpx.AsyncClient(
37
+ timeout=httpx.Timeout(30.0), follow_redirects=False
38
+ )
39
+ self._owns_client = http_client is None
40
+
41
+ async def close(self) -> None:
42
+ if self._owns_client:
43
+ await self._client.aclose()
44
+
45
+ async def get(
46
+ self, object_path: str, token: Mapping[str, object], *, params: Mapping[str, str]
47
+ ) -> dict[str, Any]:
48
+ access_token = token.get("access_token")
49
+ if not isinstance(access_token, str) or not access_token:
50
+ raise SocialReauthRequiredError("The Meta account requires reauthorization.")
51
+ try:
52
+ response = await self._client.get(
53
+ f"{_GRAPH_API_ROOT}/{self._version}/{object_path.lstrip('/')}",
54
+ params=dict(params),
55
+ headers={"Authorization": f"Bearer {access_token}"},
56
+ )
57
+ except httpx.TransportError as exc:
58
+ raise SocialProviderUnavailableError(
59
+ "Meta analytics is temporarily unavailable."
60
+ ) from exc
61
+ if response.is_success:
62
+ try:
63
+ payload = response.json()
64
+ except ValueError as exc:
65
+ raise SocialProviderUnavailableError(
66
+ "Meta returned an invalid analytics response."
67
+ ) from exc
68
+ if isinstance(payload, dict):
69
+ return payload
70
+ raise SocialProviderUnavailableError("Meta returned an invalid analytics response.")
71
+ self._raise_graph_error(response)
72
+ raise AssertionError("Meta graph error mapping must raise")
73
+
74
+ @staticmethod
75
+ def _raise_graph_error(response: httpx.Response) -> None:
76
+ code: int | None = None
77
+ try:
78
+ payload = response.json()
79
+ error = payload.get("error", {}) if isinstance(payload, dict) else {}
80
+ raw_code = error.get("code") if isinstance(error, dict) else None
81
+ code = int(raw_code) if raw_code is not None else None
82
+ except (TypeError, ValueError):
83
+ pass
84
+ if response.status_code == 401 or code in _AUTHENTICATION_ERROR_CODES:
85
+ raise SocialReauthRequiredError("The Meta account requires reauthorization.")
86
+ if response.status_code == 403 or code in _PERMISSION_ERROR_CODES:
87
+ raise SocialPermissionDeniedError("Meta analytics permission was denied.")
88
+ if response.status_code == 429 or code in _RATE_LIMIT_ERROR_CODES:
89
+ raise SocialRateLimitedError("Meta analytics rate limit reached.")
90
+ if response.status_code >= 500 or code == 1:
91
+ raise SocialProviderUnavailableError("Meta analytics is temporarily unavailable.")
92
+ raise SocialProviderUnavailableError("Meta analytics request was rejected.")
93
+
94
+
95
+ def insight_values(payload: Mapping[str, Any]) -> dict[str, int | float | None]:
96
+ """Extract only scalar values Meta actually returned for each metric."""
97
+
98
+ metrics: dict[str, int | float | None] = {}
99
+ raw_data = payload.get("data")
100
+ if not isinstance(raw_data, list):
101
+ return metrics
102
+ for item in raw_data:
103
+ if not isinstance(item, dict) or not isinstance(item.get("name"), str):
104
+ continue
105
+ value: Any = None
106
+ values = item.get("values")
107
+ if isinstance(values, list) and values and isinstance(values[-1], dict):
108
+ value = values[-1].get("value")
109
+ elif isinstance(item.get("total_value"), dict):
110
+ value = item["total_value"].get("value")
111
+ if isinstance(value, bool):
112
+ continue
113
+ if isinstance(value, (int, float)):
114
+ metrics[item["name"]] = value
115
+ return metrics
app/social/providers/oauth.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from urllib.parse import urlencode
4
+
5
+ from app.social.domain.errors import SocialProviderUnavailableError
6
+ from app.social.providers.base import SocialProviderAdapter
7
+
8
+
9
+ class OAuthFoundationAdapter(SocialProviderAdapter):
10
+ authorization_endpoint: str
11
+ client_id: str
12
+
13
+ async def get_authorization_url(
14
+ self,
15
+ *,
16
+ state: str,
17
+ redirect_uri: str,
18
+ code_challenge: str | None = None,
19
+ additional_scopes: list[str] | None = None,
20
+ ) -> str:
21
+ if not self.client_id:
22
+ raise SocialProviderUnavailableError(
23
+ f"{self.provider} OAuth credentials are not configured."
24
+ )
25
+ params = {
26
+ "client_id": self.client_id,
27
+ "redirect_uri": redirect_uri,
28
+ "response_type": "code",
29
+ "state": state,
30
+ "scope": " ".join(
31
+ dict.fromkeys(
32
+ [*self.capabilities.required_scopes, *(additional_scopes or [])]
33
+ )
34
+ ),
35
+ }
36
+ if code_challenge:
37
+ params["code_challenge"] = code_challenge
38
+ params["code_challenge_method"] = "S256"
39
+ return f"{self.authorization_endpoint}?{urlencode(params)}"
app/social/providers/registry.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from app.core.config import Settings
4
+ from app.social.domain.enums import Provider
5
+ from app.social.domain.errors import SocialProviderUnavailableError
6
+ from app.social.providers.base import SocialProviderAdapter
7
+ from app.social.providers.facebook import FacebookProvider
8
+ from app.social.providers.instagram import InstagramProvider
9
+ from app.social.providers.linkedin import LinkedInProvider
10
+ from app.social.providers.telegram import TelegramProvider
11
+ from app.social.providers.tiktok import TikTokProvider
12
+ from app.social.providers.whatsapp import WhatsAppProvider
13
+ from app.social.providers.x import XProvider
14
+ from app.social.providers.youtube import YouTubeProvider
15
+
16
+
17
+ class ProviderRegistry:
18
+ def __init__(self, providers: list[SocialProviderAdapter]) -> None:
19
+ self._providers = {provider.provider: provider for provider in providers}
20
+
21
+ def list(self) -> list[SocialProviderAdapter]:
22
+ return [self._providers[key] for key in sorted(self._providers)]
23
+
24
+ def get(self, provider: str | Provider) -> SocialProviderAdapter:
25
+ key = provider.value if isinstance(provider, Provider) else provider.strip().lower()
26
+ try:
27
+ return self._providers[key]
28
+ except KeyError as exc:
29
+ raise SocialProviderUnavailableError(f"Unknown social provider '{key}'.") from exc
30
+
31
+ async def close(self) -> None:
32
+ for provider in self._providers.values():
33
+ close = getattr(provider, "close", None)
34
+ if close is not None:
35
+ await close()
36
+
37
+
38
+ def build_provider_registry(settings: Settings) -> ProviderRegistry:
39
+ return ProviderRegistry([
40
+ YouTubeProvider(settings), FacebookProvider(settings), InstagramProvider(settings),
41
+ TikTokProvider(settings), XProvider(settings), LinkedInProvider(settings),
42
+ TelegramProvider(settings), WhatsAppProvider(settings),
43
+ ])
app/social/providers/telegram.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from app.core.config import Settings
2
+ from app.social.domain.capabilities import ProviderCapabilities
3
+ from app.social.domain.enums import ConnectionStrategy, Provider
4
+ from app.social.providers.base import SocialProviderAdapter
5
+
6
+
7
+ class TelegramProvider(SocialProviderAdapter):
8
+ def __init__(self, settings: Settings) -> None:
9
+ self.capabilities = ProviderCapabilities(
10
+ provider=Provider.TELEGRAM, connection_strategy=ConnectionStrategy.TOKEN_BOT,
11
+ account_types=["bot", "channel"],
12
+ implementation_status="registered",
13
+ )
app/social/providers/tiktok.py ADDED
@@ -0,0 +1,1131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Official TikTok Login Kit and Content Posting Direct Post adapter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ import re
8
+ from pathlib import Path
9
+ from typing import Any
10
+ from urllib.parse import urlencode, urlparse
11
+
12
+ import aiofiles
13
+ import httpx
14
+
15
+ from app.core.config import Settings
16
+ from app.social.domain.capabilities import ProviderCapabilities
17
+ from app.social.domain.enums import ConnectionStrategy, Provider
18
+ from app.social.domain.errors import (
19
+ SocialCapabilityUnsupportedError,
20
+ SocialMediaInvalidError,
21
+ SocialPermissionDeniedError,
22
+ SocialProviderUnavailableError,
23
+ SocialPublishFailedError,
24
+ SocialRateLimitedError,
25
+ SocialReauthRequiredError,
26
+ )
27
+ from app.social.providers.oauth import OAuthFoundationAdapter
28
+ from app.social.security import public_provider_data
29
+
30
+ _TIKTOK_OPEN_API = "https://open.tiktokapis.com"
31
+ _TOKEN_ENDPOINT = f"{_TIKTOK_OPEN_API}/v2/oauth/token/"
32
+ _REVOKE_ENDPOINT = f"{_TIKTOK_OPEN_API}/v2/oauth/revoke/"
33
+ _USER_INFO_ENDPOINT = f"{_TIKTOK_OPEN_API}/v2/user/info/"
34
+ _CREATOR_INFO_ENDPOINT = f"{_TIKTOK_OPEN_API}/v2/post/publish/creator_info/query/"
35
+ _DIRECT_POST_INIT_ENDPOINT = f"{_TIKTOK_OPEN_API}/v2/post/publish/video/init/"
36
+ _PUBLISH_STATUS_ENDPOINT = f"{_TIKTOK_OPEN_API}/v2/post/publish/status/fetch/"
37
+ _VIDEO_QUERY_ENDPOINT = f"{_TIKTOK_OPEN_API}/v2/video/query/"
38
+ _USER_FIELDS = "open_id,union_id,avatar_url,display_name"
39
+ _VIDEO_ANALYTICS_FIELDS = (
40
+ "id,create_time,share_url,title,video_description,duration,height,width,"
41
+ "like_count,comment_count,share_count,view_count"
42
+ )
43
+ _RETRYABLE = frozenset({429, 500, 502, 503, 504})
44
+ _MIN_CHUNK = 5_000_000
45
+ _MAX_CHUNK = 64_000_000
46
+ _MAX_FINAL_CHUNK = 128_000_000
47
+ _MAX_VIDEO_SIZE = 4_000_000_000
48
+ _MAX_VIDEO_DURATION = 600.0
49
+ _CONTENT_RANGE = re.compile(r"bytes\s+0-(\d+)/(\d+)", re.IGNORECASE)
50
+ _MEDIA_FAILURES = frozenset(
51
+ {
52
+ "file_format_check_failed",
53
+ "duration_check_failed",
54
+ "frame_rate_check_failed",
55
+ "picture_size_check_failed",
56
+ }
57
+ )
58
+
59
+
60
+ class TikTokProvider(OAuthFoundationAdapter):
61
+ """TikTok Web Login Kit plus audited Direct Post video publishing."""
62
+
63
+ authorization_endpoint = "https://www.tiktok.com/v2/auth/authorize/"
64
+ pkce_supported = False
65
+
66
+ def __init__(
67
+ self, settings: Settings, *, http_client: httpx.AsyncClient | None = None
68
+ ) -> None:
69
+ self.settings = settings
70
+ self.reconciliation_poll_seconds = settings.tiktok_processing_poll_seconds
71
+ self.client_id = settings.tiktok_client_key
72
+ self._client_secret = (
73
+ settings.tiktok_client_secret.get_secret_value()
74
+ if settings.tiktok_client_secret
75
+ else ""
76
+ )
77
+ self.redirect_uri = settings.tiktok_redirect_uri.strip()
78
+ self.configuration_ready = bool(
79
+ self.client_id and self._client_secret and self.redirect_uri
80
+ )
81
+ self._client = http_client or httpx.AsyncClient(
82
+ timeout=httpx.Timeout(settings.tiktok_request_timeout_seconds),
83
+ follow_redirects=False,
84
+ )
85
+ self._owns_client = http_client is None
86
+ # Capability advertisement is fail-closed until both the operator gate
87
+ # and complete backend credentials/callback configuration are present.
88
+ direct_post = settings.tiktok_direct_post_enabled and self.configuration_ready
89
+ self.capabilities = ProviderCapabilities(
90
+ provider=Provider.TIKTOK,
91
+ connection_strategy=ConnectionStrategy.OAUTH,
92
+ implementation_status="implemented",
93
+ account_types=["creator"],
94
+ required_scopes=["user.info.basic"],
95
+ optional_scopes=["video.publish", "video.list"],
96
+ publishing_required_scopes=["video.publish"] if direct_post else [],
97
+ # TikTok Display API video/query is independently authorized. The
98
+ # normal connection and publishing flows never request video.list.
99
+ analytics=self.configuration_ready,
100
+ analytics_required_scopes=["video.list"] if self.configuration_ready else [],
101
+ video=direct_post,
102
+ video_upload=direct_post,
103
+ video_status=direct_post,
104
+ direct_publish=direct_post,
105
+ # MediaRouter dispatches scheduled jobs at the canonical UTC time.
106
+ # TikTok currently exposes no native scheduling parameter.
107
+ scheduled_publish=direct_post,
108
+ native_scheduling=False,
109
+ delete_post=False,
110
+ publish_metadata_schema=(
111
+ {
112
+ "namespace": "tiktok",
113
+ "media_types": ["video"],
114
+ "fields": [
115
+ {"name": "title", "label": "Caption", "type": "text", "required": False, "max_length": 2200},
116
+ {"name": "privacy_level", "label": "Privacy", "type": "select", "required": True, "options_source": "privacy_level_options"},
117
+ {"name": "disable_comment", "label": "Disable comments", "type": "boolean", "required": False},
118
+ {"name": "disable_duet", "label": "Disable Duet", "type": "boolean", "required": False},
119
+ {"name": "disable_stitch", "label": "Disable Stitch", "type": "boolean", "required": False},
120
+ {"name": "brand_content_toggle", "label": "Paid partnership", "type": "boolean", "required": True},
121
+ {"name": "brand_organic_toggle", "label": "Promotes own brand", "type": "boolean", "required": True},
122
+ {"name": "is_aigc", "label": "AI-generated content", "type": "boolean", "required": True},
123
+ {"name": "music_usage_confirmed", "label": "I agree to TikTok's Music Usage Confirmation", "type": "confirmation", "required": True},
124
+ ],
125
+ }
126
+ if direct_post
127
+ else {}
128
+ ),
129
+ )
130
+
131
+ async def close(self) -> None:
132
+ if self._owns_client:
133
+ await self._client.aclose()
134
+
135
+ async def get_authorization_url(
136
+ self,
137
+ *,
138
+ state: str,
139
+ redirect_uri: str,
140
+ code_challenge: str | None = None,
141
+ additional_scopes: list[str] | None = None,
142
+ ) -> str:
143
+ if not self.client_id:
144
+ raise SocialProviderUnavailableError(
145
+ "TikTok OAuth credentials are not configured."
146
+ )
147
+ scopes = list(
148
+ dict.fromkeys(
149
+ [*self.capabilities.required_scopes, *(additional_scopes or [])]
150
+ )
151
+ )
152
+ params = {
153
+ "client_key": self.client_id,
154
+ "response_type": "code",
155
+ "redirect_uri": redirect_uri,
156
+ "scope": ",".join(scopes),
157
+ "state": state,
158
+ }
159
+ # Login Kit Web is a confidential-client flow and its current official
160
+ # contract does not define the mobile/desktop code_verifier fields.
161
+ del code_challenge
162
+ return f"{self.authorization_endpoint}?{urlencode(params)}"
163
+
164
+ async def exchange_code(
165
+ self, *, code: str, redirect_uri: str, code_verifier: str | None = None
166
+ ) -> dict[str, Any]:
167
+ if not self.client_id or not self._client_secret:
168
+ raise SocialProviderUnavailableError(
169
+ "TikTok OAuth credentials are not configured."
170
+ )
171
+ del code_verifier
172
+ return await self._token_request(
173
+ {
174
+ "client_key": self.client_id,
175
+ "client_secret": self._client_secret,
176
+ "code": code,
177
+ "grant_type": "authorization_code",
178
+ "redirect_uri": redirect_uri,
179
+ }
180
+ )
181
+
182
+ async def refresh_token(self, token: dict[str, Any]) -> dict[str, Any]:
183
+ refresh_token = token.get("refresh_token")
184
+ if not isinstance(refresh_token, str) or not refresh_token:
185
+ raise SocialReauthRequiredError(
186
+ "The TikTok account requires reauthorization."
187
+ )
188
+ if not self.client_id or not self._client_secret:
189
+ raise SocialProviderUnavailableError(
190
+ "TikTok OAuth credentials are not configured."
191
+ )
192
+ return await self._token_request(
193
+ {
194
+ "client_key": self.client_id,
195
+ "client_secret": self._client_secret,
196
+ "refresh_token": refresh_token,
197
+ "grant_type": "refresh_token",
198
+ }
199
+ )
200
+
201
+ async def revoke_token(self, token: dict[str, Any]) -> None:
202
+ access_token = token.get("access_token")
203
+ if not isinstance(access_token, str) or not access_token:
204
+ return
205
+ if not self.client_id or not self._client_secret:
206
+ raise SocialProviderUnavailableError(
207
+ "TikTok OAuth credentials are not configured."
208
+ )
209
+ try:
210
+ response = await self._client.post(
211
+ _REVOKE_ENDPOINT,
212
+ data={
213
+ "client_key": self.client_id,
214
+ "client_secret": self._client_secret,
215
+ "token": access_token,
216
+ },
217
+ )
218
+ except httpx.TransportError as exc:
219
+ raise SocialProviderUnavailableError(
220
+ "TikTok token revocation is temporarily unavailable."
221
+ ) from exc
222
+ if response.is_success:
223
+ return
224
+ payload = self._response_payload(response, operation="token revocation")
225
+ self._raise_tiktok_error(response, payload, operation="token revocation")
226
+
227
+ async def get_account(self, token: dict[str, Any]) -> dict[str, Any]:
228
+ payload = await self._authorized_get(
229
+ _USER_INFO_ENDPOINT,
230
+ token,
231
+ params={"fields": _USER_FIELDS},
232
+ operation="profile discovery",
233
+ )
234
+ data = payload.get("data")
235
+ user = data.get("user") if isinstance(data, dict) else None
236
+ if (
237
+ not isinstance(user, dict)
238
+ or not isinstance(user.get("open_id"), str)
239
+ or not user["open_id"]
240
+ ):
241
+ raise SocialReauthRequiredError(
242
+ "TikTok did not return an authenticated account identity."
243
+ )
244
+ open_id = user["open_id"]
245
+ metadata = {
246
+ "tiktok_open_id": open_id,
247
+ "tiktok_union_id": (
248
+ user.get("union_id")
249
+ if isinstance(user.get("union_id"), str)
250
+ else None
251
+ ),
252
+ }
253
+ return {
254
+ "external_account_id": open_id,
255
+ "account_type": "creator",
256
+ "username": None,
257
+ "display_name": (
258
+ user.get("display_name")
259
+ if isinstance(user.get("display_name"), str)
260
+ else None
261
+ ),
262
+ "avatar_url": (
263
+ user.get("avatar_url")
264
+ if isinstance(user.get("avatar_url"), str)
265
+ else None
266
+ ),
267
+ "metadata": {
268
+ key: value for key, value in metadata.items() if value is not None
269
+ },
270
+ }
271
+
272
+ async def get_publish_options(self, token: dict[str, Any]) -> dict[str, Any]:
273
+ self._ensure_direct_post_enabled()
274
+ payload = await self._authorized_post(
275
+ _CREATOR_INFO_ENDPOINT,
276
+ token,
277
+ json={},
278
+ operation="creator information",
279
+ )
280
+ data = payload.get("data")
281
+ if not isinstance(data, dict):
282
+ raise SocialProviderUnavailableError(
283
+ "TikTok returned invalid creator publishing options."
284
+ )
285
+ privacy = data.get("privacy_level_options")
286
+ duration = data.get("max_video_post_duration_sec")
287
+ if not isinstance(privacy, list) or not all(
288
+ isinstance(item, str) and item for item in privacy
289
+ ):
290
+ raise SocialProviderUnavailableError(
291
+ "TikTok returned invalid creator privacy options."
292
+ )
293
+ if (
294
+ not isinstance(duration, (int, float))
295
+ or isinstance(duration, bool)
296
+ or duration <= 0
297
+ ):
298
+ raise SocialProviderUnavailableError(
299
+ "TikTok returned an invalid creator duration limit."
300
+ )
301
+ disabled_options: dict[str, bool] = {}
302
+ for name in ("comment_disabled", "duet_disabled", "stitch_disabled"):
303
+ value = data.get(name)
304
+ if not isinstance(value, bool):
305
+ raise SocialProviderUnavailableError(
306
+ "TikTok returned invalid creator interaction options."
307
+ )
308
+ disabled_options[name] = value
309
+ return {
310
+ "privacy_level_options": privacy,
311
+ **disabled_options,
312
+ "max_video_post_duration_sec": min(float(duration), _MAX_VIDEO_DURATION),
313
+ "creator_username": (
314
+ data.get("creator_username")
315
+ if isinstance(data.get("creator_username"), str)
316
+ else None
317
+ ),
318
+ "creator_nickname": (
319
+ data.get("creator_nickname")
320
+ if isinstance(data.get("creator_nickname"), str)
321
+ else None
322
+ ),
323
+ }
324
+
325
+ async def validate_media(self, media: dict[str, Any]) -> None:
326
+ self._ensure_direct_post_enabled()
327
+ path = media.get("path")
328
+ size = media.get("file_size")
329
+ mime_type = str(media.get("mime_type") or "").lower()
330
+ probe = media.get("probe") if isinstance(media.get("probe"), dict) else {}
331
+ if (
332
+ not isinstance(path, Path)
333
+ or not path.is_file()
334
+ or not os.access(path, os.R_OK)
335
+ ):
336
+ raise SocialMediaInvalidError("TikTok media asset is not readable.")
337
+ try:
338
+ actual_size = path.stat().st_size
339
+ except OSError as exc:
340
+ raise SocialMediaInvalidError(
341
+ "TikTok media asset is not readable."
342
+ ) from exc
343
+ if actual_size != size:
344
+ raise SocialMediaInvalidError(
345
+ "TikTok media asset size changed after validation."
346
+ )
347
+ maximum = min(self.settings.max_upload_size, _MAX_VIDEO_SIZE)
348
+ if not isinstance(size, int) or size <= 0 or size > maximum:
349
+ raise SocialMediaInvalidError(
350
+ "TikTok video size exceeds the configured or official limit."
351
+ )
352
+ if mime_type not in {"video/mp4", "video/quicktime", "video/webm"}:
353
+ raise SocialMediaInvalidError(
354
+ "TikTok supports registered MP4, MOV, or WebM video variants."
355
+ )
356
+ container = str(probe.get("container") or "").lower()
357
+ if not any(value in container for value in ("mp4", "quicktime", "webm")):
358
+ raise SocialMediaInvalidError(
359
+ "TikTok video container is unsupported; create a compatible MediaRouter variant."
360
+ )
361
+ streams = probe.get("video_streams")
362
+ if (
363
+ not isinstance(streams, list)
364
+ or not streams
365
+ or not isinstance(streams[0], dict)
366
+ ):
367
+ raise SocialMediaInvalidError("TikTok media must contain a video stream.")
368
+ codec = str(streams[0].get("codec") or "").lower()
369
+ if codec not in {"h264", "hevc", "h265", "vp8", "vp9"}:
370
+ raise SocialMediaInvalidError(
371
+ "TikTok video codec is unsupported; create a compatible MediaRouter variant."
372
+ )
373
+ duration = probe.get("duration")
374
+ if (
375
+ not isinstance(duration, (int, float))
376
+ or isinstance(duration, bool)
377
+ or duration <= 0
378
+ or duration > _MAX_VIDEO_DURATION
379
+ ):
380
+ raise SocialMediaInvalidError(
381
+ "TikTok video duration must be positive and no longer than 10 minutes."
382
+ )
383
+ fps = probe.get("fps")
384
+ if (
385
+ not isinstance(fps, (int, float))
386
+ or isinstance(fps, bool)
387
+ or fps < 23
388
+ or fps > 60
389
+ ):
390
+ raise SocialMediaInvalidError(
391
+ "TikTok video frame rate must be between 23 and 60 FPS."
392
+ )
393
+ resolution = (
394
+ probe.get("resolution")
395
+ if isinstance(probe.get("resolution"), dict)
396
+ else {}
397
+ )
398
+ width, height = resolution.get("width"), resolution.get("height")
399
+ if (
400
+ not isinstance(width, int)
401
+ or not isinstance(height, int)
402
+ or not 360 <= width <= 4096
403
+ or not 360 <= height <= 4096
404
+ ):
405
+ raise SocialMediaInvalidError(
406
+ "TikTok video width and height must each be between 360 and 4096 pixels."
407
+ )
408
+ aspect_ratio = width / height
409
+ if not 0 < aspect_ratio < float("inf"):
410
+ raise SocialMediaInvalidError("TikTok video aspect ratio is invalid.")
411
+ audio_streams = probe.get("audio_streams")
412
+ if audio_streams is not None and not isinstance(audio_streams, list):
413
+ raise SocialMediaInvalidError("TikTok audio stream metadata is invalid.")
414
+ # TikTok does not require an audio track. For present audio, accept the
415
+ # codecs produced by MediaRouter's compatible MP4/WebM templates.
416
+ for stream in audio_streams or []:
417
+ if (
418
+ not isinstance(stream, dict)
419
+ or str(stream.get("codec") or "").lower()
420
+ not in {"aac", "mp3", "opus", "vorbis"}
421
+ ):
422
+ raise SocialMediaInvalidError(
423
+ "TikTok audio codec is unsupported; create a compatible MediaRouter variant."
424
+ )
425
+
426
+ async def upload_media(
427
+ self, token: dict[str, Any], media: dict[str, Any]
428
+ ) -> dict[str, Any]:
429
+ await self.validate_media(media)
430
+ path = media["path"]
431
+ assert isinstance(path, Path)
432
+ total = int(media["file_size"])
433
+ post_info = media.get("tiktok_post_info")
434
+ if not isinstance(post_info, dict):
435
+ raise SocialPublishFailedError(
436
+ "Typed TikTok Direct Post metadata is required."
437
+ )
438
+ creator = await self.get_publish_options(token)
439
+ self._validate_post_info(post_info, creator, media)
440
+ persist = media.get("persist_provider_state")
441
+ if persist is not None and not callable(persist):
442
+ raise SocialPublishFailedError(
443
+ "TikTok provider-state persistence is invalid."
444
+ )
445
+ heartbeat = media.get("heartbeat")
446
+ if heartbeat is not None and not callable(heartbeat):
447
+ raise SocialPublishFailedError("TikTok upload heartbeat is invalid.")
448
+ state = (
449
+ dict(media.get("provider_state"))
450
+ if isinstance(media.get("provider_state"), dict)
451
+ else {}
452
+ )
453
+ publish_id = state.get("tiktok_publish_id")
454
+ upload_url = state.get("tiktok_upload_url")
455
+ chunk_size = state.get("tiktok_chunk_size")
456
+ chunk_count = state.get("tiktok_chunk_count")
457
+ if not isinstance(publish_id, str) or not publish_id:
458
+ if state.get("tiktok_init_started"):
459
+ # TikTok has no client idempotency key or lookup-by-client-key
460
+ # endpoint. Never repeat an init whose accepted outcome could
461
+ # not be durably identified.
462
+ raise SocialPublishFailedError(
463
+ "TikTok initialization outcome is unavailable; duplicate publishing was prevented."
464
+ )
465
+ if persist:
466
+ await persist(
467
+ {
468
+ "tiktok_init_started": True,
469
+ "tiktok_video_size": total,
470
+ }
471
+ )
472
+ chunk_size, chunk_count = self._chunk_plan(total)
473
+ payload = await self._authorized_post(
474
+ _DIRECT_POST_INIT_ENDPOINT,
475
+ token,
476
+ json={
477
+ "post_info": post_info,
478
+ "source_info": {
479
+ "source": "FILE_UPLOAD",
480
+ "video_size": total,
481
+ "chunk_size": chunk_size,
482
+ "total_chunk_count": chunk_count,
483
+ },
484
+ },
485
+ operation="Direct Post initialization",
486
+ )
487
+ data = payload.get("data")
488
+ if not isinstance(data, dict):
489
+ raise SocialProviderUnavailableError(
490
+ "TikTok did not return a Direct Post upload session."
491
+ )
492
+ publish_id = data.get("publish_id")
493
+ upload_url = data.get("upload_url")
494
+ if not isinstance(publish_id, str) or not publish_id:
495
+ raise SocialProviderUnavailableError(
496
+ "TikTok did not return a Direct Post publish ID."
497
+ )
498
+ if not isinstance(upload_url, str) or not self._is_upload_url(upload_url):
499
+ raise SocialProviderUnavailableError(
500
+ "TikTok did not return a trusted upload URL."
501
+ )
502
+ state = {
503
+ "tiktok_init_started": True,
504
+ "tiktok_publish_id": publish_id,
505
+ "tiktok_upload_url": upload_url,
506
+ "tiktok_video_size": total,
507
+ "tiktok_chunk_size": chunk_size,
508
+ "tiktok_chunk_count": chunk_count,
509
+ "tiktok_uploaded_bytes": 0,
510
+ }
511
+ if persist:
512
+ await persist(state)
513
+ else:
514
+ if state.get("tiktok_video_size") != total:
515
+ raise SocialMediaInvalidError(
516
+ "TikTok retry media does not match the initialized upload."
517
+ )
518
+ status = await self.get_publish_status(token, publish_id)
519
+ if status["status"] == "published":
520
+ return {"id": publish_id, "metadata": status.get("metadata", {})}
521
+ if status["status"] == "failed":
522
+ raise self.publish_failure(status)
523
+ uploaded = status.get("metadata", {}).get("uploaded_bytes")
524
+ if isinstance(uploaded, int) and uploaded >= total:
525
+ return {"id": publish_id}
526
+ if isinstance(uploaded, int) and uploaded >= 0:
527
+ state["tiktok_uploaded_bytes"] = uploaded
528
+
529
+ if not isinstance(upload_url, str) or not self._is_upload_url(upload_url):
530
+ raise SocialProviderUnavailableError(
531
+ "TikTok upload session cannot be safely resumed."
532
+ )
533
+ if not isinstance(chunk_size, int) or not isinstance(chunk_count, int):
534
+ raise SocialProviderUnavailableError(
535
+ "TikTok upload session has invalid chunk metadata."
536
+ )
537
+ position = state.get("tiktok_uploaded_bytes", 0)
538
+ if not isinstance(position, int) or position < 0 or position > total:
539
+ raise SocialProviderUnavailableError(
540
+ "TikTok returned an invalid upload position."
541
+ )
542
+ if position < total and position % chunk_size != 0:
543
+ raise SocialProviderUnavailableError(
544
+ "TikTok upload position cannot be safely resumed."
545
+ )
546
+ try:
547
+ await self._upload_chunks(
548
+ token=token,
549
+ publish_id=publish_id,
550
+ upload_url=upload_url,
551
+ path=path,
552
+ mime_type=str(media["mime_type"]),
553
+ total=total,
554
+ chunk_size=chunk_size,
555
+ chunk_count=chunk_count,
556
+ position=position,
557
+ persist=persist,
558
+ state=state,
559
+ heartbeat=heartbeat,
560
+ )
561
+ except OSError as exc:
562
+ raise SocialMediaInvalidError(
563
+ "TikTok media asset could not be read during upload."
564
+ ) from exc
565
+ return {"id": publish_id}
566
+
567
+ async def publish(
568
+ self, token: dict[str, Any], payload: dict[str, Any]
569
+ ) -> dict[str, Any]:
570
+ del token
571
+ upload = payload.get("upload")
572
+ if not isinstance(upload, dict) or not isinstance(upload.get("id"), str):
573
+ raise SocialPublishFailedError(
574
+ "TikTok upload did not return a publish ID."
575
+ )
576
+ # Direct Post is initiated by /video/init and starts processing after
577
+ # the final upload chunk. There is no second publish endpoint.
578
+ return dict(upload)
579
+
580
+ async def get_publish_status(
581
+ self, token: dict[str, Any], external_id: str
582
+ ) -> dict[str, Any]:
583
+ self._ensure_direct_post_enabled()
584
+ response = await self._authorized_request(
585
+ "POST",
586
+ _PUBLISH_STATUS_ENDPOINT,
587
+ token,
588
+ json={"publish_id": external_id},
589
+ )
590
+ payload = self._response_payload(response, operation="publish status")
591
+ error = payload.get("error")
592
+ code = str(error.get("code", "")).lower() if isinstance(error, dict) else ""
593
+ if response.status_code == 400 and code == "invalid_publish_id":
594
+ return {
595
+ "id": external_id,
596
+ "status": "unavailable",
597
+ "metadata": {"provider_status": "INVALID_PUBLISH_ID"},
598
+ }
599
+ self._raise_tiktok_error(response, payload, operation="publish status")
600
+ data = payload.get("data")
601
+ if not isinstance(data, dict):
602
+ raise SocialProviderUnavailableError(
603
+ "TikTok returned an invalid publish status."
604
+ )
605
+ provider_status = str(data.get("status") or "").upper()
606
+ if provider_status == "PUBLISH_COMPLETE":
607
+ normalized = "published"
608
+ elif provider_status in {
609
+ "PROCESSING_UPLOAD",
610
+ "PROCESSING_DOWNLOAD",
611
+ "SEND_TO_USER_INBOX",
612
+ }:
613
+ normalized = "processing"
614
+ elif provider_status == "FAILED":
615
+ normalized = "failed"
616
+ else:
617
+ normalized = "unavailable"
618
+ public_ids = data.get("publicaly_available_post_id")
619
+ if not isinstance(public_ids, list):
620
+ # Accept the corrected spelling defensively if TikTok fixes the
621
+ # long-standing response-field typo without a version bump.
622
+ public_ids = data.get("publicly_available_post_id")
623
+ metadata: dict[str, object] = {
624
+ "provider_status": provider_status or None,
625
+ "fail_reason": (
626
+ data.get("fail_reason")
627
+ if isinstance(data.get("fail_reason"), str)
628
+ else None
629
+ ),
630
+ "uploaded_bytes": (
631
+ data.get("uploaded_bytes")
632
+ if isinstance(data.get("uploaded_bytes"), int)
633
+ else None
634
+ ),
635
+ "public_post_ids": (
636
+ [str(item) for item in public_ids]
637
+ if isinstance(public_ids, list)
638
+ else []
639
+ ),
640
+ }
641
+ return {
642
+ "id": external_id,
643
+ "status": normalized,
644
+ "metadata": {
645
+ key: value for key, value in metadata.items() if value is not None
646
+ },
647
+ }
648
+
649
+ async def get_metrics(
650
+ self, token: dict[str, Any], external_id: str
651
+ ) -> dict[str, Any]:
652
+ """Return only TikTok video metrics documented by Display API v2.
653
+
654
+ `external_id` must be the publicly available video ID returned by the
655
+ Content Posting status endpoint, not the private Direct Post
656
+ `publish_id`. AnalyticsService resolves that identity from safe target
657
+ metadata before calling this adapter.
658
+ """
659
+
660
+ if not self.configuration_ready:
661
+ raise SocialCapabilityUnsupportedError(
662
+ "TikTok analytics are unavailable until the provider is configured."
663
+ )
664
+ if not external_id or len(external_id) > 255:
665
+ raise SocialPublishFailedError("TikTok analytics video ID is invalid.")
666
+ response = await self._authorized_request(
667
+ "POST",
668
+ _VIDEO_QUERY_ENDPOINT,
669
+ token,
670
+ params={"fields": _VIDEO_ANALYTICS_FIELDS},
671
+ json={"filters": {"video_ids": [external_id]}},
672
+ )
673
+ payload = self._response_payload(response, operation="video analytics")
674
+ self._raise_tiktok_error(response, payload, operation="video analytics")
675
+ data = payload.get("data")
676
+ videos = data.get("videos") if isinstance(data, dict) else None
677
+ if not isinstance(videos, list):
678
+ raise SocialProviderUnavailableError(
679
+ "TikTok returned an invalid video analytics response."
680
+ )
681
+ video = next(
682
+ (
683
+ item
684
+ for item in videos
685
+ if isinstance(item, dict) and str(item.get("id") or "") == external_id
686
+ ),
687
+ None,
688
+ )
689
+ if video is None:
690
+ return {
691
+ "status": "unavailable",
692
+ "reason": "TIKTOK_VIDEO_NOT_AVAILABLE_TO_AUTHORIZED_USER",
693
+ }
694
+
695
+ result: dict[str, Any] = {
696
+ "status": "available",
697
+ "raw_metrics": public_provider_data(dict(video)),
698
+ }
699
+ for provider_name, normalized_name in (
700
+ ("view_count", "views"),
701
+ ("like_count", "likes"),
702
+ ("comment_count", "comments"),
703
+ ("share_count", "shares"),
704
+ ):
705
+ value = video.get(provider_name)
706
+ if isinstance(value, int) and not isinstance(value, bool) and value >= 0:
707
+ result[normalized_name] = value
708
+ created = video.get("create_time")
709
+ if isinstance(created, int) and not isinstance(created, bool) and created >= 0:
710
+ result["published_at"] = created
711
+ if isinstance(video.get("share_url"), str):
712
+ result["url"] = video["share_url"]
713
+ return result
714
+
715
+ def publish_failure(self, result: dict[str, Any]) -> Exception:
716
+ metadata = result.get("metadata") if isinstance(result.get("metadata"), dict) else {}
717
+ reason = str(metadata.get("fail_reason") or "").lower()
718
+ if reason in _MEDIA_FAILURES:
719
+ return SocialMediaInvalidError(
720
+ "TikTok rejected media that does not meet its current restrictions."
721
+ )
722
+ if reason == "auth_removed":
723
+ return SocialReauthRequiredError(
724
+ "The TikTok account requires reauthorization."
725
+ )
726
+ if reason in {"internal", "video_pull_failed", "photo_pull_failed"}:
727
+ return SocialProviderUnavailableError(
728
+ "TikTok publishing is temporarily unavailable."
729
+ )
730
+ if result.get("status") == "unavailable":
731
+ return SocialProviderUnavailableError(
732
+ "TikTok publish status is unavailable."
733
+ )
734
+ return SocialPublishFailedError(
735
+ "TikTok rejected the publishing request."
736
+ )
737
+
738
+ async def _upload_chunks(
739
+ self,
740
+ *,
741
+ token: dict[str, Any],
742
+ publish_id: str,
743
+ upload_url: str,
744
+ path: Path,
745
+ mime_type: str,
746
+ total: int,
747
+ chunk_size: int,
748
+ chunk_count: int,
749
+ position: int,
750
+ persist: Any,
751
+ state: dict[str, object],
752
+ heartbeat: Any,
753
+ ) -> None:
754
+ async with aiofiles.open(path, "rb") as source:
755
+ await source.seek(position)
756
+ while position < total:
757
+ if heartbeat:
758
+ await heartbeat()
759
+ index = position // chunk_size
760
+ if index >= chunk_count:
761
+ raise SocialProviderUnavailableError(
762
+ "TikTok upload exceeded its initialized chunk count."
763
+ )
764
+ length = total - position if index == chunk_count - 1 else chunk_size
765
+ if length <= 0 or length > _MAX_FINAL_CHUNK:
766
+ raise SocialProviderUnavailableError(
767
+ "TikTok upload chunk plan is invalid."
768
+ )
769
+ chunk = await source.read(length)
770
+ if len(chunk) != length:
771
+ raise SocialMediaInvalidError(
772
+ "TikTok upload file ended before its registered size."
773
+ )
774
+ position = await self._put_chunk(
775
+ token=token,
776
+ publish_id=publish_id,
777
+ upload_url=upload_url,
778
+ chunk=chunk,
779
+ start=position,
780
+ total=total,
781
+ mime_type=mime_type,
782
+ )
783
+ await source.seek(position)
784
+ state["tiktok_uploaded_bytes"] = position
785
+ if persist:
786
+ await persist(state)
787
+
788
+ async def _put_chunk(
789
+ self,
790
+ *,
791
+ token: dict[str, Any],
792
+ publish_id: str,
793
+ upload_url: str,
794
+ chunk: bytes,
795
+ start: int,
796
+ total: int,
797
+ mime_type: str,
798
+ ) -> int:
799
+ end = start + len(chunk) - 1
800
+ for attempt in range(4):
801
+ try:
802
+ response = await self._client.put(
803
+ upload_url,
804
+ content=chunk,
805
+ headers={
806
+ "Content-Type": mime_type,
807
+ "Content-Length": str(len(chunk)),
808
+ "Content-Range": f"bytes {start}-{end}/{total}",
809
+ },
810
+ )
811
+ except (httpx.TimeoutException, httpx.RequestError) as exc:
812
+ reconciled = await self._reconcile_uploaded_bytes(
813
+ token, publish_id, start
814
+ )
815
+ if reconciled > start:
816
+ return reconciled
817
+ if attempt == 3:
818
+ raise SocialProviderUnavailableError(
819
+ "TikTok upload chunk could not be delivered."
820
+ ) from exc
821
+ await asyncio.sleep(2**attempt)
822
+ continue
823
+ if response.status_code == 201:
824
+ return total
825
+ if response.status_code == 206:
826
+ received = self._range_position(response.headers.get("Content-Range"))
827
+ next_position = received if received is not None else end + 1
828
+ if next_position != end + 1:
829
+ raise SocialProviderUnavailableError(
830
+ "TikTok returned an inconsistent upload byte range."
831
+ )
832
+ return next_position
833
+ if response.status_code in _RETRYABLE:
834
+ reconciled = await self._reconcile_uploaded_bytes(
835
+ token, publish_id, start
836
+ )
837
+ if reconciled > start:
838
+ return reconciled
839
+ if attempt < 3:
840
+ await asyncio.sleep(2**attempt)
841
+ continue
842
+ raise SocialProviderUnavailableError(
843
+ "TikTok upload is temporarily unavailable."
844
+ )
845
+ if response.status_code == 401:
846
+ raise SocialReauthRequiredError(
847
+ "The TikTok upload session requires reauthorization."
848
+ )
849
+ if response.status_code in {403, 404}:
850
+ if response.status_code == 403:
851
+ raise SocialPermissionDeniedError(
852
+ "TikTok denied permission to upload this post."
853
+ )
854
+ raise SocialPublishFailedError(
855
+ "TikTok upload session expired before completion."
856
+ )
857
+ if response.status_code in {400, 416}:
858
+ raise SocialMediaInvalidError(
859
+ "TikTok rejected the upload byte range or media chunk."
860
+ )
861
+ raise SocialPublishFailedError("TikTok upload request failed.")
862
+ raise SocialProviderUnavailableError(
863
+ "TikTok upload retry budget was exhausted."
864
+ )
865
+
866
+ async def _reconcile_uploaded_bytes(
867
+ self, token: dict[str, Any], publish_id: str, fallback: int
868
+ ) -> int:
869
+ status = await self.get_publish_status(token, publish_id)
870
+ metadata = (
871
+ status.get("metadata")
872
+ if isinstance(status.get("metadata"), dict)
873
+ else {}
874
+ )
875
+ uploaded = metadata.get("uploaded_bytes")
876
+ return uploaded if isinstance(uploaded, int) and uploaded >= 0 else fallback
877
+
878
+ async def _token_request(self, form: dict[str, str]) -> dict[str, Any]:
879
+ try:
880
+ response = await self._client.post(_TOKEN_ENDPOINT, data=form)
881
+ except httpx.TransportError as exc:
882
+ raise SocialProviderUnavailableError(
883
+ "TikTok OAuth is temporarily unavailable."
884
+ ) from exc
885
+ payload = self._response_payload(response, operation="OAuth")
886
+ self._raise_tiktok_error(response, payload, operation="OAuth")
887
+ if not isinstance(payload.get("access_token"), str) or not payload["access_token"]:
888
+ raise SocialReauthRequiredError("TikTok did not return an access token.")
889
+ return payload
890
+
891
+ async def _authorized_get(
892
+ self,
893
+ url: str,
894
+ token: dict[str, Any],
895
+ *,
896
+ params: dict[str, str],
897
+ operation: str,
898
+ ) -> dict[str, Any]:
899
+ response = await self._authorized_request(
900
+ "GET", url, token, params=params
901
+ )
902
+ payload = self._response_payload(response, operation=operation)
903
+ self._raise_tiktok_error(response, payload, operation=operation)
904
+ return payload
905
+
906
+ async def _authorized_post(
907
+ self,
908
+ url: str,
909
+ token: dict[str, Any],
910
+ *,
911
+ json: dict[str, object],
912
+ operation: str,
913
+ ) -> dict[str, Any]:
914
+ response = await self._authorized_request(
915
+ "POST", url, token, json=json
916
+ )
917
+ payload = self._response_payload(response, operation=operation)
918
+ self._raise_tiktok_error(response, payload, operation=operation)
919
+ return payload
920
+
921
+ async def _authorized_request(
922
+ self,
923
+ method: str,
924
+ url: str,
925
+ token: dict[str, Any],
926
+ **kwargs: Any,
927
+ ) -> httpx.Response:
928
+ access_token = self._access_token(token)
929
+ headers = dict(kwargs.pop("headers", {}))
930
+ headers["Authorization"] = f"Bearer {access_token}"
931
+ headers.setdefault("Content-Type", "application/json; charset=UTF-8")
932
+ try:
933
+ return await self._client.request(
934
+ method, url, headers=headers, **kwargs
935
+ )
936
+ except httpx.TransportError as exc:
937
+ raise SocialProviderUnavailableError(
938
+ "TikTok provider request is temporarily unavailable."
939
+ ) from exc
940
+
941
+ def _validate_post_info(
942
+ self,
943
+ post_info: dict[str, object],
944
+ creator: dict[str, Any],
945
+ media: dict[str, Any],
946
+ ) -> None:
947
+ privacy = post_info.get("privacy_level")
948
+ options = creator.get("privacy_level_options")
949
+ if not isinstance(options, list) or privacy not in options:
950
+ raise SocialPermissionDeniedError(
951
+ "The selected TikTok privacy level is unavailable for this creator."
952
+ )
953
+ for field in ("comment", "duet", "stitch"):
954
+ if creator.get(f"{field}_disabled") and not post_info.get(
955
+ f"disable_{field}"
956
+ ):
957
+ raise SocialPermissionDeniedError(
958
+ f"TikTok requires {field} to remain disabled for this creator."
959
+ )
960
+ probe = media.get("probe") if isinstance(media.get("probe"), dict) else {}
961
+ duration = probe.get("duration")
962
+ maximum = creator.get("max_video_post_duration_sec")
963
+ if (
964
+ isinstance(duration, (int, float))
965
+ and isinstance(maximum, (int, float))
966
+ and duration > maximum
967
+ ):
968
+ raise SocialMediaInvalidError(
969
+ "TikTok video exceeds this creator's current duration limit."
970
+ )
971
+ cover = post_info.get("video_cover_timestamp_ms")
972
+ if (
973
+ isinstance(cover, int)
974
+ and isinstance(duration, (int, float))
975
+ and cover >= duration * 1000
976
+ ):
977
+ raise SocialMediaInvalidError(
978
+ "TikTok cover timestamp must fall within the video duration."
979
+ )
980
+
981
+ if (
982
+ post_info.get("brand_content_toggle")
983
+ and privacy != "PUBLIC_TO_EVERYONE"
984
+ ):
985
+ raise SocialPermissionDeniedError(
986
+ "TikTok branded content requires public visibility."
987
+ )
988
+
989
+ def _ensure_direct_post_enabled(self) -> None:
990
+ if (
991
+ not self.settings.tiktok_direct_post_enabled
992
+ or not self.configuration_ready
993
+ ):
994
+ raise SocialCapabilityUnsupportedError(
995
+ "TikTok Direct Post is not enabled for this approved application."
996
+ )
997
+
998
+ def _chunk_plan(self, total: int) -> tuple[int, int]:
999
+ if total <= _MAX_CHUNK:
1000
+ return total, 1
1001
+ chunk_size = min(
1002
+ max(_MIN_CHUNK, self.settings.tiktok_upload_chunk_bytes), _MAX_CHUNK
1003
+ )
1004
+ count = total // chunk_size
1005
+ final_size = total - (count - 1) * chunk_size
1006
+ if count < 1 or count > 1000 or final_size > _MAX_FINAL_CHUNK:
1007
+ raise SocialMediaInvalidError(
1008
+ "TikTok video cannot be represented by a supported upload chunk plan."
1009
+ )
1010
+ return chunk_size, count
1011
+
1012
+ @staticmethod
1013
+ def _range_position(value: str | None) -> int | None:
1014
+ if not value:
1015
+ return None
1016
+ match = _CONTENT_RANGE.search(value)
1017
+ return int(match.group(1)) + 1 if match else None
1018
+
1019
+ @staticmethod
1020
+ def _is_upload_url(value: str) -> bool:
1021
+ parsed = urlparse(value)
1022
+ return (
1023
+ parsed.scheme == "https"
1024
+ and parsed.username is None
1025
+ and parsed.password is None
1026
+ and parsed.hostname is not None
1027
+ and parsed.hostname == "open-upload.tiktokapis.com"
1028
+ and parsed.path.startswith("/video/")
1029
+ )
1030
+
1031
+ @staticmethod
1032
+ def _access_token(token: dict[str, Any]) -> str:
1033
+ access_token = token.get("access_token")
1034
+ if not isinstance(access_token, str) or not access_token:
1035
+ raise SocialReauthRequiredError(
1036
+ "The TikTok account requires reauthorization."
1037
+ )
1038
+ return access_token
1039
+
1040
+ @staticmethod
1041
+ def _response_payload(
1042
+ response: httpx.Response, *, operation: str
1043
+ ) -> dict[str, Any]:
1044
+ try:
1045
+ payload = response.json()
1046
+ except ValueError as exc:
1047
+ raise SocialProviderUnavailableError(
1048
+ f"TikTok returned an invalid {operation} response."
1049
+ ) from exc
1050
+ if not isinstance(payload, dict):
1051
+ raise SocialProviderUnavailableError(
1052
+ f"TikTok returned an invalid {operation} response."
1053
+ )
1054
+ return payload
1055
+
1056
+ @staticmethod
1057
+ def _raise_tiktok_error(
1058
+ response: httpx.Response, payload: dict[str, Any], *, operation: str
1059
+ ) -> None:
1060
+ error = payload.get("error")
1061
+ if isinstance(error, dict):
1062
+ code = str(error.get("code", "")).lower()
1063
+ elif isinstance(error, str):
1064
+ code = error.lower()
1065
+ else:
1066
+ code = ""
1067
+ if response.is_success and code in {"", "ok"}:
1068
+ return
1069
+ if response.status_code == 401 or code in {
1070
+ "access_token_invalid",
1071
+ "access_token_expired",
1072
+ "authorization_revoked",
1073
+ "auth_removed",
1074
+ "invalid_grant",
1075
+ "invalid_code",
1076
+ "invalid_token",
1077
+ }:
1078
+ raise SocialReauthRequiredError(
1079
+ "The TikTok account requires reauthorization."
1080
+ )
1081
+ if operation == "OAuth" and response.status_code == 400:
1082
+ if code in {"access_denied", "invalid_scope"}:
1083
+ raise SocialPermissionDeniedError(
1084
+ "TikTok OAuth authorization was denied."
1085
+ )
1086
+ if code in {"invalid_request", "invalid_authorization_code"}:
1087
+ raise SocialReauthRequiredError(
1088
+ "The TikTok authorization code is invalid or expired."
1089
+ )
1090
+ if response.status_code == 403 or code in {
1091
+ "scope_not_authorized",
1092
+ "permission_denied",
1093
+ "access_not_allowed",
1094
+ "token_not_authorized_for_specified_publish_id",
1095
+ }:
1096
+ raise SocialPermissionDeniedError(
1097
+ f"TikTok {operation} permission was denied."
1098
+ )
1099
+ if response.status_code == 429 or "rate" in code or "quota" in code:
1100
+ raise SocialRateLimitedError(
1101
+ f"TikTok {operation} rate limit reached."
1102
+ )
1103
+ if response.status_code >= 500 or code in {
1104
+ "internal",
1105
+ "internal_error",
1106
+ "server_error",
1107
+ }:
1108
+ raise SocialProviderUnavailableError(
1109
+ f"TikTok {operation} is temporarily unavailable."
1110
+ )
1111
+ if code in _MEDIA_FAILURES or code in {
1112
+ "invalid_file_upload",
1113
+ "video_size_check_failed",
1114
+ }:
1115
+ raise SocialMediaInvalidError(
1116
+ "TikTok rejected media that does not meet its restrictions."
1117
+ )
1118
+ if response.status_code == 400 or code in {
1119
+ "invalid_param",
1120
+ "invalid_request",
1121
+ "spam_risk",
1122
+ "spam_risk_text",
1123
+ "spam_risk_too_many_posts",
1124
+ "spam_risk_user_banned_from_posting",
1125
+ }:
1126
+ raise SocialPublishFailedError(
1127
+ f"TikTok rejected the {operation} request."
1128
+ )
1129
+ raise SocialProviderUnavailableError(
1130
+ f"TikTok {operation} request was rejected."
1131
+ )
app/social/providers/whatsapp.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from app.core.config import Settings
2
+ from app.social.domain.capabilities import ProviderCapabilities
3
+ from app.social.domain.enums import ConnectionStrategy, Provider
4
+ from app.social.providers.base import SocialProviderAdapter
5
+
6
+
7
+ class WhatsAppProvider(SocialProviderAdapter):
8
+ def __init__(self, settings: Settings) -> None:
9
+ self.capabilities = ProviderCapabilities(
10
+ provider=Provider.WHATSAPP, connection_strategy=ConnectionStrategy.BUSINESS_API,
11
+ account_types=["business_account", "phone_number"],
12
+ implementation_status="registered",
13
+ )
app/social/providers/x.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from app.core.config import Settings
2
+ from app.social.domain.capabilities import ProviderCapabilities
3
+ from app.social.domain.enums import ConnectionStrategy, Provider
4
+ from app.social.providers.oauth import OAuthFoundationAdapter
5
+
6
+
7
+ class XProvider(OAuthFoundationAdapter):
8
+ authorization_endpoint = "https://twitter.com/i/oauth2/authorize"
9
+
10
+ def __init__(self, settings: Settings) -> None:
11
+ self.client_id = settings.x_client_id
12
+ self.capabilities = ProviderCapabilities(
13
+ provider=Provider.X, connection_strategy=ConnectionStrategy.OAUTH,
14
+ implementation_status="registered", account_types=["user"],
15
+ )
app/social/providers/youtube.py ADDED
@@ -0,0 +1,630 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import re
5
+ import time
6
+ from pathlib import Path
7
+ from typing import Any
8
+ from urllib.parse import urlencode, urlparse
9
+
10
+ import aiofiles
11
+ import httpx
12
+
13
+ from app.core.config import Settings
14
+ from app.core.logger import get_logger
15
+ from app.social.domain.capabilities import ProviderCapabilities
16
+ from app.social.domain.enums import ConnectionStrategy, Provider
17
+ from app.social.domain.errors import (
18
+ SocialMediaInvalidError,
19
+ SocialPermissionDeniedError,
20
+ SocialProviderQuotaError,
21
+ SocialProviderUnavailableError,
22
+ SocialPublishFailedError,
23
+ SocialRateLimitedError,
24
+ SocialReauthRequiredError,
25
+ )
26
+ from app.social.providers.oauth import OAuthFoundationAdapter
27
+
28
+ logger = get_logger(__name__)
29
+
30
+ _GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
31
+ _GOOGLE_REVOKE_URL = "https://oauth2.googleapis.com/revoke"
32
+ _YOUTUBE_API = "https://www.googleapis.com/youtube/v3"
33
+ _YOUTUBE_UPLOAD = "https://www.googleapis.com/upload/youtube/v3/videos"
34
+ _RETRYABLE = frozenset({429, 500, 502, 503, 504})
35
+ _RANGE = re.compile(r"bytes=0-(\d+)")
36
+
37
+
38
+ class YouTubeProvider(OAuthFoundationAdapter):
39
+ """Official YouTube Data API v3 adapter.
40
+
41
+ All provider requests use an access token supplied by TokenService through
42
+ the social worker/service boundary; this class never persists or exposes
43
+ credentials. Resumable-session URIs enter and leave only encrypted worker
44
+ state through the callback supplied to ``upload_media``.
45
+ """
46
+
47
+ authorization_endpoint = "https://accounts.google.com/o/oauth2/v2/auth"
48
+
49
+ def __init__(
50
+ self,
51
+ settings: Settings,
52
+ *,
53
+ http_client: httpx.AsyncClient | None = None,
54
+ ) -> None:
55
+ self.settings = settings
56
+ self.reconciliation_poll_seconds = settings.youtube_processing_poll_seconds
57
+ self.client_id = settings.google_client_id
58
+ self._client_secret = (
59
+ settings.google_client_secret.get_secret_value()
60
+ if settings.google_client_secret
61
+ else ""
62
+ )
63
+ self._client = http_client or httpx.AsyncClient(
64
+ timeout=httpx.Timeout(settings.youtube_request_timeout_seconds),
65
+ follow_redirects=False,
66
+ )
67
+ self._owns_client = http_client is None
68
+ self._uploads = asyncio.Semaphore(settings.youtube_max_concurrent_uploads)
69
+ self.capabilities = ProviderCapabilities(
70
+ provider=Provider.YOUTUBE,
71
+ connection_strategy=ConnectionStrategy.OAUTH,
72
+ video=True,
73
+ video_upload=True,
74
+ video_status=True,
75
+ channel_metadata=True,
76
+ direct_publish=True,
77
+ # Scheduled posts are dispatched by the existing durable
78
+ # MediaRouter scheduler. Native YouTube `publishAt` is also typed
79
+ # and sent when explicitly supplied.
80
+ scheduled_publish=True,
81
+ analytics=True,
82
+ delete_post=True,
83
+ personal_publishing=True,
84
+ implementation_status="implemented",
85
+ account_types=["channel"],
86
+ required_scopes=["https://www.googleapis.com/auth/youtube.upload"],
87
+ )
88
+
89
+ async def close(self) -> None:
90
+ if self._owns_client:
91
+ await self._client.aclose()
92
+
93
+ async def get_authorization_url(
94
+ self,
95
+ *,
96
+ state: str,
97
+ redirect_uri: str,
98
+ code_challenge: str | None = None,
99
+ additional_scopes: list[str] | None = None,
100
+ ) -> str:
101
+ if not self.client_id:
102
+ raise SocialProviderUnavailableError("youtube OAuth credentials are not configured.")
103
+ params = {
104
+ "client_id": self.client_id,
105
+ "redirect_uri": redirect_uri,
106
+ "response_type": "code",
107
+ "state": state,
108
+ "scope": " ".join(
109
+ dict.fromkeys(
110
+ [*self.capabilities.required_scopes, *(additional_scopes or [])]
111
+ )
112
+ ),
113
+ # Offline access is necessary for workers to publish after the
114
+ # browser callback has ended. `prompt=consent` makes reconnecting
115
+ # an account reliably return a new refresh token.
116
+ "access_type": "offline",
117
+ "prompt": "consent",
118
+ "include_granted_scopes": "false",
119
+ }
120
+ if code_challenge:
121
+ params["code_challenge"] = code_challenge
122
+ params["code_challenge_method"] = "S256"
123
+ return f"{self.authorization_endpoint}?{urlencode(params)}"
124
+
125
+ async def exchange_code(
126
+ self, *, code: str, redirect_uri: str, code_verifier: str | None = None
127
+ ) -> dict[str, Any]:
128
+ if not self.client_id or not self._client_secret:
129
+ raise SocialProviderUnavailableError("youtube OAuth credentials are not configured.")
130
+ if not code_verifier:
131
+ raise SocialPermissionDeniedError("YouTube OAuth PKCE verification is required.")
132
+ response = await self._request(
133
+ "POST",
134
+ _GOOGLE_TOKEN_URL,
135
+ data={
136
+ "code": code,
137
+ "client_id": self.client_id,
138
+ "client_secret": self._client_secret,
139
+ "redirect_uri": redirect_uri,
140
+ "grant_type": "authorization_code",
141
+ "code_verifier": code_verifier,
142
+ },
143
+ oauth=True,
144
+ )
145
+ payload = self._json(response)
146
+ if not payload.get("access_token"):
147
+ raise SocialReauthRequiredError("Google did not return an access token.")
148
+ return payload
149
+
150
+ async def refresh_token(self, token: dict[str, Any]) -> dict[str, Any]:
151
+ refresh_token = token.get("refresh_token")
152
+ if not isinstance(refresh_token, str) or not refresh_token:
153
+ raise SocialReauthRequiredError("The YouTube account has no refresh token.")
154
+ if not self.client_id or not self._client_secret:
155
+ raise SocialProviderUnavailableError("youtube OAuth credentials are not configured.")
156
+ response = await self._request(
157
+ "POST",
158
+ _GOOGLE_TOKEN_URL,
159
+ data={
160
+ "client_id": self.client_id,
161
+ "client_secret": self._client_secret,
162
+ "refresh_token": refresh_token,
163
+ "grant_type": "refresh_token",
164
+ },
165
+ oauth=True,
166
+ )
167
+ refreshed = self._json(response)
168
+ if not refreshed.get("access_token"):
169
+ raise SocialReauthRequiredError("Google did not return a refreshed access token.")
170
+ # Google normally omits refresh_token on refresh. TokenService merges
171
+ # it with the existing encrypted credential before persistence.
172
+ return refreshed
173
+
174
+ async def revoke_token(self, token: dict[str, Any]) -> None:
175
+ value = token.get("refresh_token") or token.get("access_token")
176
+ if not isinstance(value, str) or not value:
177
+ return
178
+ response = await self._client.post(_GOOGLE_REVOKE_URL, data={"token": value})
179
+ if response.status_code not in {200, 204, 400}:
180
+ self._raise_provider_error(response)
181
+
182
+ async def get_account(self, token: dict[str, Any]) -> dict[str, Any]:
183
+ response = await self._api_request(
184
+ "GET",
185
+ f"{_YOUTUBE_API}/channels",
186
+ token,
187
+ params={"part": "snippet", "mine": "true", "maxResults": "1"},
188
+ )
189
+ items = self._json(response).get("items")
190
+ if not isinstance(items, list) or not items or not isinstance(items[0], dict):
191
+ raise SocialPermissionDeniedError("The authorized Google account has no accessible YouTube channel.")
192
+ channel = items[0]
193
+ channel_id = channel.get("id")
194
+ snippet = channel.get("snippet") if isinstance(channel.get("snippet"), dict) else {}
195
+ if not isinstance(channel_id, str) or not channel_id:
196
+ raise SocialPublishFailedError("YouTube returned a channel without a stable channel ID.")
197
+ thumbnails = snippet.get("thumbnails") if isinstance(snippet.get("thumbnails"), dict) else {}
198
+ avatar = None
199
+ for size in ("high", "medium", "default"):
200
+ candidate = thumbnails.get(size)
201
+ if isinstance(candidate, dict) and isinstance(candidate.get("url"), str):
202
+ avatar = candidate["url"]
203
+ break
204
+ custom_url = snippet.get("customUrl")
205
+ return {
206
+ "external_account_id": channel_id,
207
+ "account_type": "channel",
208
+ "display_name": snippet.get("title") if isinstance(snippet.get("title"), str) else channel_id,
209
+ "username": custom_url if isinstance(custom_url, str) else None,
210
+ "avatar_url": avatar,
211
+ "metadata": {
212
+ "channel_id": channel_id,
213
+ "published_at": snippet.get("publishedAt"),
214
+ "country": snippet.get("country"),
215
+ },
216
+ }
217
+
218
+ async def get_capabilities(self) -> ProviderCapabilities:
219
+ """Return the exact supported YouTube contract; no inferred features."""
220
+ return self.capabilities
221
+
222
+ async def validate_media(self, media: dict[str, Any]) -> None:
223
+ path = media.get("path")
224
+ mime_type = str(media.get("mime_type") or "")
225
+ size = media.get("file_size")
226
+ probe = media.get("probe") if isinstance(media.get("probe"), dict) else {}
227
+ if not isinstance(path, Path) or not path.is_file():
228
+ raise SocialMediaInvalidError("YouTube media asset is not readable.")
229
+ if not mime_type.startswith("video/"):
230
+ raise SocialMediaInvalidError("YouTube accepts video media only.")
231
+ if not isinstance(size, int) or size <= 0 or size > self.settings.max_upload_size:
232
+ raise SocialMediaInvalidError("YouTube media file size is invalid or exceeds the configured limit.")
233
+ container = str(probe.get("container") or "").lower()
234
+ if not any(value in container for value in ("mp4", "quicktime", "matroska", "webm", "mpeg")):
235
+ raise SocialMediaInvalidError("YouTube media container is unsupported; create a compatible MediaRouter variant.")
236
+ video_streams = probe.get("video_streams") if isinstance(probe.get("video_streams"), list) else []
237
+ if not video_streams:
238
+ raise SocialMediaInvalidError("YouTube media must contain a video stream.")
239
+ codec = str(video_streams[0].get("codec") or "").lower() if isinstance(video_streams[0], dict) else ""
240
+ if codec not in {"h264", "hevc", "vp8", "vp9", "av1", "mpeg4"}:
241
+ raise SocialMediaInvalidError("YouTube video codec is unsupported; create a compatible MediaRouter variant.")
242
+ duration = probe.get("duration")
243
+ if not isinstance(duration, (int, float)) or duration <= 0:
244
+ raise SocialMediaInvalidError("YouTube media must have a positive duration.")
245
+ resolution = probe.get("resolution") if isinstance(probe.get("resolution"), dict) else {}
246
+ width, height = resolution.get("width"), resolution.get("height")
247
+ if not isinstance(width, int) or not isinstance(height, int) or width < 1 or height < 1:
248
+ raise SocialMediaInvalidError("YouTube media must have valid dimensions.")
249
+
250
+ async def upload_media(self, token: dict[str, Any], media: dict[str, Any]) -> dict[str, Any]:
251
+ await self.validate_media(media)
252
+ resource = media.get("youtube_resource")
253
+ if not isinstance(resource, dict):
254
+ raise SocialPublishFailedError("Typed YouTube metadata is required to upload a video.")
255
+ path = media["path"]
256
+ assert isinstance(path, Path)
257
+ total = int(media["file_size"])
258
+ mime_type = str(media["mime_type"])
259
+ persist_session = media.get("persist_upload_session")
260
+ if persist_session is not None and not callable(persist_session):
261
+ raise SocialPublishFailedError("YouTube upload session persistence is invalid.")
262
+ heartbeat = media.get("heartbeat")
263
+ if heartbeat is not None and not callable(heartbeat):
264
+ raise SocialPublishFailedError("YouTube upload heartbeat is invalid.")
265
+ session_url = media.get("upload_session_url")
266
+ if session_url is not None and not self._is_google_upload_url(str(session_url)):
267
+ raise SocialPublishFailedError("Stored YouTube upload session is invalid.")
268
+
269
+ async with self._uploads:
270
+ started = time.monotonic()
271
+ if isinstance(session_url, str) and session_url:
272
+ resumed = await self._resume_position(session_url, total, token)
273
+ if isinstance(resumed, dict):
274
+ self._log("youtube_upload_completed", bytes=total, resumed=True, elapsed=time.monotonic() - started)
275
+ return self._video_result(resumed)
276
+ if resumed is None:
277
+ session_url = None
278
+ if persist_session:
279
+ await persist_session(None)
280
+ else:
281
+ position = resumed
282
+ if not session_url:
283
+ session_url = await self._initialize_upload(
284
+ token,
285
+ resource,
286
+ total,
287
+ mime_type,
288
+ bool(media.get("notify_subscribers", True)),
289
+ )
290
+ if persist_session:
291
+ await persist_session(session_url)
292
+ position = 0
293
+ self._log("youtube_upload_started", bytes=total, resumed=position > 0)
294
+ try:
295
+ return await self._upload_chunks(
296
+ token, session_url, path, total, mime_type, position, heartbeat
297
+ )
298
+ except Exception:
299
+ self._log("youtube_upload_failed", bytes=total, elapsed=time.monotonic() - started)
300
+ raise
301
+
302
+ async def publish(self, token: dict[str, Any], payload: dict[str, Any]) -> dict[str, Any]:
303
+ # Video insertion is the publish operation in YouTube Data API v3.
304
+ # Returning the durable video identifier is deliberately not a claim
305
+ # that YouTube processing/publication has completed; the worker always
306
+ # calls get_publish_status before marking a target PUBLISHED.
307
+ upload = payload.get("upload")
308
+ if not isinstance(upload, dict) or not isinstance(upload.get("id"), str):
309
+ raise SocialPublishFailedError("YouTube upload did not return a video ID.")
310
+ return dict(upload)
311
+
312
+ async def get_publish_status(self, token: dict[str, Any], external_id: str) -> dict[str, Any]:
313
+ response = await self._api_request(
314
+ "GET",
315
+ f"{_YOUTUBE_API}/videos",
316
+ token,
317
+ params={"part": "snippet,status,processingDetails", "id": external_id},
318
+ )
319
+ items = self._json(response).get("items")
320
+ if not isinstance(items, list) or not items:
321
+ return {"id": external_id, "status": "deleted", "metadata": {"reason": "not_found"}}
322
+ video = items[0] if isinstance(items[0], dict) else {}
323
+ status = video.get("status") if isinstance(video.get("status"), dict) else {}
324
+ processing = video.get("processingDetails") if isinstance(video.get("processingDetails"), dict) else {}
325
+ upload_status = str(status.get("uploadStatus") or "")
326
+ processing_status = str(processing.get("processingStatus") or "")
327
+ if upload_status in {"failed", "rejected"} or processing_status in {"failed", "terminated"}:
328
+ normalized = "failed"
329
+ elif upload_status == "deleted":
330
+ normalized = "deleted"
331
+ elif processing_status in {"processing", "uploading"} or upload_status == "uploaded" or upload_status == "processed" and processing_status not in {"succeeded", ""}:
332
+ normalized = "processing"
333
+ elif upload_status in {"processed", "uploaded"} or processing_status == "succeeded":
334
+ normalized = "published"
335
+ else:
336
+ normalized = "unavailable"
337
+ snippet = video.get("snippet") if isinstance(video.get("snippet"), dict) else {}
338
+ return {
339
+ "id": str(video.get("id") or external_id),
340
+ "url": f"https://www.youtube.com/watch?v={video.get('id') or external_id}",
341
+ "status": normalized,
342
+ "published_at": snippet.get("publishedAt"),
343
+ "metadata": {
344
+ "privacy_status": status.get("privacyStatus"),
345
+ "upload_status": upload_status or None,
346
+ "processing_status": processing_status or None,
347
+ "failure_reason": processing.get("processingFailureReason"),
348
+ "rejection_reason": status.get("rejectionReason"),
349
+ },
350
+ }
351
+
352
+ async def delete_post(self, token: dict[str, Any], external_id: str) -> None:
353
+ try:
354
+ response = await self._client.request(
355
+ "DELETE",
356
+ f"{_YOUTUBE_API}/videos",
357
+ params={"id": external_id},
358
+ headers={"Authorization": self._bearer(token)},
359
+ )
360
+ except httpx.TimeoutException as exc:
361
+ raise SocialProviderUnavailableError("YouTube provider request timed out.") from exc
362
+ except httpx.RequestError as exc:
363
+ raise SocialProviderUnavailableError("YouTube provider request failed.") from exc
364
+ # Deletion is idempotent from MediaRouter's perspective. A video that
365
+ # was already removed must not keep a tenant unable to remove its local
366
+ # SocialPost record.
367
+ if response.status_code in {200, 204, 404}:
368
+ return
369
+ if response.status_code >= 400:
370
+ self._raise_provider_error(response)
371
+
372
+ async def get_metrics(self, token: dict[str, Any], external_id: str) -> dict[str, Any]:
373
+ response = await self._api_request(
374
+ "GET",
375
+ f"{_YOUTUBE_API}/videos",
376
+ token,
377
+ params={"part": "statistics,snippet,status", "id": external_id},
378
+ )
379
+ items = self._json(response).get("items")
380
+ if not isinstance(items, list) or not items:
381
+ return {"id": external_id, "status": "unavailable", "raw_metrics": {}}
382
+ video = items[0] if isinstance(items[0], dict) else {}
383
+ statistics = video.get("statistics") if isinstance(video.get("statistics"), dict) else {}
384
+ snippet = video.get("snippet") if isinstance(video.get("snippet"), dict) else {}
385
+ raw = {
386
+ key: value
387
+ for key, value in statistics.items()
388
+ if key in {"viewCount", "likeCount", "commentCount", "favoriteCount"}
389
+ }
390
+ return {
391
+ "id": str(video.get("id") or external_id),
392
+ "status": "available",
393
+ "views": self._int(statistics.get("viewCount")),
394
+ "likes": self._int(statistics.get("likeCount")),
395
+ "comments": self._int(statistics.get("commentCount")),
396
+ "published_at": snippet.get("publishedAt"),
397
+ "raw_metrics": raw,
398
+ }
399
+
400
+ async def _initialize_upload(
401
+ self,
402
+ token: dict[str, Any],
403
+ resource: dict[str, Any],
404
+ total: int,
405
+ mime_type: str,
406
+ notify_subscribers: bool,
407
+ ) -> str:
408
+ response = await self._api_request(
409
+ "POST",
410
+ _YOUTUBE_UPLOAD,
411
+ token,
412
+ params={"uploadType": "resumable", "part": "snippet,status", "notifySubscribers": str(notify_subscribers).lower()},
413
+ json=resource,
414
+ headers={
415
+ "X-Upload-Content-Length": str(total),
416
+ "X-Upload-Content-Type": mime_type,
417
+ },
418
+ )
419
+ location = response.headers.get("Location")
420
+ if not location or not self._is_google_upload_url(location):
421
+ raise SocialPublishFailedError("YouTube did not create a valid resumable upload session.")
422
+ return location
423
+
424
+ async def _resume_position(self, session_url: str, total: int, token: dict[str, Any]) -> int | dict[str, Any] | None:
425
+ for retry in range(4):
426
+ try:
427
+ response = await self._client.put(
428
+ session_url,
429
+ headers={
430
+ "Authorization": self._bearer(token),
431
+ "Content-Length": "0",
432
+ "Content-Range": f"bytes */{total}",
433
+ },
434
+ )
435
+ except httpx.TimeoutException as exc:
436
+ if retry == 3:
437
+ raise SocialProviderUnavailableError("YouTube resumable upload status timed out.") from exc
438
+ await asyncio.sleep(2**retry)
439
+ continue
440
+ except httpx.RequestError as exc:
441
+ if retry == 3:
442
+ raise SocialProviderUnavailableError("YouTube resumable upload status could not be reached.") from exc
443
+ await asyncio.sleep(2**retry)
444
+ continue
445
+ if response.status_code in {200, 201}:
446
+ return self._json(response)
447
+ if response.status_code == 308:
448
+ match = _RANGE.fullmatch(response.headers.get("Range", ""))
449
+ return int(match.group(1)) + 1 if match else 0
450
+ if response.status_code in {404, 410}:
451
+ return None
452
+ if response.status_code in _RETRYABLE and retry < 3:
453
+ await asyncio.sleep(2**retry)
454
+ continue
455
+ self._raise_provider_error(response)
456
+ raise SocialProviderUnavailableError("YouTube resumable upload status could not be reconciled.")
457
+
458
+ async def _upload_chunks(
459
+ self,
460
+ token: dict[str, Any],
461
+ session_url: str,
462
+ path: Path,
463
+ total: int,
464
+ mime_type: str,
465
+ position: int,
466
+ heartbeat: Any,
467
+ ) -> dict[str, Any]:
468
+ async with aiofiles.open(path, "rb") as media:
469
+ await media.seek(position)
470
+ while position < total:
471
+ if heartbeat is not None:
472
+ await heartbeat()
473
+ chunk = await media.read(min(self.settings.youtube_upload_chunk_bytes, total - position))
474
+ if not chunk:
475
+ raise SocialPublishFailedError("YouTube upload file ended before its registered size.")
476
+ end = position + len(chunk) - 1
477
+ response = await self._put_chunk_with_resume(
478
+ token, session_url, chunk, position, end, total, mime_type
479
+ )
480
+ if isinstance(response, dict):
481
+ self._log("youtube_upload_completed", bytes=total)
482
+ return self._video_result(response)
483
+ next_position = response
484
+ if next_position < position:
485
+ raise SocialPublishFailedError("YouTube resumable upload returned an invalid byte range.")
486
+ position = next_position
487
+ await media.seek(position)
488
+ raise SocialPublishFailedError("YouTube upload ended without a video result.")
489
+
490
+ async def _put_chunk_with_resume(
491
+ self, token: dict[str, Any], session_url: str, chunk: bytes, start: int, end: int, total: int, mime_type: str
492
+ ) -> int | dict[str, Any]:
493
+ for retry in range(5):
494
+ try:
495
+ response = await self._client.put(
496
+ session_url,
497
+ content=chunk,
498
+ headers={
499
+ "Authorization": self._bearer(token),
500
+ "Content-Type": mime_type,
501
+ "Content-Length": str(len(chunk)),
502
+ "Content-Range": f"bytes {start}-{end}/{total}",
503
+ },
504
+ )
505
+ except (httpx.TimeoutException, httpx.RequestError) as exc:
506
+ if retry == 4:
507
+ raise SocialProviderUnavailableError("YouTube upload chunk could not be delivered.") from exc
508
+ reconciled = await self._resume_position(session_url, total, token)
509
+ if isinstance(reconciled, dict):
510
+ return reconciled
511
+ if reconciled is None:
512
+ raise SocialProviderUnavailableError("YouTube resumable upload session expired.") from exc
513
+ if reconciled > start:
514
+ return reconciled
515
+ await asyncio.sleep(2**retry)
516
+ continue
517
+ if response.status_code in {200, 201}:
518
+ return self._json(response)
519
+ if response.status_code == 308:
520
+ match = _RANGE.fullmatch(response.headers.get("Range", ""))
521
+ return int(match.group(1)) + 1 if match else 0
522
+ if response.status_code in _RETRYABLE and retry < 4:
523
+ reconciled = await self._resume_position(session_url, total, token)
524
+ if isinstance(reconciled, dict):
525
+ return reconciled
526
+ if reconciled is None:
527
+ raise SocialProviderUnavailableError("YouTube resumable upload session expired.")
528
+ if reconciled > start:
529
+ return reconciled
530
+ await asyncio.sleep(2**retry)
531
+ continue
532
+ self._raise_provider_error(response)
533
+ raise SocialProviderUnavailableError("YouTube upload chunk retry budget was exhausted.")
534
+
535
+ async def _api_request(self, method: str, url: str, token: dict[str, Any], **kwargs: Any) -> httpx.Response:
536
+ headers = dict(kwargs.pop("headers", {}))
537
+ headers["Authorization"] = self._bearer(token)
538
+ return await self._request(method, url, headers=headers, **kwargs)
539
+
540
+ async def _request(self, method: str, url: str, *, oauth: bool = False, **kwargs: Any) -> httpx.Response:
541
+ try:
542
+ response = await self._client.request(method, url, **kwargs)
543
+ except httpx.TimeoutException as exc:
544
+ raise SocialProviderUnavailableError("YouTube provider request timed out.") from exc
545
+ except httpx.RequestError as exc:
546
+ raise SocialProviderUnavailableError("YouTube provider request failed.") from exc
547
+ if response.status_code >= 400:
548
+ self._raise_provider_error(response, oauth=oauth)
549
+ return response
550
+
551
+ @staticmethod
552
+ def _json(response: httpx.Response) -> dict[str, Any]:
553
+ try:
554
+ value = response.json()
555
+ except ValueError as exc:
556
+ raise SocialPublishFailedError("YouTube returned an invalid response.") from exc
557
+ if not isinstance(value, dict):
558
+ raise SocialPublishFailedError("YouTube returned an invalid response.")
559
+ return value
560
+
561
+ def _raise_provider_error(self, response: httpx.Response, *, oauth: bool = False) -> None:
562
+ try:
563
+ payload = response.json()
564
+ except ValueError:
565
+ payload = {}
566
+ error = payload.get("error") if isinstance(payload, dict) else {}
567
+ detail = error if isinstance(error, dict) else {}
568
+ reasons = {
569
+ item.get("reason")
570
+ for item in detail.get("errors", [])
571
+ if isinstance(item, dict) and isinstance(item.get("reason"), str)
572
+ }
573
+ logger.warning(
574
+ "youtube_provider_error",
575
+ extra={
576
+ "provider": "youtube",
577
+ "provider_status": response.status_code,
578
+ "provider_reasons": sorted(reasons),
579
+ "oauth": oauth,
580
+ },
581
+ )
582
+ # Never pass arbitrary upstream text through. Google messages may
583
+ # contain user-provided metadata; stable messages remain safe to log.
584
+ if response.status_code == 401:
585
+ raise SocialReauthRequiredError("YouTube authorization is no longer valid.")
586
+ if response.status_code == 403:
587
+ if reasons & {"quotaExceeded", "dailyLimitExceeded", "userRateLimitExceeded"}:
588
+ raise SocialProviderQuotaError("YouTube API quota is exhausted.")
589
+ raise SocialPermissionDeniedError("YouTube denied the requested operation.")
590
+ if response.status_code == 429:
591
+ if reasons & {"quotaExceeded", "dailyLimitExceeded"}:
592
+ raise SocialProviderQuotaError("YouTube API quota is exhausted.")
593
+ raise SocialRateLimitedError("YouTube rate limit was reached.")
594
+ if response.status_code in {500, 502, 503, 504}:
595
+ raise SocialProviderUnavailableError("YouTube is temporarily unavailable.")
596
+ if oauth and response.status_code == 400:
597
+ raise SocialReauthRequiredError("Google rejected the OAuth authorization response.")
598
+ if response.status_code == 400:
599
+ raise SocialPublishFailedError("YouTube rejected the supplied video metadata or upload request.")
600
+ raise SocialPublishFailedError("YouTube provider request failed.")
601
+
602
+ @staticmethod
603
+ def _video_result(video: dict[str, Any]) -> dict[str, Any]:
604
+ video_id = video.get("id")
605
+ if not isinstance(video_id, str) or not video_id:
606
+ raise SocialPublishFailedError("YouTube upload completed without a video ID.")
607
+ return {"id": video_id, "url": f"https://www.youtube.com/watch?v={video_id}"}
608
+
609
+ @staticmethod
610
+ def _bearer(token: dict[str, Any]) -> str:
611
+ access_token = token.get("access_token")
612
+ if not isinstance(access_token, str) or not access_token:
613
+ raise SocialReauthRequiredError("The YouTube account has no access token.")
614
+ return f"Bearer {access_token}"
615
+
616
+ @staticmethod
617
+ def _is_google_upload_url(value: str) -> bool:
618
+ parsed = urlparse(value)
619
+ return parsed.scheme == "https" and parsed.hostname in {"www.googleapis.com", "upload.youtube.com"} and parsed.path.startswith("/upload/youtube/")
620
+
621
+ @staticmethod
622
+ def _int(value: object) -> int | None:
623
+ try:
624
+ return int(str(value))
625
+ except (TypeError, ValueError):
626
+ return None
627
+
628
+ @staticmethod
629
+ def _log(event: str, **fields: Any) -> None:
630
+ logger.info(event, extra={"provider": "youtube", **fields})
app/social/repositories/__init__.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ from app.social.repositories.accounts import AccountRepository
2
+ from app.social.repositories.jobs import JobRepository
3
+ from app.social.repositories.posts import PostRepository
4
+ from app.social.repositories.tokens import TokenRepository
5
+
6
+ __all__ = ["AccountRepository", "JobRepository", "PostRepository", "TokenRepository"]
app/social/repositories/accounts.py ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from sqlalchemy import select
4
+
5
+ from app.social.database import SocialDatabase
6
+ from app.social.domain.errors import SocialAccountNotFoundError
7
+ from app.social.models import SocialAccount
8
+
9
+
10
+ class AccountRepository:
11
+ def __init__(self, database: SocialDatabase) -> None:
12
+ self.database = database
13
+
14
+ async def list(self, workspace_id: str, *, offset: int = 0, limit: int = 100) -> list[SocialAccount]:
15
+ async with self.database.session(workspace_id) as session:
16
+ return list((await session.scalars(select(SocialAccount).where(SocialAccount.workspace_id == workspace_id).order_by(SocialAccount.created_at.desc()).offset(offset).limit(limit))).all())
17
+
18
+ async def get(self, workspace_id: str, account_id: str) -> SocialAccount:
19
+ async with self.database.session(workspace_id) as session:
20
+ record = await session.scalar(select(SocialAccount).where(SocialAccount.id == account_id, SocialAccount.workspace_id == workspace_id))
21
+ if record is None:
22
+ raise SocialAccountNotFoundError("Social account was not found.")
23
+ return record
24
+
25
+ async def create(self, record: SocialAccount) -> SocialAccount:
26
+ async with self.database.session(record.workspace_id) as session:
27
+ session.add(record)
28
+ await session.commit()
29
+ await session.refresh(record)
30
+ return record
31
+
32
+ async def get_by_external(
33
+ self, workspace_id: str, provider: str, external_account_id: str
34
+ ) -> SocialAccount | None:
35
+ async with self.database.session(workspace_id) as session:
36
+ return await session.scalar(
37
+ select(SocialAccount).where(
38
+ SocialAccount.workspace_id == workspace_id,
39
+ SocialAccount.provider == provider,
40
+ SocialAccount.external_account_id == external_account_id,
41
+ )
42
+ )
43
+
44
+ async def update_connection(
45
+ self, workspace_id: str, account_id: str, *, account_type: str,
46
+ username: str | None, display_name: str | None, avatar_url: str | None,
47
+ metadata: dict[str, object],
48
+ ) -> SocialAccount:
49
+ async with self.database.session(workspace_id) as session:
50
+ record = await session.scalar(
51
+ select(SocialAccount).where(
52
+ SocialAccount.id == account_id,
53
+ SocialAccount.workspace_id == workspace_id,
54
+ )
55
+ )
56
+ if record is None:
57
+ raise SocialAccountNotFoundError("Social account was not found.")
58
+ record.account_type = account_type
59
+ record.username = username
60
+ record.display_name = display_name
61
+ record.avatar_url = avatar_url
62
+ record.metadata_json = metadata
63
+ record.status = "connected"
64
+ await session.commit()
65
+ await session.refresh(record)
66
+ return record
67
+
68
+ async def disconnect(self, workspace_id: str, account_id: str) -> SocialAccount:
69
+ async with self.database.session(workspace_id) as session:
70
+ record = await session.scalar(select(SocialAccount).where(SocialAccount.id == account_id, SocialAccount.workspace_id == workspace_id))
71
+ if record is None:
72
+ raise SocialAccountNotFoundError("Social account was not found.")
73
+ record.status = "disconnected"
74
+ await session.commit()
75
+ return record
76
+
77
+ async def set_status(
78
+ self, workspace_id: str, account_id: str, status: str
79
+ ) -> SocialAccount:
80
+ async with self.database.session(workspace_id) as session:
81
+ record = await session.scalar(
82
+ select(SocialAccount).where(
83
+ SocialAccount.id == account_id,
84
+ SocialAccount.workspace_id == workspace_id,
85
+ )
86
+ )
87
+ if record is None:
88
+ raise SocialAccountNotFoundError("Social account was not found.")
89
+ record.status = status
90
+ await session.commit()
91
+ return record
app/social/repositories/assets.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from sqlalchemy import select
4
+ from sqlalchemy.exc import IntegrityError
5
+
6
+ from app.social.database import SocialDatabase
7
+ from app.social.domain.errors import SocialMediaInvalidError
8
+ from app.social.models import SocialMediaAsset
9
+
10
+
11
+ class SocialMediaAssetRepository:
12
+ def __init__(self, database: SocialDatabase) -> None:
13
+ self.database = database
14
+
15
+ async def get(self, workspace_id: str, asset_id: str) -> SocialMediaAsset:
16
+ async with self.database.session(workspace_id) as session:
17
+ record = await session.scalar(
18
+ select(SocialMediaAsset).where(
19
+ SocialMediaAsset.id == asset_id,
20
+ SocialMediaAsset.workspace_id == workspace_id,
21
+ )
22
+ )
23
+ if record is None:
24
+ raise SocialMediaInvalidError("Media asset was not found in this workspace.")
25
+ return record
26
+
27
+ async def list(self, workspace_id: str, *, offset: int = 0, limit: int = 100) -> list[SocialMediaAsset]:
28
+ async with self.database.session(workspace_id) as session:
29
+ return list(
30
+ (
31
+ await session.scalars(
32
+ select(SocialMediaAsset)
33
+ .where(SocialMediaAsset.workspace_id == workspace_id)
34
+ .order_by(SocialMediaAsset.created_at.desc())
35
+ .offset(offset)
36
+ .limit(limit)
37
+ )
38
+ ).all()
39
+ )
40
+
41
+ async def create(self, record: SocialMediaAsset) -> SocialMediaAsset:
42
+ try:
43
+ async with self.database.session(record.workspace_id) as session:
44
+ session.add(record)
45
+ await session.commit()
46
+ await session.refresh(record)
47
+ return record
48
+ except IntegrityError:
49
+ async with self.database.session(record.workspace_id) as session:
50
+ existing = await session.scalar(
51
+ select(SocialMediaAsset).where(
52
+ SocialMediaAsset.workspace_id == record.workspace_id,
53
+ SocialMediaAsset.request_id == record.request_id,
54
+ SocialMediaAsset.filename == record.filename,
55
+ )
56
+ )
57
+ if existing is None:
58
+ raise
59
+ return existing
app/social/repositories/jobs.py ADDED
@@ -0,0 +1,287 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timedelta, timezone
4
+
5
+ from sqlalchemy import and_, or_, select
6
+ from sqlalchemy.exc import IntegrityError
7
+
8
+ from app.social.database import SocialDatabase
9
+ from app.social.domain.errors import SocialJobNotFoundError
10
+ from app.social.domain.state_machine import validate_transition
11
+ from app.social.models import SocialJob, SocialJobAttempt
12
+ from app.social.oauth.encryption import TokenCipher
13
+
14
+
15
+ class JobRepository:
16
+ def __init__(self, database: SocialDatabase, cipher: TokenCipher) -> None:
17
+ self.database = database
18
+ self.cipher = cipher
19
+
20
+ async def list(
21
+ self, workspace_id: str, *, offset: int = 0, limit: int = 100
22
+ ) -> list[SocialJob]:
23
+ async with self.database.session(workspace_id) as session:
24
+ return list(
25
+ (
26
+ await session.scalars(
27
+ select(SocialJob)
28
+ .where(SocialJob.workspace_id == workspace_id)
29
+ .order_by(SocialJob.created_at.desc())
30
+ .offset(offset)
31
+ .limit(limit)
32
+ )
33
+ ).all()
34
+ )
35
+
36
+ async def get(self, workspace_id: str, job_id: str) -> SocialJob:
37
+ async with self.database.session(workspace_id) as session:
38
+ record = await session.scalar(
39
+ select(SocialJob).where(
40
+ SocialJob.id == job_id, SocialJob.workspace_id == workspace_id
41
+ )
42
+ )
43
+ if record is None:
44
+ raise SocialJobNotFoundError("Social job was not found.")
45
+ return record
46
+
47
+ async def get_by_idempotency(
48
+ self, workspace_id: str, idempotency_key: str
49
+ ) -> SocialJob | None:
50
+ async with self.database.session(workspace_id) as session:
51
+ return await session.scalar(
52
+ select(SocialJob).where(
53
+ SocialJob.workspace_id == workspace_id,
54
+ SocialJob.idempotency_key == idempotency_key,
55
+ )
56
+ )
57
+
58
+ async def list_for_post(self, workspace_id: str, post_id: str) -> list[SocialJob]:
59
+ async with self.database.session(workspace_id) as session:
60
+ return list(
61
+ (
62
+ await session.scalars(
63
+ select(SocialJob).where(
64
+ SocialJob.workspace_id == workspace_id,
65
+ SocialJob.social_post_id == post_id,
66
+ )
67
+ )
68
+ ).all()
69
+ )
70
+
71
+ async def create_many(self, jobs: list[SocialJob]) -> list[SocialJob]:
72
+ if not jobs:
73
+ return []
74
+ try:
75
+ async with self.database.session(jobs[0].workspace_id) as session:
76
+ session.add_all(jobs)
77
+ await session.commit()
78
+ for record in jobs:
79
+ await session.refresh(record)
80
+ return jobs
81
+ except IntegrityError:
82
+ canonical: list[SocialJob] = []
83
+ for job in jobs:
84
+ if not job.idempotency_key:
85
+ raise
86
+ record = await self.get_by_idempotency(
87
+ job.workspace_id, job.idempotency_key
88
+ )
89
+ if record is None:
90
+ raise
91
+ canonical.append(record)
92
+ return canonical
93
+
94
+ async def transition(
95
+ self,
96
+ workspace_id: str,
97
+ job_id: str,
98
+ status: str,
99
+ *,
100
+ error_code: str | None = None,
101
+ error_message: str | None = None,
102
+ next_attempt_at: datetime | None = None,
103
+ ) -> SocialJob:
104
+ async with self.database.session(workspace_id) as session:
105
+ record = await session.scalar(
106
+ select(SocialJob).where(
107
+ SocialJob.id == job_id, SocialJob.workspace_id == workspace_id
108
+ )
109
+ )
110
+ if record is None:
111
+ raise SocialJobNotFoundError("Social job was not found.")
112
+ destination = validate_transition(record.status, status)
113
+ record.status = destination.value
114
+ record.error_code = error_code
115
+ record.error_message = error_message
116
+ record.next_attempt_at = next_attempt_at
117
+ now = datetime.now(timezone.utc)
118
+ if destination.value == "preparing" and record.started_at is None:
119
+ record.started_at = now
120
+ if destination.value in {"published", "failed", "cancelled"}:
121
+ record.completed_at = now
122
+ await session.commit()
123
+ return record
124
+
125
+ async def set_provider_state(
126
+ self, workspace_id: str, job_id: str, state: dict[str, object] | None
127
+ ) -> None:
128
+ async with self.database.session(workspace_id) as session:
129
+ record = await session.scalar(
130
+ select(SocialJob).where(
131
+ SocialJob.id == job_id, SocialJob.workspace_id == workspace_id
132
+ )
133
+ )
134
+ if record is None:
135
+ raise SocialJobNotFoundError("Social job was not found.")
136
+ record.provider_state_encrypted = self.cipher.encrypt(state) if state else None
137
+ await session.commit()
138
+
139
+ async def get_provider_state(self, workspace_id: str, job_id: str) -> dict[str, object]:
140
+ async with self.database.session(workspace_id) as session:
141
+ record = await session.scalar(
142
+ select(SocialJob).where(
143
+ SocialJob.id == job_id, SocialJob.workspace_id == workspace_id
144
+ )
145
+ )
146
+ if record is None:
147
+ raise SocialJobNotFoundError("Social job was not found.")
148
+ value = record.provider_state_encrypted
149
+ if not value:
150
+ return {}
151
+ decoded = self.cipher.decrypt(value)
152
+ return decoded if isinstance(decoded, dict) else {}
153
+
154
+ async def defer_reconciliation(
155
+ self, workspace_id: str, job_id: str, *, next_attempt_at: datetime
156
+ ) -> SocialJob:
157
+ """Keep provider processing in PUBLISHING without counting a retry."""
158
+ async with self.database.session(workspace_id) as session:
159
+ record = await session.scalar(
160
+ select(SocialJob).where(
161
+ SocialJob.id == job_id, SocialJob.workspace_id == workspace_id
162
+ )
163
+ )
164
+ if record is None:
165
+ raise SocialJobNotFoundError("Social job was not found.")
166
+ if record.status != "publishing":
167
+ raise SocialJobNotFoundError("Social job is not awaiting provider reconciliation.")
168
+ record.next_attempt_at = next_attempt_at
169
+ await session.commit()
170
+ await session.refresh(record)
171
+ return record
172
+
173
+ async def heartbeat(self, workspace_id: str, job_id: str) -> None:
174
+ """Renew the active worker lease during a long streaming upload."""
175
+ async with self.database.session(workspace_id) as session:
176
+ record = await session.scalar(
177
+ select(SocialJob).where(
178
+ SocialJob.id == job_id, SocialJob.workspace_id == workspace_id
179
+ )
180
+ )
181
+ if record is None:
182
+ raise SocialJobNotFoundError("Social job was not found.")
183
+ record.updated_at = datetime.now(timezone.utc)
184
+ await session.commit()
185
+
186
+ async def start_attempt(self, workspace_id: str, job_id: str) -> SocialJobAttempt:
187
+ async with self.database.session(workspace_id) as session:
188
+ record = await session.scalar(
189
+ select(SocialJob).where(
190
+ SocialJob.id == job_id, SocialJob.workspace_id == workspace_id
191
+ )
192
+ )
193
+ if record is None:
194
+ raise SocialJobNotFoundError("Social job was not found.")
195
+ record.attempt_count += 1
196
+ attempt = SocialJobAttempt(
197
+ social_job_id=job_id,
198
+ attempt_number=record.attempt_count,
199
+ status="started",
200
+ )
201
+ session.add(attempt)
202
+ await session.commit()
203
+ await session.refresh(attempt)
204
+ return attempt
205
+
206
+ async def complete_attempt(
207
+ self,
208
+ attempt_id: str,
209
+ *,
210
+ status: str,
211
+ error_code: str | None = None,
212
+ error_message: str | None = None,
213
+ provider_request_id: str | None = None,
214
+ ) -> None:
215
+ async with self.database.session() as session:
216
+ attempt = await session.get(SocialJobAttempt, attempt_id)
217
+ if attempt:
218
+ attempt.status = status
219
+ attempt.error_code = error_code
220
+ attempt.error_message = error_message
221
+ attempt.provider_request_id = provider_request_id
222
+ attempt.completed_at = datetime.now(timezone.utc)
223
+ await session.commit()
224
+
225
+ async def claim_due(
226
+ self, *, limit: int = 50, stale_after_seconds: int = 900
227
+ ) -> list[SocialJob]:
228
+ now = datetime.now(timezone.utc)
229
+ stale_before = now - timedelta(seconds=stale_after_seconds)
230
+ async with self.database.session() as session:
231
+ active_statuses = ["preparing", "processing", "uploading", "publishing"]
232
+ statement = (
233
+ select(SocialJob)
234
+ .where(
235
+ or_(
236
+ and_(
237
+ SocialJob.status.in_(["queued", "retrying"]),
238
+ (
239
+ SocialJob.next_attempt_at.is_(None)
240
+ | (SocialJob.next_attempt_at <= now)
241
+ ),
242
+ ),
243
+ and_(
244
+ SocialJob.status == "publishing",
245
+ SocialJob.next_attempt_at.is_not(None),
246
+ SocialJob.next_attempt_at <= now,
247
+ ),
248
+ and_(
249
+ SocialJob.status.in_(active_statuses),
250
+ SocialJob.updated_at <= stale_before,
251
+ ),
252
+ )
253
+ )
254
+ .order_by(SocialJob.created_at)
255
+ .limit(limit)
256
+ .with_for_update(skip_locked=True)
257
+ )
258
+ jobs = list((await session.scalars(statement)).all())
259
+ claimed: list[SocialJob] = []
260
+ for job in jobs:
261
+ # A provider-side processing poll is not a failed attempt and
262
+ # must remain in PUBLISHING. Clear its due timestamp while the
263
+ # worker owns this reconciliation pass.
264
+ if job.status == "publishing" and job.next_attempt_at and job.next_attempt_at <= now:
265
+ job.next_attempt_at = None
266
+ claimed.append(job)
267
+ continue
268
+ if job.status in active_statuses:
269
+ if job.attempt_count >= job.max_attempts:
270
+ validate_transition(job.status, "failed")
271
+ job.status = "failed"
272
+ job.error_code = "SOCIAL_WORKER_LEASE_EXPIRED"
273
+ job.error_message = "The worker lease expired after the retry limit."
274
+ job.completed_at = now
275
+ continue
276
+ validate_transition(job.status, "retrying")
277
+ job.status = "retrying"
278
+ job.error_code = "SOCIAL_WORKER_LEASE_EXPIRED"
279
+ job.error_message = "The worker lease expired; retrying safely."
280
+ job.next_attempt_at = now
281
+ validate_transition(job.status, "preparing")
282
+ job.status = "preparing"
283
+ if job.started_at is None:
284
+ job.started_at = now
285
+ claimed.append(job)
286
+ await session.commit()
287
+ return claimed
app/social/repositories/posts.py ADDED
@@ -0,0 +1,310 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timezone
4
+
5
+ from sqlalchemy import select
6
+ from sqlalchemy.exc import IntegrityError
7
+
8
+ from app.social.database import SocialDatabase
9
+ from app.social.domain.errors import SocialIdempotencyConflictError, SocialPostNotFoundError
10
+ from app.social.models import (
11
+ MediaVariant,
12
+ SocialCampaign,
13
+ SocialPost,
14
+ SocialPostTarget,
15
+ SocialSchedule,
16
+ )
17
+
18
+
19
+ class PostRepository:
20
+ def __init__(self, database: SocialDatabase) -> None:
21
+ self.database = database
22
+
23
+ async def assert_related_resources_owned(
24
+ self,
25
+ workspace_id: str,
26
+ *,
27
+ campaign_id: str | None,
28
+ variant_id: str | None,
29
+ ) -> None:
30
+ async with self.database.session(workspace_id) as session:
31
+ if campaign_id:
32
+ campaign = await session.scalar(
33
+ select(SocialCampaign.id).where(
34
+ SocialCampaign.id == campaign_id,
35
+ SocialCampaign.workspace_id == workspace_id,
36
+ )
37
+ )
38
+ if campaign is None:
39
+ raise SocialPostNotFoundError("Social campaign was not found.")
40
+ if variant_id:
41
+ variant = await session.scalar(
42
+ select(MediaVariant.id).where(
43
+ MediaVariant.id == variant_id,
44
+ MediaVariant.workspace_id == workspace_id,
45
+ )
46
+ )
47
+ if variant is None:
48
+ raise SocialPostNotFoundError("Media variant was not found.")
49
+
50
+ async def list(
51
+ self, workspace_id: str, *, offset: int = 0, limit: int = 100
52
+ ) -> list[tuple[SocialPost, list[SocialPostTarget]]]:
53
+ async with self.database.session(workspace_id) as session:
54
+ posts = list(
55
+ (
56
+ await session.scalars(
57
+ select(SocialPost)
58
+ .where(SocialPost.workspace_id == workspace_id)
59
+ .order_by(SocialPost.created_at.desc())
60
+ .offset(offset)
61
+ .limit(limit)
62
+ )
63
+ ).all()
64
+ )
65
+ if not posts:
66
+ return []
67
+ targets = list(
68
+ (
69
+ await session.scalars(
70
+ select(SocialPostTarget).where(
71
+ SocialPostTarget.social_post_id.in_([post.id for post in posts])
72
+ )
73
+ )
74
+ ).all()
75
+ )
76
+ by_post: dict[str, list[SocialPostTarget]] = {}
77
+ for target in targets:
78
+ by_post.setdefault(target.social_post_id, []).append(target)
79
+ return [(post, by_post.get(post.id, [])) for post in posts]
80
+
81
+ async def get(
82
+ self, workspace_id: str, post_id: str
83
+ ) -> tuple[SocialPost, list[SocialPostTarget]]:
84
+ async with self.database.session(workspace_id) as session:
85
+ post = await session.scalar(
86
+ select(SocialPost).where(
87
+ SocialPost.id == post_id, SocialPost.workspace_id == workspace_id
88
+ )
89
+ )
90
+ if post is None:
91
+ raise SocialPostNotFoundError("Social post was not found.")
92
+ targets = list(
93
+ (
94
+ await session.scalars(
95
+ select(SocialPostTarget)
96
+ .where(SocialPostTarget.social_post_id == post_id)
97
+ .order_by(SocialPostTarget.created_at)
98
+ )
99
+ ).all()
100
+ )
101
+ return post, targets
102
+
103
+ async def get_by_post_id_unscoped(
104
+ self, post_id: str
105
+ ) -> tuple[SocialPost, list[SocialPostTarget]]:
106
+ """Worker-only lookup; API requests must always use the tenant-scoped get."""
107
+ async with self.database.session() as session:
108
+ post = await session.get(SocialPost, post_id)
109
+ if post is None:
110
+ raise SocialPostNotFoundError("Social post was not found.")
111
+ targets = list(
112
+ (
113
+ await session.scalars(
114
+ select(SocialPostTarget).where(
115
+ SocialPostTarget.social_post_id == post_id
116
+ )
117
+ )
118
+ ).all()
119
+ )
120
+ return post, targets
121
+
122
+ async def get_by_idempotency(
123
+ self, workspace_id: str, idempotency_key: str
124
+ ) -> tuple[SocialPost, list[SocialPostTarget]] | None:
125
+ async with self.database.session(workspace_id) as session:
126
+ post = await session.scalar(
127
+ select(SocialPost).where(
128
+ SocialPost.workspace_id == workspace_id,
129
+ SocialPost.idempotency_key == idempotency_key,
130
+ )
131
+ )
132
+ return await self.get(workspace_id, post.id) if post else None
133
+
134
+ async def create(
135
+ self, post: SocialPost, targets: list[SocialPostTarget]
136
+ ) -> tuple[SocialPost, list[SocialPostTarget]]:
137
+ try:
138
+ async with self.database.session(post.workspace_id) as session:
139
+ session.add(post)
140
+ await session.flush()
141
+ for target in targets:
142
+ target.social_post_id = post.id
143
+ session.add(target)
144
+ await session.commit()
145
+ await session.refresh(post)
146
+ for target in targets:
147
+ await session.refresh(target)
148
+ return post, targets
149
+ except IntegrityError:
150
+ if post.idempotency_key:
151
+ existing = await self.get_by_idempotency(
152
+ post.workspace_id, post.idempotency_key
153
+ )
154
+ if existing and existing[0].request_fingerprint == post.request_fingerprint:
155
+ return existing
156
+ raise SocialIdempotencyConflictError(
157
+ "The idempotency key was already used for a different request."
158
+ )
159
+ raise
160
+
161
+ async def set_status(
162
+ self, workspace_id: str, post_id: str, status: str
163
+ ) -> tuple[SocialPost, list[SocialPostTarget]]:
164
+ async with self.database.session(workspace_id) as session:
165
+ post = await session.scalar(
166
+ select(SocialPost).where(
167
+ SocialPost.id == post_id, SocialPost.workspace_id == workspace_id
168
+ )
169
+ )
170
+ if post is None:
171
+ raise SocialPostNotFoundError("Social post was not found.")
172
+ post.status = status
173
+ if status == "published":
174
+ post.published_at = datetime.now(timezone.utc)
175
+ await session.commit()
176
+ return await self.get(workspace_id, post_id)
177
+
178
+ async def set_target_status(
179
+ self,
180
+ workspace_id: str,
181
+ target_id: str,
182
+ status: str,
183
+ *,
184
+ error_code: str | None = None,
185
+ error_message: str | None = None,
186
+ external_post_id: str | None = None,
187
+ external_url: str | None = None,
188
+ provider_metadata: dict[str, object] | None = None,
189
+ ) -> SocialPostTarget:
190
+ async with self.database.session(workspace_id) as session:
191
+ target = await session.scalar(
192
+ select(SocialPostTarget)
193
+ .join(SocialPost, SocialPost.id == SocialPostTarget.social_post_id)
194
+ .where(
195
+ SocialPostTarget.id == target_id,
196
+ SocialPost.workspace_id == workspace_id,
197
+ )
198
+ )
199
+ if target is None:
200
+ raise SocialPostNotFoundError("Social post target was not found.")
201
+ target.status = status
202
+ target.error_code = error_code
203
+ target.error_message = error_message
204
+ target.external_post_id = external_post_id or target.external_post_id
205
+ target.external_url = external_url or target.external_url
206
+ if provider_metadata:
207
+ target.platform_metadata = {
208
+ **target.platform_metadata,
209
+ "provider": {
210
+ **(
211
+ target.platform_metadata.get("provider", {})
212
+ if isinstance(target.platform_metadata.get("provider"), dict)
213
+ else {}
214
+ ),
215
+ **provider_metadata,
216
+ },
217
+ }
218
+ if status == "published":
219
+ target.published_at = datetime.now(timezone.utc)
220
+ await session.commit()
221
+ return target
222
+
223
+ async def delete(self, workspace_id: str, post_id: str) -> None:
224
+ post, _ = await self.get(workspace_id, post_id)
225
+ async with self.database.session(workspace_id) as session:
226
+ attached = await session.merge(post)
227
+ await session.delete(attached)
228
+ await session.commit()
229
+
230
+ async def upsert_schedule(
231
+ self,
232
+ workspace_id: str,
233
+ post_id: str,
234
+ *,
235
+ scheduled_at: datetime,
236
+ timezone_name: str,
237
+ ) -> SocialSchedule:
238
+ await self.get(workspace_id, post_id)
239
+ try:
240
+ return await self._write_schedule(
241
+ workspace_id, post_id, scheduled_at, timezone_name
242
+ )
243
+ except IntegrityError:
244
+ # Two clients can race to schedule the same post. The unique
245
+ # constraint remains authoritative; the loser retries as an update.
246
+ return await self._write_schedule(
247
+ workspace_id, post_id, scheduled_at, timezone_name
248
+ )
249
+
250
+ async def _write_schedule(
251
+ self,
252
+ workspace_id: str,
253
+ post_id: str,
254
+ scheduled_at: datetime,
255
+ timezone_name: str,
256
+ ) -> SocialSchedule:
257
+ async with self.database.session(workspace_id) as session:
258
+ schedule = await session.scalar(
259
+ select(SocialSchedule)
260
+ .where(SocialSchedule.social_post_id == post_id)
261
+ .with_for_update()
262
+ )
263
+ if schedule is None:
264
+ schedule = SocialSchedule(
265
+ social_post_id=post_id,
266
+ scheduled_at=scheduled_at,
267
+ timezone=timezone_name,
268
+ status="scheduled",
269
+ )
270
+ session.add(schedule)
271
+ else:
272
+ schedule.scheduled_at = scheduled_at
273
+ schedule.timezone = timezone_name
274
+ schedule.status = "scheduled"
275
+ post = await session.get(SocialPost, post_id)
276
+ if post:
277
+ post.status = "scheduled"
278
+ post.publish_mode = "schedule"
279
+ await session.commit()
280
+ await session.refresh(schedule)
281
+ return schedule
282
+
283
+ async def cancel_schedule(self, workspace_id: str, post_id: str) -> None:
284
+ await self.get(workspace_id, post_id)
285
+ async with self.database.session(workspace_id) as session:
286
+ schedule = await session.scalar(
287
+ select(SocialSchedule).where(SocialSchedule.social_post_id == post_id)
288
+ )
289
+ if schedule:
290
+ schedule.status = "cancelled"
291
+ await session.commit()
292
+
293
+ async def claim_due_schedules(self, *, limit: int = 100) -> list[SocialSchedule]:
294
+ now = datetime.now(timezone.utc)
295
+ async with self.database.session() as session:
296
+ statement = (
297
+ select(SocialSchedule)
298
+ .where(
299
+ SocialSchedule.status == "scheduled",
300
+ SocialSchedule.scheduled_at <= now,
301
+ )
302
+ .order_by(SocialSchedule.scheduled_at)
303
+ .limit(limit)
304
+ .with_for_update(skip_locked=True)
305
+ )
306
+ records = list((await session.scalars(statement)).all())
307
+ for record in records:
308
+ record.status = "queued"
309
+ await session.commit()
310
+ return records
app/social/repositories/tokens.py ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timezone
4
+
5
+ from sqlalchemy import select
6
+
7
+ from app.social.database import SocialDatabase
8
+ from app.social.domain.errors import SocialAccountNotFoundError
9
+ from app.social.models import SocialAccount, SocialAccountToken
10
+
11
+
12
+ class TokenRepository:
13
+ """Token persistence is intentionally accessible only through TokenService."""
14
+
15
+ def __init__(self, database: SocialDatabase) -> None:
16
+ self.database = database
17
+
18
+ async def get(
19
+ self, workspace_id: str, account_id: str
20
+ ) -> SocialAccountToken | None:
21
+ """Return a token record only within its owning tenant context."""
22
+ async with self.database.session(workspace_id) as session:
23
+ return await session.scalar(
24
+ select(SocialAccountToken)
25
+ .join(
26
+ SocialAccount,
27
+ SocialAccount.id == SocialAccountToken.social_account_id,
28
+ )
29
+ .where(
30
+ SocialAccountToken.social_account_id == account_id,
31
+ SocialAccount.workspace_id == workspace_id,
32
+ )
33
+ )
34
+
35
+ async def save(
36
+ self, workspace_id: str, record: SocialAccountToken
37
+ ) -> SocialAccountToken:
38
+ async with self.database.session(workspace_id) as session:
39
+ owner = await session.scalar(
40
+ select(SocialAccount.id).where(
41
+ SocialAccount.id == record.social_account_id,
42
+ SocialAccount.workspace_id == workspace_id,
43
+ )
44
+ )
45
+ if owner is None:
46
+ raise SocialAccountNotFoundError("Social account was not found.")
47
+ existing = await session.scalar(
48
+ select(SocialAccountToken)
49
+ .join(
50
+ SocialAccount,
51
+ SocialAccount.id == SocialAccountToken.social_account_id,
52
+ )
53
+ .where(
54
+ SocialAccountToken.social_account_id == record.social_account_id,
55
+ SocialAccount.workspace_id == workspace_id,
56
+ )
57
+ )
58
+ if existing:
59
+ existing.access_token_secret_id = record.access_token_secret_id
60
+ existing.refresh_token_secret_id = record.refresh_token_secret_id
61
+ existing.encrypted_payload = record.encrypted_payload
62
+ existing.expires_at = record.expires_at
63
+ existing.scopes = record.scopes
64
+ existing.token_type = record.token_type
65
+ existing.last_refreshed_at = datetime.now(timezone.utc)
66
+ existing.revoked_at = None
67
+ await session.commit()
68
+ return existing
69
+ session.add(record)
70
+ await session.commit()
71
+ await session.refresh(record)
72
+ return record
73
+
74
+ async def revoke(self, workspace_id: str, account_id: str) -> None:
75
+ async with self.database.session(workspace_id) as session:
76
+ record = await session.scalar(
77
+ select(SocialAccountToken)
78
+ .join(
79
+ SocialAccount,
80
+ SocialAccount.id == SocialAccountToken.social_account_id,
81
+ )
82
+ .where(
83
+ SocialAccountToken.social_account_id == account_id,
84
+ SocialAccount.workspace_id == workspace_id,
85
+ )
86
+ )
87
+ if record:
88
+ record.revoked_at = datetime.now(timezone.utc)
89
+ record.encrypted_payload = None
90
+ await session.commit()