Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions app/(app)/projects/[id]/social/setup/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string | undefined> = {
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;
Expand Down Expand Up @@ -131,6 +142,25 @@ export default async function SocialSetupPage({
Last post {new Date(a.last_post_at).toLocaleString()}
</p>
)}
{a.status !== "active" && a.platform !== "bluesky" && (
<p className="mt-1 text-xs text-[var(--color-warn)]">
⚠ Session expired.{" "}
{PLATFORM_LOGIN_URLS[a.platform] && (
<>
<a
href={PLATFORM_LOGIN_URLS[a.platform]!}
target="_blank"
rel="noreferrer"
className="underline"
>
Log in to {a.platform} ↗
</a>
, then re-export cookies with Cookie-Editor and paste
them in the {a.platform} section below to reconnect.
</>
)}
</p>
)}
{a.platform === "bluesky" && a.enc_app_password && (
<AppPasswordReveal accountId={a.id} />
)}
Expand Down
15 changes: 15 additions & 0 deletions app/(marketing)/recent/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
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;
}
10 changes: 9 additions & 1 deletion lib/sp/browserPost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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({
Expand Down
44 changes: 42 additions & 2 deletions lib/sp/platforms/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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<void> {
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;
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();

Expand Down
Loading