haixunMaster/lib/threads-api/auth.ts

201 lines
5.5 KiB
TypeScript
Raw Permalink Normal View History

2026-06-21 12:50:31 +00:00
import { prisma } from "@/lib/db";
import { getAppBaseUrl } from "./config";
async function safeJson<T>(res: Response): Promise<T> {
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<string | null> {
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;
}
}