import { prisma } from "@/lib/db"; import { getAppBaseUrl } from "./config"; async function safeJson(res: Response): Promise { try { return (await res.json()) as T; } catch { throw new Error(`Threads API 回應格式錯誤(HTTP ${res.status})`); } } const OAUTH_SCOPES = [ "threads_basic", "threads_content_publish", "threads_read_replies", "threads_manage_replies", "threads_manage_insights", "threads_keyword_search", ]; export function buildThreadsOAuthUrl( request: Request, appId: string, state?: string, appUrlOverride?: string | null ): string { const clientId = appId.trim(); if (!clientId || !/^\d+$/.test(clientId)) { throw new Error( "Threads App ID 格式不正確。請填 Meta 後台 Basic 裡的「Threads App ID」(純數字),不是上方的一般 App ID。" ); } const redirectUri = `${getAppBaseUrl(request, appUrlOverride)}/api/threads/oauth/callback`; const url = new URL("https://threads.net/oauth/authorize"); url.searchParams.set("client_id", clientId); url.searchParams.set("redirect_uri", redirectUri); url.searchParams.set("scope", OAUTH_SCOPES.join(",")); url.searchParams.set("response_type", "code"); url.searchParams.set("state", state?.trim() || "threadtools"); return url.toString(); } export async function exchangeCodeForToken(input: { appId: string; appSecret: string; code: string; redirectUri: string; }) { const body = new URLSearchParams({ client_id: input.appId, client_secret: input.appSecret, grant_type: "authorization_code", redirect_uri: input.redirectUri, code: input.code.replace(/#_$/, ""), }); const res = await fetch("https://graph.threads.net/oauth/access_token", { method: "POST", body, }); const json = await safeJson<{ access_token?: string; user_id?: number | string; error_message?: string; }>(res); if (!res.ok || !json.access_token || !json.user_id) { throw new Error(json.error_message ?? "無法換取 Threads access token"); } return { accessToken: json.access_token, userId: String(json.user_id), }; } export async function exchangeForLongLivedToken(appSecret: string, shortLivedToken: string) { const url = new URL("https://graph.threads.net/access_token"); url.searchParams.set("grant_type", "th_exchange_token"); url.searchParams.set("client_secret", appSecret); url.searchParams.set("access_token", shortLivedToken); const res = await fetch(url.toString()); const json = await safeJson<{ access_token?: string; expires_in?: number; error_message?: string; }>(res); if (!res.ok || !json.access_token) { throw new Error(json.error_message ?? "無法換取長效 token"); } return { accessToken: json.access_token, expiresIn: json.expires_in, }; } /** 綁定後抓取該帳號的 Threads 公開資訊(目前只取 username),用來自動命名帳號 */ export async function fetchThreadsProfile( accessToken: string ): Promise<{ username?: string }> { try { const url = new URL("https://graph.threads.net/v1.0/me"); url.searchParams.set("fields", "username"); url.searchParams.set("access_token", accessToken); const res = await fetch(url.toString()); if (!res.ok) return {}; const json = (await res.json()) as { username?: string }; return { username: json.username }; } catch { return {}; } } export async function saveThreadsAuthForAccount( accountId: string, data: { accessToken: string; userId: string; expiresIn?: number; } ) { const expiresAt = data.expiresIn ? new Date(Date.now() + data.expiresIn * 1000) : null; await prisma.account.update({ where: { id: accountId }, data: { threadsAccessToken: data.accessToken, threadsUserId: data.userId, threadsTokenExpiresAt: expiresAt, }, }); } export async function clearThreadsAuthForAccount(accountId: string) { await prisma.account.update({ where: { id: accountId }, data: { threadsAccessToken: null, threadsUserId: null, threadsTokenExpiresAt: null, }, }); } export async function refreshLongLivedToken(accessToken: string) { const url = new URL("https://graph.threads.net/refresh_access_token"); url.searchParams.set("grant_type", "th_refresh_token"); url.searchParams.set("access_token", accessToken); const res = await fetch(url.toString()); const json = await safeJson<{ access_token?: string; expires_in?: number; error_message?: string; }>(res); if (!res.ok || !json.access_token) { throw new Error(json.error_message ?? "無法更新 Threads token"); } return { accessToken: json.access_token, expiresIn: json.expires_in, }; } export async function ensureValidAccessTokenForAccount(account: { id: string; threadsAccessToken: string | null; threadsUserId: string | null; threadsTokenExpiresAt: Date | null; }): Promise { const token = account.threadsAccessToken?.trim(); if (!token || !account.threadsUserId?.trim()) return null; const expiresAt = account.threadsTokenExpiresAt; const needsRefresh = !expiresAt || expiresAt.getTime() - Date.now() < 7 * 24 * 60 * 60 * 1000; if (!needsRefresh) return token; try { const refreshed = await refreshLongLivedToken(token); await saveThreadsAuthForAccount(account.id, { accessToken: refreshed.accessToken, userId: account.threadsUserId.trim(), expiresIn: refreshed.expiresIn, }); return refreshed.accessToken; } catch { return token; } }