← 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
M modules/board/index.jsM modules/channels/index.js
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 →