201 lines
5.5 KiB
TypeScript
201 lines
5.5 KiB
TypeScript
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;
|
||
}
|
||
}
|