← back to Marketing Command Center
channels: /publish skips non-postable + media-mismatched channels
29d1ff2b04b3cfc51701cd634e5e6ecda1bcb492 · 2026-07-22 07:50:51 -0700 · Steve Abrams
A full-fan-out publish used to FIRE every connected channel and collect
guaranteed errors (FB App-Review #200, LinkedIn 401, TikTok/YouTube image-as-
video). Now /publish consults the postable signal + media-type fit and SKIPS
(stages with a clear reason) any channel that can't actually take this post,
firing only the ones that will land. Result rows carry skipped:true + reason.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
M modules/channels/index.js
Diff
commit 29d1ff2b04b3cfc51701cd634e5e6ecda1bcb492
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Jul 22 07:50:51 2026 -0700
channels: /publish skips non-postable + media-mismatched channels
A full-fan-out publish used to FIRE every connected channel and collect
guaranteed errors (FB App-Review #200, LinkedIn 401, TikTok/YouTube image-as-
video). Now /publish consults the postable signal + media-type fit and SKIPS
(stages with a clear reason) any channel that can't actually take this post,
firing only the ones that will land. Result rows carry skipped:true + reason.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
modules/channels/index.js | 25 +++++++++++++++++++++----
1 file changed, 21 insertions(+), 4 deletions(-)
diff --git a/modules/channels/index.js b/modules/channels/index.js
index 0b1dc63..097c969 100644
--- a/modules/channels/index.js
+++ b/modules/channels/index.js
@@ -870,19 +870,36 @@ module.exports = {
}
const st = platformStatus();
const live = d.confirm === true && d.dryRun === false;
+ const isVideo = !!content.videoUrl;
+ const isImage = !!content.mediaUrl && !content.videoUrl;
+ // Why a channel can't actually post right now (null = clear to fire).
+ // Respects the honest `postable` signal + media-type fit so a full fan-out
+ // silently SKIPS doomed attempts — Facebook (App Review), LinkedIn (dead
+ // token), and video-only TikTok/YouTube given an image — instead of firing
+ // them and collecting guaranteed errors in the outbox.
+ const blockedReason = (ch) => {
+ const s = st[ch] || {};
+ if (!s.connected) return 'not connected';
+ if (!s.postable) return s.postableNote || 'not postable';
+ const mt = s.mediaTypes || ['image', 'text'];
+ if (isVideo && !mt.includes('video')) return 'accepts image/text only — no video';
+ if (isImage && !mt.includes('image')) return `requires video — image not accepted (accepts: ${mt.join('/')})`;
+ return null;
+ };
const outbox = readOutbox();
const results = [];
for (const ch of channels) {
- const connected = st[ch].connected;
- if (live && connected) {
+ const block = blockedReason(ch);
+ if (live && !block) {
const r = await ADAPTERS[ch](content, { pages: selectedPages }).catch(e => [{ ok: false, error: e.message }]);
const ok = r.length > 0 && r.every(x => x.ok);
outbox.push({ id: 'pub-' + ch + '-' + outbox.length, channel: ch, caption: content.caption, mediaUrl: content.mediaUrl, pages: selectedPages.length || undefined, status: ok ? 'posted' : 'failed', detail: r, at: new Date().toISOString() });
results.push({ channel: ch, live: true, ok, detail: r });
} else {
- const reason = !connected ? 'pending-connection' : (d.dryRun !== false ? 'staged (dry-run)' : 'staged (needs confirm)');
+ const reason = block ? `skipped — ${block}`
+ : (d.dryRun !== false ? 'staged (dry-run)' : 'staged (needs confirm)');
outbox.push({ id: 'stg-' + ch + '-' + outbox.length, channel: ch, caption: content.caption, mediaUrl: content.mediaUrl, status: reason, at: new Date().toISOString() });
- results.push({ channel: ch, live: false, staged: true, reason });
+ results.push({ channel: ch, live: false, skipped: !!block, staged: true, reason });
}
}
writeOutbox(outbox);
← 69af9fe channels: add honest 'postable' status — connected != can-ac
·
back to Marketing Command Center
·
chore: v1.7.0 (session close — Threads un-gate, image+SKU ge 0eff96e →