import config from "@/lib/config"; import { EOL } from "node:os"; type SuccessPosted = { ok: true; data: { status: "SUCCESS"; id: T; source: "api"; deduped: boolean; post: { id: T; content: string; zone: "normal" | "free"; created_at: string; }; }; } const getRealLength = (str: string): number => { const segmenter = new Intl.Segmenter("ja", { granularity: "grapheme" }); return [...segmenter.segment(str)].length; }; const realSlice = (str: string, limit: number): string => { const segmenter = new Intl.Segmenter("ja", { granularity: "grapheme" }); const segments = [...segmenter.segment(str)]; return segments.slice(0, limit).map(s => s.segment).join(""); }; export const createPost = async (data: { text: string; zone?: "normal" | "free"; replyTarget?: "all" | "followers" | "mutual" | "mentioned" | "none"; replyid?: string; images?: [Blob, string][]; }, title: string) => { const excessedMessage = "**👉返信に続きがあります。**"; const excessedMessageLength = getRealLength(excessedMessage); const eolLength = getRealLength(EOL); let lines = data.text.split(EOL); let firstUniqid = ""; let count = 0; const results: SuccessPosted[] = []; while (lines.length > 0) { count++; let currentText = ""; const maxLength = config.post.maxLength; const limit = maxLength - (excessedMessageLength + eolLength * 2); while (lines.length > 0) { const nextLine = lines[0]; if (nextLine === undefined) break; const nextLineLength = getRealLength(nextLine); if (nextLineLength > limit && currentText === "") { const targetLine = lines.shift() || ""; currentText = realSlice(targetLine, limit); lines.unshift(realSlice(targetLine, limit * -1)); break; } const potentialText = currentText ? currentText + EOL + nextLine : nextLine; if (getRealLength(potentialText) <= limit) { currentText = potentialText; lines.shift(); } else { break; } } currentText = currentText.trimEnd(); if (lines.length > 0) { currentText += EOL.repeat(2) + excessedMessage; } let postedUniqid = ""; let success = false; for (let attempt = 1; attempt <= config.post.maxRetries; attempt++) { try { const replyid = data.replyid === undefined && firstUniqid !== "" ? firstUniqid : data.replyid; const formData = new FormData(); formData.append("content", currentText); formData.append("zone", data.zone ?? "normal"); formData.append("reply_restriction", data.replyTarget ?? "all"); if (replyid) { formData.append("reply_to_id", replyid); } if (data.images) { data.images.forEach(img => formData.append("images", img[0], img[1])); } const req = await fetch(`https://collapse.jp/api/v3/bot/posts`, { method: "POST", headers: { "Authorization": `Bearer ${config.mtweet.token}`, "X-Idempotency-Key": String(process.hrtime.bigint()), }, body: formData, }); const res = await req.json(); if (res.ok) { success = true; postedUniqid = res.data.id; results.push(res); break; } console.warn(`${title}の投稿に失敗しました (試行 ${attempt}/${config.post.maxRetries}):`, `${res.error.message}(${res.error.code})`); if (attempt < config.post.maxRetries) { const interval = res.error.details?.retry_after ? res.error.details.retry_after * 1000 : config.post.retryInterval; console.log(`${interval}ms待機します...`); await new Promise(resolve => setTimeout(resolve, interval)); } } catch (err) { console.error(err); } } if (!success) { console.error(`${title}の全試行が失敗したため、処理を中断します。`); break; } if (firstUniqid === "") firstUniqid = postedUniqid; console.log(`${title}を投稿(${count}):`, postedUniqid); while (lines.length > 0 && lines[0]?.trim() === "") { lines.shift(); } } return results; }