diff --git a/.conductor/settings.local.toml b/.conductor/settings.local.toml new file mode 100644 index 00000000..331e1c08 --- /dev/null +++ b/.conductor/settings.local.toml @@ -0,0 +1,16 @@ +"$schema" = "https://conductor.build/schemas/settings.repo.schema.json" + +[scripts] +setup = "pip install -r requirements.txt && python manage.py migrate" +auto_run_after_setup = false + +[scripts.run.dev] +command = "python manage.py runserver $CONDUCTOR_PORT" +default = true + +[[preview_urls]] +name = "BrightBean Studio" +url = "http://localhost:$CONDUCTOR_PORT" + +[prompts] +general = "Read README.md and Makefile before starting. Check the agency-os token budget at ~/Projects/agency-os/state/agent_token_budgets.json before spawning subagents or running heavy LLM work. Prefer free/local LLM for quick triage and mechanical fixes. Reserve premier design models for actual design work and only when token budget allows. Nothing gets sent, posted, deployed-to-client, or invoiced without Eric's explicit sign-off. Never push directly to main or master - use a feature branch and PR." diff --git a/brightbean-social-readiness.md b/brightbean-social-readiness.md new file mode 100644 index 00000000..208aa247 --- /dev/null +++ b/brightbean-social-readiness.md @@ -0,0 +1,71 @@ +# BrightBean Social Readiness — Plants and Chairs + +Re-derived 2026-07-18 (kimi) after the original file was lost. Sources: ingestion script +`scripts/ingest_assets.py` (branch `fix-brightbean-llms-setup`), live filesystem audit, and a +full Airtable DAM scan (base `appjU8WwLSSaSNJxC`, table `🎞 Digital assets`). + +## 1. Verified source directories (live audit 2026-07-18) + +| Directory | Media files | +|---|---| +| `agency-os/output/social-ready-2026-06-25/feed` | 26 | +| `agency-os/output/content-pool-2026-06-23` | 255 (incl. `reels/` with the 2 mp4s) | +| `~/Downloads/pac_store-ites_3d/item_1` | 142 | +| `agency-os/reels` | 0 (empty; reels live in content-pool) | + +All 30 files mapped in `ASSET_METADATA` are present on disk (30/30). The two mp4s were +found at `content-pool-2026-06-23/reels/`, not `agency-os/reels/`. + +## 2. DAM audit (Airtable, 2026-07-18) + +- 2,120 total records. 936 have valid local paths, 114 point to missing files, 1,070 have + no Local Path at all. +- Valid by project: UNIVERZE 358, PNC-MERCH 198, PC-1985 60, PC-WFF 56, PC-007B 31, + PC-FLEA 30. +- Missing by project: PNC 53, PC-1985 15, SJB-EXPLORER 12, PC-SEAVITA 9. +- Full dump: `/tmp/dam_audit.json` (regenerate via the Airtable API when needed). + +## 3. Ingestion candidate set (30 files, all verified present) + +- Hero photos: `IMG_1761.png` (Plants Over Chairs hero), `IMG_1634.png` (Plants and Jeeps + hero), both in `pac_store-ites_3d/item_1`. +- Reels: `new-01-screenprint-process.mp4` (screen-print process), + `new-03-beach-lifestyle.mp4` (studio/place), both in `content-pool-2026-06-23/reels/`. +- Social-ready feed set (26 PNGs in `social-ready-2026-06-25/feed`): s01-s10 heroes and + grids, v2 series (stone, payasotee, vanlife, dotcap, barlogo), v3 cards (petstore, + productos, mialma, scatter pink), post-2..5 (flowers, vanlife royal, payaso tote, + beanie), cap-dir3 streetwear, cap-dir4 AR. + +Runbook: `python scripts/ingest_assets.py --dry-run` first, then real run. The script +dedupes by SHA-256, uploads to R2 via Django storage, and creates MediaAsset rows. +**Blocker before any run: rotate the exposed R2 credentials, then set the new pair as Fly +secrets (`S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`).** + +## 4. Eight-post draft calendar (drafts only; nothing posts without Eric) + +Cadence Tue/Fri, starting after accounts connect. All copy is placeholder direction for +the brand check, not final copy. + +| # | Date | Asset | Format | Direction | +|---|------|-------|--------|-----------| +| 1 | Tue 2026-07-21 | `s01-iconic-hero.png` | IG feed | Iconic cap hero. Re-introduce the drop. | +| 2 | Fri 2026-07-24 | `new-01-screenprint-process.mp4` | Reel | Process video: screen-print behind the scenes. | +| 3 | Tue 2026-07-28 | `v2-01-stone-hero.png` | IG feed | Stone colorway hero. | +| 4 | Fri 2026-07-31 | `s06-iconic-grid2.png` | IG feed | Grid/detail post. | +| 5 | Tue 2026-08-04 | `v2-02-payasotee.png` | IG feed | Payaso tee graphic feature. | +| 6 | Fri 2026-08-07 | `new-03-beach-lifestyle.mp4` | Reel | Lifestyle/place reel. | +| 7 | Tue 2026-08-11 | `cap-dir3-streetwear-post.png` | IG feed | Streetwear styling post. | +| 8 | Fri 2026-08-14 | `s08-scatter-sky.png` | IG feed | Scatter print, soft closer for the run. | + +Crosspost: once Bluesky/Mastodon are connected, mirror 1, 3, 5, 7 as stills. Captions go +through the QA/brand gate and Eric approval before scheduling. + +## 5. Account connection status (2026-07-18) + +- Instagram: blocked at Meta SMS 2FA in the token-generation step (PAC-337, needs Eric). + App credentials (`PLATFORM_INSTAGRAM_APP_ID/SECRET`) are set as Fly secrets. +- Bluesky: no account yet. Needs studio handle + App Password (agent connects it). +- Mastodon: no instance/account yet. +- LinkedIn, Pinterest, TikTok, Google/YouTube: app credentials exist in + `brightbean-studio/.env`; each still needs its OAuth grant flow. +- R2/S3: Fly secrets present but the pair is exposed; rotate before uploads. diff --git a/brightbean.db b/brightbean.db new file mode 100644 index 00000000..96ea1ab2 Binary files /dev/null and b/brightbean.db differ diff --git a/config/settings/base.py b/config/settings/base.py index 0e18823c..1cdd961c 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -358,8 +358,13 @@ # Platform credentials env vars (cloud version) _META_CREDENTIALS = { - "app_id": env("PLATFORM_FACEBOOK_APP_ID", default=""), - "app_secret": env("PLATFORM_FACEBOOK_APP_SECRET", default=""), + # The existing PnC Meta app is configured through the Instagram Business + # use case. Keep the dedicated Instagram variables as a compatibility + # fallback so an existing deployment can use the same app without a + # destructive secret rotation. + "app_id": env("PLATFORM_FACEBOOK_APP_ID", default="") or env("PLATFORM_INSTAGRAM_APP_ID", default=""), + "app_secret": env("PLATFORM_FACEBOOK_APP_SECRET", default="") + or env("PLATFORM_INSTAGRAM_APP_SECRET", default=""), } _GOOGLE_CREDENTIALS = { "client_id": env("PLATFORM_GOOGLE_CLIENT_ID", default=""), diff --git a/fly.toml b/fly.toml new file mode 100644 index 00000000..ee2838f6 --- /dev/null +++ b/fly.toml @@ -0,0 +1,24 @@ +app = 'brightbean-pnc' +primary_region = 'lax' + +[build] + +[env] + PORT = '8000' + +[http_service] + internal_port = 8000 + force_https = true + auto_stop_machines = true + auto_start_machines = true + min_machines_running = 0 + processes = ['app'] + +[[vm]] + memory = '1gb' + cpu_kind = 'shared' + cpus = 1 + processes = ['app'] + +[deploy] + release_command = 'python manage.py migrate --noinput' diff --git a/providers/instagram.py b/providers/instagram.py index 94c9dfdb..edf61d59 100644 --- a/providers/instagram.py +++ b/providers/instagram.py @@ -106,11 +106,14 @@ def supported_media_types(self) -> list[MediaType]: @property def required_scopes(self) -> list[str]: + # Meta renamed Instagram permissions with an "instagram_business_" + # prefix; the old names (instagram_basic, instagram_content_publish, + # etc.) are now rejected as invalid scopes. return [ - "instagram_basic", - "instagram_content_publish", - "instagram_manage_comments", - "instagram_manage_insights", + "instagram_business_basic", + "instagram_business_content_publish", + "instagram_business_manage_comments", + "instagram_business_manage_insights", "pages_show_list", "pages_read_engagement", ] diff --git a/scripts/connect_social_accounts.py b/scripts/connect_social_accounts.py new file mode 100644 index 00000000..599df7b3 --- /dev/null +++ b/scripts/connect_social_accounts.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""Connect social accounts to BrightBean from stored credentials. + +Wires Bluesky (handle + app password) and Mastodon (instance + user token) +into the BrightBean production workspace, replicating the web connect flows +(apps/social_accounts/views.py) for headless use. + +Reads credentials from environment (set them as Fly secrets or pass inline): + BLUESKY_HANDLE, BLUESKY_APP_PASSWORD + MASTODON_INSTANCE_URL, MASTODON_ACCESS_TOKEN + +Usage (production): + fly ssh console --app brightbean-pnc -C "python scripts/connect_social_accounts.py" + +Does NOT post anything. Connecting only; publishing stays behind the Eric gate. +""" + +import os +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production") + +import django + +django.setup() + +from django.utils import timezone # noqa: E402 +from apps.social_accounts.models import SocialAccount # noqa: E402 +from apps.workspaces.models import Workspace # noqa: E402 +from apps.calendar.services import create_default_queue_and_slots # noqa: E402 +from apps.credentials.models import PlatformCredential # noqa: E402 +from providers.bluesky import BlueskyProvider # noqa: E402 + + +def _get_workspace(): + ws = Workspace.objects.order_by("created_at").first() + if ws is None: + raise RuntimeError("No workspace found in this BrightBean instance") + return ws + + +def _upsert(workspace, platform, *, platform_id, name, handle, avatar, followers, + access_token, refresh_token="", instance_url=""): + account, created = SocialAccount.objects.update_or_create( + workspace_id=workspace.id, + platform=platform, + account_platform_id=platform_id, + defaults={ + "account_name": name, + "account_handle": handle or "", + "avatar_url": avatar or "", + "follower_count": followers or 0, + "oauth_access_token": access_token, + "oauth_refresh_token": refresh_token or "", + "instance_url": instance_url or "", + "connection_status": SocialAccount.ConnectionStatus.CONNECTED, + "last_error": "", + "analytics_needs_reconnect": False, + }, + ) + if created: + create_default_queue_and_slots(account) + return account, created + + +def connect_bluesky(workspace): + handle = os.environ.get("BLUESKY_HANDLE", "").lstrip("@") + app_password = os.environ.get("BLUESKY_APP_PASSWORD", "") + if not handle or not app_password: + print("Bluesky: SKIP (BLUESKY_HANDLE / BLUESKY_APP_PASSWORD not set)") + return + provider = BlueskyProvider() + tokens = provider.create_session(handle, app_password) + profile = provider.get_profile(tokens.access_token) + account, created = _upsert( + workspace, PlatformCredential.Platform.BLUESKY, + platform_id=profile.platform_id, name=profile.name, handle=profile.handle, + avatar=profile.avatar_url, followers=profile.follower_count, + access_token=tokens.access_token, refresh_token=tokens.refresh_token, + instance_url=provider.pds_url, + ) + print(f"Bluesky: {'created' if created else 'updated'} -> {profile.handle}") + + +def connect_mastodon(workspace): + import requests + + instance = os.environ.get("MASTODON_INSTANCE_URL", "").rstrip("/") + token = os.environ.get("MASTODON_ACCESS_TOKEN", "") + if not instance or not token: + print("Mastodon: SKIP (MASTODON_INSTANCE_URL / MASTODON_ACCESS_TOKEN not set)") + return + resp = requests.get( + f"{instance}/api/v1/accounts/verify_credentials", + headers={"Authorization": f"Bearer {token}"}, timeout=20, + ) + if resp.status_code != 200: + print(f"Mastodon: token check failed ({resp.status_code}): {resp.text[:200]}") + print("Mastodon: if 403 'confirmed e-mail', click the confirmation link in the inbox first") + return + me = resp.json() + account, created = _upsert( + workspace, PlatformCredential.Platform.MASTODON, + platform_id=str(me["id"]), name=me.get("display_name") or me.get("acct", ""), + handle=me.get("acct", ""), avatar=me.get("avatar", ""), + followers=me.get("followers_count", 0), + access_token=token, instance_url=instance, + ) + print(f"Mastodon: {'created' if created else 'updated'} -> {me.get('acct')}") + + +def main(): + workspace = _get_workspace() + print(f"Workspace: {workspace.name} ({workspace.id})") + connect_bluesky(workspace) + connect_mastodon(workspace) + total = SocialAccount.objects.filter(workspace=workspace).count() + print(f"Connected social accounts in workspace: {total}") + + +if __name__ == "__main__": + main()