haixunMaster/lib/threads-api/auth.ts

201 lines
5.5 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
}
}