import { getActiveAccountProfile, setActiveAccountForUser } from "@/lib/account-context"; import { prisma } from "@/lib/db"; import { requireSessionUser } from "@/lib/auth/session"; import { humanDelay } from "@/lib/utils"; import { assessComposeReady, dismissBlockingDialogs } from "./compose"; import { clearBrowserProfile, launchLoginContext, launchSessionContext, withPage, } from "./browser"; import type { BrowserContext } from "playwright"; import { withSessionLock } from "./session-lock"; const LOGIN_URL = "https://www.threads.com/login"; const HOME_URL = "https://www.threads.com/"; export class SessionError extends Error { constructor(message: string) { super(message); this.name = "SessionError"; } } export interface ActiveSession { id: string; username: string | null; storageState: string; } export async function saveAccountSession( accountId: string, storageState: string, options?: { valid?: boolean; username?: string | null } ) { await prisma.account.update({ where: { id: accountId }, data: { storageState, ...(options?.valid !== undefined && { valid: options.valid }), ...(options?.username !== undefined && { username: options.username }), }, }); } async function persistStorageState(accountId: string, storageState: string, valid = true) { await saveAccountSession(accountId, storageState, { valid }); } export async function clearBrowserSession(): Promise<{ cleared: boolean; message: string }> { const account = await getActiveAccount(); const profileResult = await withSessionLock(() => clearBrowserProfile()); if (account) { await saveAccountSession(account.id, "", { valid: false }); return { cleared: true, message: "已清除此帳號的瀏覽器 session", }; } return profileResult; } export async function startLoginFlow(options?: { clearSession?: boolean; }): Promise<{ success: boolean; message: string }> { return withSessionLock(async () => { if (options?.clearSession !== false) { await clearBrowserProfile(); } const context = await launchLoginContext(); const page = context.pages()[0] ?? (await context.newPage()); try { await page.goto(LOGIN_URL, { waitUntil: "domcontentloaded", timeout: 60000 }); try { await page.waitForURL( (url) => url.hostname.includes("threads") && !url.pathname.includes("/login") && !url.hostname.includes("instagram"), { timeout: 300000 } ); } catch { // 可能停在 Instagram 授權頁,繼續等待使用者完成 } const deadline = Date.now() + 300000; let compose = await assessComposeReady(page); while (!compose.ready && Date.now() < deadline) { if (page.url().includes("instagram.com")) { await page.waitForTimeout(2000); } else { await page.goto(HOME_URL, { waitUntil: "domcontentloaded", timeout: 45000 }).catch(() => undefined); await humanDelay(1500, 2500); await dismissBlockingDialogs(page); compose = await assessComposeReady(page); } if (!compose.ready) await page.waitForTimeout(2000); } if (!compose.ready) { return { success: false, message: compose.message ?? "登入逾時。請確認已在瀏覽器完成 Instagram 授權,並看見 Threads 首頁後再試。", }; } await dismissBlockingDialogs(page); compose = await assessComposeReady(page); if (!compose.ready) { return { success: false, message: compose.message ?? "登入未完成,請在瀏覽器內完成 Instagram 授權後再試一次", }; } const storageState = JSON.stringify(await context.storageState()); const username = await extractUsername(page); const user = await requireSessionUser(); const active = await getActiveAccountProfile(); const existing = active ?? (username ? await prisma.account.findFirst({ where: { userId: user.id, username } }) : null); const account = existing ? await prisma.account.update({ where: { id: existing.id }, data: { username, displayName: existing.displayName ?? username ?? undefined, storageState, valid: true, }, }) : await prisma.account.create({ data: { userId: user.id, username, displayName: username ?? "Threads 帳號", storageState, valid: true, }, }); await setActiveAccountForUser(user.id, account.id); await saveAccountSession(account.id, storageState, { valid: true, username }); return { success: true, message: username ? `已登入:@${username}` : "登入成功" }; } finally { await context.close(); } }); } async function extractUsername(page: import("playwright").Page): Promise { try { const profileLink = page.locator('a[href*="/@"]').first(); const href = await profileLink.getAttribute("href", { timeout: 5000 }); if (!href) return null; const match = href.match(/@([^/?]+)/); return match?.[1] ?? null; } catch { return null; } } export async function getActiveAccount() { return getActiveAccountProfile(); } export async function refreshSession(account?: { id: string; username: string | null; storageState: string; }): Promise<{ valid: boolean; username: string | null; storageState: string; message: string; refreshed: boolean; }> { const current = account ?? (await getActiveAccount()); if (!current) { return { valid: false, username: null, storageState: "", message: "尚未登入 Threads", refreshed: false, }; } return withSessionLock(async () => { let context: BrowserContext; try { context = await launchSessionContext({ storageState: current.storageState }); } catch (error) { await saveAccountSession(current.id, current.storageState, { valid: false }); return { valid: false, username: current.username, storageState: current.storageState, refreshed: false, message: error instanceof Error ? error.message : "無法啟動瀏覽器,請稍後再試", }; } try { const page = context.pages()[0] ?? (await context.newPage()); await page.goto(HOME_URL, { waitUntil: "domcontentloaded", timeout: 45000 }); await humanDelay(1200, 2200); await dismissBlockingDialogs(page); const compose = await assessComposeReady(page); const valid = compose.ready; const username = valid ? (await extractUsername(page)) ?? current.username : current.username; const storageState = JSON.stringify(await context.storageState()); await saveAccountSession(current.id, storageState, { valid, username }); return { valid, username, storageState, refreshed: true, message: valid ? "Session 已自動更新,可正常發文" : (compose.message ?? "Session 已失效,請重新登入"), }; } catch (error) { await saveAccountSession(current.id, current.storageState, { valid: false }); const message = error instanceof Error ? error.message : "更新 session 失敗"; return { valid: false, username: current.username, storageState: current.storageState, refreshed: false, message, }; } finally { await context.close(); } }); } export async function ensureActiveSession(): Promise { const account = await getActiveAccount(); if (!account) { throw new SessionError("尚未登入 Threads,請到設定頁登入"); } if (!account.storageState) { throw new SessionError("目前經營帳號尚未登入 Threads,請到設定頁登入此帳號"); } const refreshed = await refreshSession(account); if (!refreshed.valid) { throw new SessionError(refreshed.message || "Threads session 已失效,請到設定頁重新登入"); } return { id: account.id, username: refreshed.username, storageState: refreshed.storageState, }; } export function browserSessionOptions(session: ActiveSession) { return { accountId: session.id, storageState: session.storageState, onSessionPersisted: (storageState: string) => persistStorageState(session.id, storageState), }; } export function accountHasStoredSession(account: { storageState: string } | null): boolean { return !!account?.storageState && account.storageState.length > 2; } /** 讀取 DB 已儲存的同步狀態,不啟動瀏覽器驗證(避免每次進頁面都把「已同步」洗成未同步)。 */ export async function getStoredSessionStatus(): Promise<{ synced: boolean; valid: boolean; username?: string | null; message: string; }> { const account = await getActiveAccount(); if (!account) { return { synced: false, valid: false, message: "尚未選擇經營帳號" }; } if (!accountHasStoredSession(account)) { return { synced: false, valid: false, username: account.username, message: "尚未同步 — 請在 Chrome 登入 threads.com 後按「從 Chrome 同步」", }; } return { synced: true, valid: true, username: account.username, message: account.username ? `已同步 · @${account.username}` : "已同步到 server(session 已儲存)", }; } /** 啟動瀏覽器實際驗證並更新 session(用於發文/海巡前,不作為 UI 狀態來源)。 */ export async function checkSessionValid(): Promise<{ valid: boolean; username?: string | null; message: string; refreshed?: boolean; }> { const account = await getActiveAccount(); if (!account) { return { valid: false, message: "尚未登入 Threads" }; } if (!accountHasStoredSession(account)) { return { valid: false, username: account.username, message: "目前經營帳號尚未同步 Threads session" }; } const result = await refreshSession(account); return { valid: result.valid, username: result.username, message: result.message, refreshed: result.refreshed, }; } export async function probeSession(storageState: string): Promise { return withPage( storageState, async (page) => { await page.goto(HOME_URL, { waitUntil: "domcontentloaded", timeout: 30000 }); await humanDelay(1200, 2000); return !page.url().includes("/login"); }, { persistSession: false } ); }