355 lines
11 KiB
TypeScript
355 lines
11 KiB
TypeScript
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<string | null> {
|
||
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<ActiveSession> {
|
||
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<boolean> {
|
||
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 }
|
||
);
|
||
}
|