[object Object]

← back to Marketing Command Center

Channels: add Bluesky (live, app-password) + Threads (review-gated) publishers; board shows them as real columns

8e94008156bbdd6b5f760ad69d4ed3ecba224bbe · 2026-06-15 12:23:22 -0700 · SteveStudio2

Files touched

Diff

commit 8e94008156bbdd6b5f760ad69d4ed3ecba224bbe
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Mon Jun 15 12:23:22 2026 -0700

    Channels: add Bluesky (live, app-password) + Threads (review-gated) publishers; board shows them as real columns
---
 modules/board/index.js    |   4 +-
 modules/channels/index.js | 105 ++++++++++++++++++++++++++++++++++++++++++++--
 2 files changed, 103 insertions(+), 6 deletions(-)

diff --git a/modules/board/index.js b/modules/board/index.js
index 3ac6b3e..c1f8e64 100644
--- a/modules/board/index.js
+++ b/modules/board/index.js
@@ -20,8 +20,8 @@ module.exports = {
         { id: 'tiktok',    label: 'TikTok',    icon: '🎵', kind: 'social' },
         { id: 'youtube',   label: 'YouTube',   icon: '▶️', kind: 'social' },
         { id: 'linkedin',  label: 'LinkedIn',  icon: '💼', kind: 'linkedin' },
-        { id: 'threads',   label: 'Threads',   icon: '🧵', kind: 'social', soon: true },
-        { id: 'bluesky',   label: 'Bluesky',   icon: '🦋', kind: 'social', soon: true },
+        { id: 'bluesky',   label: 'Bluesky',   icon: '🦋', kind: 'social' },
+        { id: 'threads',   label: 'Threads',   icon: '🧵', kind: 'social' },
         { id: 'email',     label: 'Email · Constant Contact', icon: '✉️', kind: 'email' },
       ],
     }));
diff --git a/modules/channels/index.js b/modules/channels/index.js
index 1838634..8f89de4 100644
--- a/modules/channels/index.js
+++ b/modules/channels/index.js
@@ -68,6 +68,8 @@ const ALLOWED_KEYS = new Set([
   'META_APP_ID', 'META_APP_SECRET', 'FB_PAGE_IDS', 'FB_PAGE_ACCESS_TOKEN', 'IG_BUSINESS_ACCOUNT_ID', 'IG_ACCESS_TOKEN',
   'TIKTOK_CLIENT_KEY', 'TIKTOK_CLIENT_SECRET', 'TIKTOK_ACCESS_TOKEN',
   'YOUTUBE_CLIENT_ID', 'YOUTUBE_CLIENT_SECRET', 'GOOGLE_CLIENT_ID', 'GOOGLE_CLIENT_SECRET', 'YOUTUBE_ACCESS_TOKEN', 'YOUTUBE_REFRESH_TOKEN',
+  'BLUESKY_HANDLE', 'BLUESKY_APP_PASSWORD',
+  'THREADS_ACCESS_TOKEN', 'THREADS_USER_ID',
   'OAUTH_REDIRECT_BASE',
 ]);
 // Provider cards the front-end renders from. id is the form group only.
@@ -105,6 +107,28 @@ const SETUP_PROVIDERS = [
       { key: 'YOUTUBE_CLIENT_SECRET', label: 'OAuth Client Secret', secret: true, ph: 'GOCSPX-...' },
     ],
   },
+  {
+    id: 'bluesky', title: 'Bluesky', icon: '🦋', noRedirect: true,
+    blurb: 'No app review, no OAuth — just an App Password. In Bluesky: Settings → Privacy and security → App Passwords → Add. Paste your handle + the app password below and it posts live immediately.',
+    portal: 'https://bsky.app/settings/app-passwords', portalLabel: 'Bluesky App Passwords',
+    platforms: ['bluesky'],
+    permissions: 'AT Protocol app-password (full account; no granular scopes)',
+    fields: [
+      { key: 'BLUESKY_HANDLE', label: 'Handle', secret: false, ph: 'designerwallcoverings.bsky.social' },
+      { key: 'BLUESKY_APP_PASSWORD', label: 'App Password', secret: true, ph: 'xxxx-xxxx-xxxx-xxxx' },
+    ],
+  },
+  {
+    id: 'threads', title: 'Threads', icon: '🧵', noRedirect: true,
+    blurb: 'Threads (Meta) Graph API. Needs a Threads user-access token with threads_basic + threads_content_publish (Meta App Review gates content_publish, like FB/IG). Paste the token + your Threads user id to enable live posting.',
+    portal: 'https://developers.facebook.com/docs/threads', portalLabel: 'Threads API docs',
+    platforms: ['threads'],
+    permissions: 'threads_basic · threads_content_publish',
+    fields: [
+      { key: 'THREADS_USER_ID', label: 'Threads User ID', secret: false, ph: 'e.g. 17841400000000000' },
+      { key: 'THREADS_ACCESS_TOKEN', label: 'Access Token', secret: true, ph: 'THQVJ...' },
+    ],
+  },
 ];
 
 function setEnvKeys(updates) {
@@ -214,13 +238,32 @@ function platformStatus() {
       // Connecting works, but uploads aren't built — a "live" post will stage.
       caveat: 'Connecting works, but the video upload pipeline isn’t wired yet — posts stage instead of going live.',
     },
+    bluesky: {
+      label: 'Bluesky', icon: '🦋',
+      // App-password auth: present creds = live. No OAuth, no review.
+      connected: !!(env('BLUESKY_HANDLE') && env('BLUESKY_APP_PASSWORD')),
+      accounts: env('BLUESKY_HANDLE') ? [env('BLUESKY_HANDLE')] : [],
+      needs: 'Bluesky handle + an App Password (Settings → App Passwords). Posts go live immediately — no review.',
+      scopes: ['app-password'],
+    },
+    threads: {
+      label: 'Threads', icon: '🧵',
+      connected: !!(env('THREADS_ACCESS_TOKEN') && env('THREADS_USER_ID')),
+      accounts: env('THREADS_USER_ID') ? [env('THREADS_USER_ID')] : [],
+      needs: 'Threads user id + access token with threads_content_publish (Meta App Review gates publish, like FB/IG).',
+      scopes: ['threads_basic', 'threads_content_publish'],
+      caveat: 'threads_content_publish needs Meta App Review before live posting; until then posts stage.',
+    },
   };
   // Merge in OAuth-flow state: a platform is `connected` if env creds OR a minted
   // token exists; `configured` = app client id/secret present so a Connect button works.
   for (const p of Object.keys(base)) {
-    base[p].configured = cfg(p);
+    // OAuth platforms: configured = app id/secret present (lights the Connect button).
+    // App-password / token-paste platforms (bluesky, threads): configured once their
+    // creds are present — there's no OAuth Connect, creds are saved directly via /config.
+    base[p].configured = cfg(p) || base[p].connected;
     base[p].connected = base[p].connected || tokenConnected(p);
-    base[p].connectUrl = (base[p].configured && !base[p].connected) ? `/api/channels/connect/${p}` : null;
+    base[p].connectUrl = (cfg(p) && !base[p].connected) ? `/api/channels/connect/${p}` : null;
   }
   return base;
 }
@@ -293,7 +336,61 @@ async function postYouTube() {
   // for URL-only v1. Staged with a clear note until the upload pipeline is wired.
   return [{ ok: false, error: 'YouTube upload pipeline not wired (needs resumable binary upload)' }];
 }
-const ADAPTERS = { facebook: postFacebook, instagram: postInstagram, tiktok: postTikTok, youtube: postYouTube };
+async function postBluesky(content) {
+  // AT Protocol: createSession (handle + app password) → optional uploadBlob for the
+  // image → createRecord (app.bsky.feed.post). No OAuth, no review. 300-char limit.
+  const handle = env('BLUESKY_HANDLE'), pw = env('BLUESKY_APP_PASSWORD');
+  if (!handle || !pw) return [{ ok: false, error: 'Bluesky not configured (handle + app password)' }];
+  const PDS = 'https://bsky.social/xrpc';
+  const s = await fetch(`${PDS}/com.atproto.server.createSession`, {
+    method: 'POST', headers: { 'content-type': 'application/json' },
+    body: JSON.stringify({ identifier: handle, password: pw }),
+  });
+  const sj = await s.json().catch(() => ({}));
+  if (!s.ok || !sj.accessJwt) return [{ ok: false, error: sj.message || sj.error || 'Bluesky auth failed' }];
+  const jwt = sj.accessJwt, did = sj.did;
+  let embed;
+  if (content.mediaUrl) {
+    try {
+      const img = await fetch(content.mediaUrl);
+      const buf = Buffer.from(await img.arrayBuffer());
+      const ct = img.headers.get('content-type') || 'image/jpeg';
+      const up = await fetch(`${PDS}/com.atproto.repo.uploadBlob`, {
+        method: 'POST', headers: { 'content-type': ct, authorization: `Bearer ${jwt}` }, body: buf,
+      });
+      const uj = await up.json().catch(() => ({}));
+      if (up.ok && uj.blob) embed = { $type: 'app.bsky.embed.images', images: [{ alt: (content.caption || '').slice(0, 280), image: uj.blob }] };
+    } catch { /* post text-only if image fetch/upload fails */ }
+  }
+  const record = { $type: 'app.bsky.feed.post', text: (content.caption || '').slice(0, 300), createdAt: new Date().toISOString() };
+  if (embed) record.embed = embed;
+  const r = await fetch(`${PDS}/com.atproto.repo.createRecord`, {
+    method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${jwt}` },
+    body: JSON.stringify({ repo: did, collection: 'app.bsky.feed.post', record }),
+  });
+  const rj = await r.json().catch(() => ({}));
+  return [{ ok: r.ok, id: rj.uri, error: r.ok ? null : (rj.message || rj.error || 'post failed') }];
+}
+async function postThreads(content) {
+  // Threads Graph API: create a media container (TEXT or IMAGE) → publish it.
+  // threads_content_publish is App-Review-gated, so this fails cleanly until approved.
+  const token = env('THREADS_ACCESS_TOKEN'), uid = env('THREADS_USER_ID');
+  if (!token || !uid) return [{ ok: false, error: 'Threads not configured (user id + access token)' }];
+  const base = `https://graph.threads.net/v1.0/${uid}`;
+  const cp = new URLSearchParams({ access_token: token });
+  if (content.mediaUrl) { cp.set('media_type', 'IMAGE'); cp.set('image_url', content.mediaUrl); } else { cp.set('media_type', 'TEXT'); }
+  if (content.caption) cp.set('text', content.caption.slice(0, 500));
+  const c = await fetch(`${base}/threads`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: cp });
+  const cj = await c.json().catch(() => ({}));
+  if (!c.ok || !cj.id) return [{ ok: false, error: cj.error?.message || 'Threads container create failed' }];
+  const pub = await fetch(`${base}/threads_publish`, {
+    method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' },
+    body: new URLSearchParams({ creation_id: cj.id, access_token: token }),
+  });
+  const pj = await pub.json().catch(() => ({}));
+  return [{ ok: pub.ok, id: pj.id, error: pub.ok ? null : (pj.error?.message || 'Threads publish failed') }];
+}
+const ADAPTERS = { facebook: postFacebook, instagram: postInstagram, tiktok: postTikTok, youtube: postYouTube, bluesky: postBluesky, threads: postThreads };
 
 module.exports = {
   id: 'channels',
@@ -326,7 +423,7 @@ module.exports = {
       const providers = SETUP_PROVIDERS.map(p => ({
         id: p.id, title: p.title, icon: p.icon, blurb: p.blurb,
         portal: p.portal, portalLabel: p.portalLabel, permissions: p.permissions,
-        redirectUris: p.platforms.map(pl => ({ platform: pl, uri: redirectUri(pl) })),
+        redirectUris: p.noRedirect ? [] : p.platforms.map(pl => ({ platform: pl, uri: redirectUri(pl) })),
         fields: p.fields.map(f => ({ key: f.key, label: f.label, secret: f.secret, ph: f.ph, set: !!env(f.key) })),
         platforms: p.platforms.map(pl => ({
           id: pl, label: st[pl].label, connected: st[pl].connected,

← e76aecd Add Meta App Review prep doc (System-User route + full revie  ·  back to Marketing Command Center  ·  Vendor IG roster: add last-10-posts grid per vendor via Inst 96afa0a →