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