import express from 'express';
import cors from 'cors';
import multer from 'multer';
import dotenv from 'dotenv';
import { BskyAgent } from '@atproto/api';
import { OAuth2Authenticator, Client } from 'mixi2-js';
import sharp from 'sharp';
dotenv.config();
const app = express();
const port = 3000;
app.use(cors());
app.use(express.json());
const upload = multer({ storage: multer.memoryStorage() });
// 🦋 Blueskyへの投稿処理
async function postToBluesky(text, imageBuffer, mimeType) {
console.log("Blueskyへの投稿を開始します...");
try {
const agent = new BskyAgent({ service: '<https://bsky.social>' });
await agent.login({
identifier: process.env.BLUESKY_HANDLE,
password: process.env.BLUESKY_APP_PASSWORD,
});
let embed = undefined;
if (imageBuffer && mimeType) {
const uploadRes = await agent.uploadBlob(imageBuffer, { encoding: mimeType });
embed = {
$type: 'app.bsky.embed.images',
images: [{ image: uploadRes.data.blob, alt: 'Uploaded via local bot' }]
};
}
await agent.post({
text: text || '',
createdAt: new Date().toISOString(),
embed: embed
});
console.log("✅ Blueskyへの投稿に成功しました。");
} catch (error) {
console.error("❌ Blueskyへの投稿に失敗しました:", error.message);
}
}
// 🟠 mixi2への投稿処理 (Step3 スキップ強行突破版)
async function postToMixi2(text, imageBuffer, mimeType) {
console.log("mixi2への投稿を開始します...");
try {
const authenticator = new OAuth2Authenticator({
clientId: process.env.MIXI2_CLIENT_ID,
clientSecret: process.env.MIXI2_CLIENT_SECRET,
tokenUrl: '<https://application-auth.mixi.social/oauth2/token>'
});
const client = new Client({
apiAddress: 'application-api.mixi.social:443',
authenticator: authenticator
});
let mediaIdList = [];
if (imageBuffer && mimeType) {
console.log("mixi2: [Step 1] メディアアップロードの開始...");
const initRes = await client.initiatePostMediaUpload({
mediaType: 'TYPE_IMAGE',
contentType: mimeType,
dataSize: imageBuffer.length
});
const mediaId = initRes.mediaId || initRes.media_id;
const uploadUrl = initRes.uploadUrl || initRes.upload_url;
if (!mediaId || !uploadUrl) throw new Error("ID取得失敗");
console.log(`mixi2: [Step 2] バイナリデータの確実な送信...`);
let accessToken;
if (typeof authenticator.getAccessToken === 'function') {
accessToken = await authenticator.getAccessToken();
} else {
const tokenRes = await fetch('<https://application-auth.mixi.social/oauth2/token>', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `grant_type=client_credentials&client_id=${process.env.MIXI2_CLIENT_ID}&client_secret=${process.env.MIXI2_CLIENT_SECRET}`
});
const tokenData = await tokenRes.json();
accessToken = tokenData.access_token;
}
// Bufferをそのまま送信
const uploadRes = await fetch(uploadUrl, {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/octet-stream',
'Content-Length': imageBuffer.length.toString()
},
body: imageBuffer
});
console.log(`👉 Step 2 応答: HTTP ${uploadRes.status}`);
if (!uploadRes.ok) throw new Error(`Upload API Error: ${uploadRes.status}`);
console.log("mixi2: [Step 3] ライブラリのバグを回避するため、ポーリングをスキップして8秒待機します...");
// ⚠️ 諸悪の根源である状況確認機能を使わず、処理が終わるのを強制的に待つ
await new Promise(resolve => setTimeout(resolve, 8000));
mediaIdList.push(mediaId);
}
console.log("mixi2: [Step 4] ポストの作成...");
await client.createPost({
text: text || '',
mediaIdList: mediaIdList.length > 0 ? mediaIdList : undefined
});
console.log("✅ mixi2への投稿に成功しました。");
} catch (error) {
console.error("❌ mixi2への投稿に失敗しました:", error.message || error);
}
}
// 🥝 Misskeyへの投稿処理
async function postToMisskey(text, imageBuffer, mimeType, originalName) {
console.log("Misskeyへの投稿を開始します...");
try {
const host = process.env.MISSKEY_HOST || '<https://misskey.io>';
let fileIds = [];
if (imageBuffer && mimeType) {
const blob = new Blob([imageBuffer], { type: mimeType });
const formData = new FormData();
formData.append('i', process.env.MISSKEY_TOKEN);
formData.append('file', blob, originalName || 'image.jpg');
const uploadRes = await fetch(`${host}/api/drive/files/create`, { method: 'POST', body: formData });
if (!uploadRes.ok) throw new Error("Upload failed");
const uploadData = await uploadRes.json();
fileIds.push(uploadData.id);
}
const response = await fetch(`${host}/api/notes/create`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
i: process.env.MISSKEY_TOKEN,
text: text || null,
fileIds: fileIds.length > 0 ? fileIds : undefined
})
});
if (!response.ok) throw new Error("Post failed");
console.log("✅ Misskeyへの投稿に成功しました。");
} catch (error) {
console.error("❌ Misskeyへの投稿に失敗しました:", error.message);
}
}
// 🚪 APIエンドポイント
app.post('/post', upload.single('image'), async (req, res) => {
const text = req.body.text;
const image = req.file;
if (!text && !image) return res.status(400).json({ error: "テキストまたは画像が必要です。" });
console.log(`\n=== 新しい投稿リクエストを受信しました ===`);
if (text) console.log(`テキスト: ${text}`);
let finalBuffer = null;
let finalMimeType = null;
let originalName = null;
if (image) {
originalName = image.originalname;
finalBuffer = image.buffer;
finalMimeType = image.mimetype;
if (image.buffer.length > 1900000) {
console.log("⚠️ 画像サイズが大きいため、共通の最適化(リサイズ&圧縮)を行います...");
finalBuffer = await sharp(image.buffer)
.resize({ width: 1600, withoutEnlargement: true }) // 縦横サイズも縮小
.jpeg({ quality: 80 })
.toBuffer();
finalMimeType = 'image/jpeg';
console.log(`✅ 最適化完了: ${finalBuffer.length} bytes に軽量化しました。`);
}
}
res.status(200).json({ message: "投稿処理を開始しました。" });
await Promise.all([
postToBluesky(text, finalBuffer, finalMimeType),
postToMixi2(text, finalBuffer, finalMimeType),
postToMisskey(text, finalBuffer, finalMimeType, originalName)
]);
console.log("=== 全ての投稿処理が完了しました ===\n");
});
app.listen(port, () => {
console.log(`マルチSNS投稿サーバーが起動しました!`);
console.log(`待機ポート: ${port}`);
});