109 lines
3.1 KiB
TypeScript
109 lines
3.1 KiB
TypeScript
|
|
import { THREADS_MAX_CHARS } from "@/lib/utils";
|
||
|
|
import { threadsGraphGet, threadsGraphPost, ThreadsApiError } from "./client";
|
||
|
|
import type { ThreadsApiCredentials, ThreadsPublishInput, ThreadsPublishResult } from "./types";
|
||
|
|
|
||
|
|
const POLL_INTERVAL_MS = 3000;
|
||
|
|
const POLL_MAX_ATTEMPTS = 20;
|
||
|
|
|
||
|
|
export async function publishViaThreadsApi(
|
||
|
|
credentials: ThreadsApiCredentials,
|
||
|
|
input: ThreadsPublishInput
|
||
|
|
): Promise<ThreadsPublishResult> {
|
||
|
|
if (input.text.length > THREADS_MAX_CHARS) {
|
||
|
|
return { success: false, error: `貼文超過 ${THREADS_MAX_CHARS} 字上限`, method: "api" };
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
const containerParams: Record<string, string> = {
|
||
|
|
access_token: credentials.accessToken,
|
||
|
|
text: input.text,
|
||
|
|
};
|
||
|
|
|
||
|
|
if (input.imageUrl) {
|
||
|
|
containerParams.media_type = "IMAGE";
|
||
|
|
containerParams.image_url = input.imageUrl;
|
||
|
|
} else {
|
||
|
|
containerParams.media_type = "TEXT";
|
||
|
|
}
|
||
|
|
|
||
|
|
if (input.replyToId) {
|
||
|
|
containerParams.reply_to_id = input.replyToId;
|
||
|
|
}
|
||
|
|
|
||
|
|
const container = await threadsGraphPost<{ id: string }>(
|
||
|
|
`/${credentials.userId}/threads`,
|
||
|
|
containerParams
|
||
|
|
);
|
||
|
|
|
||
|
|
if (!container.id) {
|
||
|
|
return { success: false, error: "無法建立發布容器", method: "api" };
|
||
|
|
}
|
||
|
|
|
||
|
|
await waitForContainerReady(container.id, credentials.accessToken);
|
||
|
|
|
||
|
|
const published = await threadsGraphPost<{ id: string }>(
|
||
|
|
`/${credentials.userId}/threads_publish`,
|
||
|
|
{
|
||
|
|
access_token: credentials.accessToken,
|
||
|
|
creation_id: container.id,
|
||
|
|
}
|
||
|
|
);
|
||
|
|
|
||
|
|
if (!published.id) {
|
||
|
|
return { success: false, error: "發布請求未回傳貼文 ID", method: "api" };
|
||
|
|
}
|
||
|
|
|
||
|
|
const permalink = await fetchPermalink(published.id, credentials.accessToken);
|
||
|
|
|
||
|
|
return {
|
||
|
|
success: true,
|
||
|
|
mediaId: published.id,
|
||
|
|
permalink: permalink ?? undefined,
|
||
|
|
method: "api",
|
||
|
|
};
|
||
|
|
} catch (error) {
|
||
|
|
const message =
|
||
|
|
error instanceof ThreadsApiError
|
||
|
|
? error.message
|
||
|
|
: error instanceof Error
|
||
|
|
? error.message
|
||
|
|
: "Threads API 發布失敗";
|
||
|
|
return { success: false, error: message, method: "api" };
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function waitForContainerReady(containerId: string, accessToken: string) {
|
||
|
|
for (let attempt = 0; attempt < POLL_MAX_ATTEMPTS; attempt++) {
|
||
|
|
if (attempt > 0) {
|
||
|
|
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
|
||
|
|
}
|
||
|
|
|
||
|
|
const status = await threadsGraphGet<{ status?: string; error_message?: string }>(
|
||
|
|
`/${containerId}`,
|
||
|
|
{
|
||
|
|
access_token: accessToken,
|
||
|
|
fields: "status,error_message",
|
||
|
|
}
|
||
|
|
);
|
||
|
|
|
||
|
|
if (status.status === "FINISHED") return;
|
||
|
|
if (status.status === "ERROR") {
|
||
|
|
throw new ThreadsApiError(status.error_message ?? "媒體處理失敗");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Meta 建議至少等 30 秒,若狀態查不到仍嘗試發布
|
||
|
|
}
|
||
|
|
|
||
|
|
async function fetchPermalink(mediaId: string, accessToken: string): Promise<string | null> {
|
||
|
|
try {
|
||
|
|
const media = await threadsGraphGet<{ permalink?: string }>(`/${mediaId}`, {
|
||
|
|
access_token: accessToken,
|
||
|
|
fields: "permalink",
|
||
|
|
});
|
||
|
|
return media.permalink ?? null;
|
||
|
|
} catch {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
}
|