diff --git a/app/(app)/projects/[id]/social/setup/page.tsx b/app/(app)/projects/[id]/social/setup/page.tsx index 59ec7b06..3c8f8c14 100644 --- a/app/(app)/projects/[id]/social/setup/page.tsx +++ b/app/(app)/projects/[id]/social/setup/page.tsx @@ -12,6 +12,17 @@ import { AppPasswordReveal } from "./app-password-reveal"; export const metadata = { title: "Social · Connect accounts" }; +// Where to send the user to log in when a cookie session has expired, so they +// can re-export fresh cookies. Mastodon is instance-specific, so omitted. +const PLATFORM_LOGIN_URLS: Record = { + reddit: "https://www.reddit.com/login", + facebook_page: "https://www.facebook.com/login", + threads: "https://www.threads.net/login", + instagram: "https://www.instagram.com/accounts/login/", + x: "https://x.com/login", + linkedin: "https://www.linkedin.com/login", +}; + type SetupSearchParams = Promise<{ connected?: string; error?: string; @@ -131,6 +142,25 @@ export default async function SocialSetupPage({ Last post {new Date(a.last_post_at).toLocaleString()}

)} + {a.status !== "active" && a.platform !== "bluesky" && ( +

+ ⚠ Session expired.{" "} + {PLATFORM_LOGIN_URLS[a.platform] && ( + <> + + Log in to {a.platform} ↗ + + , then re-export cookies with Cookie-Editor and paste + them in the {a.platform} section below to reconnect. + + )} +

+ )} {a.platform === "bluesky" && a.enc_app_password && ( )} diff --git a/app/(marketing)/recent/page.tsx b/app/(marketing)/recent/page.tsx index 1b81db5b..3e9b0790 100644 --- a/app/(marketing)/recent/page.tsx +++ b/app/(marketing)/recent/page.tsx @@ -363,5 +363,20 @@ async function fetchOutreachHistory( list.push(item); byAudit.set(row.audit_id, list); } + + // Collapse to one row per channel+provider (the latest attempt), so repeated + // sends/retries update in place instead of piling up. Rows are already + // ordered newest-first, so the first occurrence per key is the current one. + for (const [auditId, list] of byAudit) { + const seen = new Set(); + const deduped: OutreachHistoryItem[] = []; + for (const item of list) { + const key = `${item.channel}:${item.provider}`; + if (seen.has(key)) continue; + seen.add(key); + deduped.push(item); + } + byAudit.set(auditId, deduped); + } return byAudit; } diff --git a/lib/sp/browserPost.ts b/lib/sp/browserPost.ts index 5c45bc50..2e3a1ca2 100644 --- a/lib/sp/browserPost.ts +++ b/lib/sp/browserPost.ts @@ -13,6 +13,7 @@ import type { SupabaseClient } from "@supabase/supabase-js"; import type OpenAI from "openai"; import { decryptSecret } from "@/lib/sp/vault"; import { + LOGIN_WALL_PREFIX, parseCookies, redditBrowserPost, facebookBrowserPost, @@ -198,9 +199,16 @@ async function fail( .select("consecutive_failures") .eq("id", accountId) .maybeSingle(); + // A login-wall failure means the stored cookies are dead — flag the account + // token_expired so the UI prompts a reconnect (re-export cookies) and stops + // posting to it until then. + const sessionExpired = message.startsWith(LOGIN_WALL_PREFIX); await supabase .from("sp_account") - .update({ consecutive_failures: ((acct?.consecutive_failures ?? 0) as number) + 1 }) + .update({ + consecutive_failures: ((acct?.consecutive_failures ?? 0) as number) + 1, + ...(sessionExpired ? { status: "token_expired" } : {}), + }) .eq("id", accountId); } await supabase.from("sp_publish_attempt").insert({ diff --git a/lib/sp/platforms/browser.ts b/lib/sp/platforms/browser.ts index c732b376..6b5c2ef0 100644 --- a/lib/sp/platforms/browser.ts +++ b/lib/sp/platforms/browser.ts @@ -14,7 +14,12 @@ // scraping flows. Add playwright-extra + stealth plugin if detection becomes // a problem. -import { chromium, type Browser, type BrowserContext } from "playwright"; +import { + chromium, + type Browser, + type BrowserContext, + type Page, +} from "playwright"; import { browserSemaphore } from "@/lib/sp/browserSemaphore"; import { handleCodeChallenge, type CodeWaiter } from "@/lib/sp/verificationChallenge"; @@ -24,6 +29,31 @@ function codePrompt(platform: string): string { return `${platform} is asking for a verification code. Enter the code it just sent (email / SMS / authenticator) to finish posting.`; } +// Prefix on errors thrown when the cookie session is dead and the platform is +// showing a login wall. browserPost.ts recognizes it and flags the account as +// token_expired so the user is prompted to reconnect (re-export cookies). +export const LOGIN_WALL_PREFIX = "SESSION_EXPIRED"; + +// After navigating with cookies (and clearing any code challenge), bail out +// with a clear, recognizable error if we landed on a login page instead of the +// app — otherwise the composer selectors just time out opaquely. Detects a +// login URL or a visible password field. +async function assertLoggedIn(page: Page, platform: string): Promise { + const url = page.url().toLowerCase(); + const onLogin = + /\/login|\/signin|\/sign_in|\/uas\/login|accounts\/login|\/auth\/login/.test(url); + const passwordVisible = await page + .locator('input[type="password"]') + .first() + .isVisible({ timeout: 1500 }) + .catch(() => false); + if (onLogin || passwordVisible) { + throw new Error( + `${LOGIN_WALL_PREFIX}: ${platform} session expired — reconnect the account with fresh cookies.`, + ); + } +} + export type BrowserCookie = { name: string; value: string; @@ -133,6 +163,7 @@ export async function redditBrowserPost(args: { waitUntil: "domcontentloaded", }); if (waitForCode) await handleCodeChallenge(page, waitForCode, codePrompt("Reddit")); + await assertLoggedIn(page, "Reddit"); // Select "Text" tab const textTab = page.getByRole("tab", { name: /text/i }); @@ -183,6 +214,7 @@ export async function facebookBrowserPost(args: { waitUntil: "domcontentloaded", }); if (waitForCode) await handleCodeChallenge(page, waitForCode, codePrompt("Facebook")); + await assertLoggedIn(page, "Facebook"); // Click the "Write something..." composer const composer = page.getByPlaceholder(/write something/i) @@ -246,6 +278,7 @@ export async function threadsBrowserPost(args: { const page = await ctx.newPage(); await page.goto("https://www.threads.net", { waitUntil: "domcontentloaded" }); if (waitForCode) await handleCodeChallenge(page, waitForCode, codePrompt("Threads")); + await assertLoggedIn(page, "Threads"); // New Thread button const newThreadBtn = page @@ -308,6 +341,7 @@ export async function instagramBrowserPost(args: { const page = await ctx.newPage(); await page.goto("https://www.instagram.com", { waitUntil: "domcontentloaded" }); if (waitForCode) await handleCodeChallenge(page, waitForCode, codePrompt("Instagram")); + await assertLoggedIn(page, "Instagram"); // Download image to temp file const imgRes = await fetch(imageUrl); @@ -377,6 +411,7 @@ export async function xBrowserPost(args: { const page = await ctx.newPage(); await page.goto("https://x.com/home", { waitUntil: "domcontentloaded" }); if (waitForCode) await handleCodeChallenge(page, waitForCode, codePrompt("X")); + await assertLoggedIn(page, "X"); // Click the compose box const compose = page @@ -435,6 +470,7 @@ export async function linkedinBrowserPost(args: { const page = await ctx.newPage(); await page.goto("https://www.linkedin.com/feed/", { waitUntil: "domcontentloaded" }); if (waitForCode) await handleCodeChallenge(page, waitForCode, codePrompt("LinkedIn")); + await assertLoggedIn(page, "LinkedIn"); // Start a post const startPost = page @@ -504,6 +540,7 @@ export async function mastodonBrowserPost(args: { const page = await ctx.newPage(); await page.goto(base, { waitUntil: "domcontentloaded" }); if (waitForCode) await handleCodeChallenge(page, waitForCode, codePrompt("Mastodon")); + await assertLoggedIn(page, "Mastodon"); // Mastodon web app — compose textarea. Match the compose box specifically: // a bare getByRole("textbox") also matched the "Search or paste URL" input @@ -532,9 +569,12 @@ export async function mastodonBrowserPost(args: { } } - // Toot / Publish button + // Toot / Publish button. Mastodon labels it "Publish!" / "Toot!"; also + // accept the compose form's submit button as a fallback. await page .getByRole("button", { name: /publish|toot/i }) + .or(page.locator("button.compose-form__submit")) + .or(page.locator(".compose-form button[type='submit']")) .last() .click();