diff --git a/src/ai-manager.ts b/src/ai-manager.ts index 2c5f41f..39254ad 100644 --- a/src/ai-manager.ts +++ b/src/ai-manager.ts @@ -2,16 +2,20 @@ import { GoogleGenerativeAI } from '@google/generative-ai'; import axios from 'axios'; import { getConfig } from './config-manager.js'; -export async function generateAltText(buffer: Buffer, mimeType: string, contextText: string): Promise { +export async function generateAltText( + buffer: Buffer, + mimeType: string, + contextText: string, +): Promise { const config = getConfig(); - + // 1. Determine Provider and Credentials // Priority: AI Config > Legacy Gemini Config > Environment Variables - - let provider = config.ai?.provider || 'gemini'; + + const provider = config.ai?.provider || 'gemini'; let apiKey = config.ai?.apiKey; let model = config.ai?.model; - let baseUrl = config.ai?.baseUrl; + const baseUrl = config.ai?.baseUrl; // Fallbacks for Environment Variables if (!apiKey) { @@ -23,12 +27,12 @@ export async function generateAltText(buffer: Buffer, mimeType: string, contextT // Fallback for Gemini specific legacy env var if provider is implicitly gemini if (!apiKey && provider === 'gemini') { - apiKey = process.env.GEMINI_API_KEY; + apiKey = process.env.GEMINI_API_KEY; } // API Key is mandatory for Gemini and Anthropic if (!apiKey && (provider === 'gemini' || provider === 'anthropic')) { - return undefined; + return undefined; } // Default Models @@ -48,7 +52,14 @@ export async function generateAltText(buffer: Buffer, mimeType: string, contextT return await callOpenAICompatible(apiKey, model || 'gpt-4o', baseUrl, buffer, mimeType, contextText); case 'anthropic': // apiKey is guaranteed by check above - return await callAnthropic(apiKey!, model || 'claude-3-5-sonnet-20241022', baseUrl, buffer, mimeType, contextText); + return await callAnthropic( + apiKey!, + model || 'claude-3-5-sonnet-20241022', + baseUrl, + buffer, + mimeType, + contextText, + ); default: console.warn(`[AI] ⚠️ Unknown provider: ${provider}`); return undefined; @@ -59,7 +70,13 @@ export async function generateAltText(buffer: Buffer, mimeType: string, contextT } } -async function callGemini(apiKey: string, modelName: string, buffer: Buffer, mimeType: string, contextText: string): Promise { +async function callGemini( + apiKey: string, + modelName: string, + buffer: Buffer, + mimeType: string, + contextText: string, +): Promise { const genAI = new GoogleGenerativeAI(apiKey); const model = genAI.getGenerativeModel({ model: modelName }); @@ -72,53 +89,62 @@ async function callGemini(apiKey: string, modelName: string, buffer: Buffer, mim { inlineData: { data: buffer.toString('base64'), - mimeType - } - } + mimeType, + }, + }, ]); const response = await result.response; return response.text(); } -async function callOpenAICompatible(apiKey: string | undefined, model: string, baseUrl: string | undefined, buffer: Buffer, mimeType: string, contextText: string): Promise { - const url = baseUrl ? `${baseUrl.replace(/\/+$/, '')}/chat/completions` : 'https://api.openai.com/v1/chat/completions'; - +async function callOpenAICompatible( + apiKey: string | undefined, + model: string, + baseUrl: string | undefined, + buffer: Buffer, + mimeType: string, + contextText: string, +): Promise { + const url = baseUrl + ? `${baseUrl.replace(/\/+$/, '')}/chat/completions` + : 'https://api.openai.com/v1/chat/completions'; + const base64Image = `data:${mimeType};base64,${buffer.toString('base64')}`; const payload = { model: model, messages: [ { - role: "user", + role: 'user', content: [ { - type: "text", - text: `Describe this image for alt text. Be concise but descriptive. Context from the tweet text: "${contextText}".` + type: 'text', + text: `Describe this image for alt text. Be concise but descriptive. Context from the tweet text: "${contextText}".`, }, { - type: "image_url", + type: 'image_url', image_url: { - url: base64Image - } - } - ] - } + url: base64Image, + }, + }, + ], + }, ], - max_tokens: 300 + max_tokens: 300, }; const headers: Record = { - 'Content-Type': 'application/json' + 'Content-Type': 'application/json', }; if (apiKey) { - headers['Authorization'] = `Bearer ${apiKey}`; + headers['Authorization'] = `Bearer ${apiKey}`; } // OpenRouter specific headers (optional but good practice) if (url.includes('openrouter.ai')) { - headers['HTTP-Referer'] = 'https://github.com/tweets-2-bsky'; - headers['X-Title'] = 'Tweets to Bluesky'; + headers['HTTP-Referer'] = 'https://github.com/tweets-2-bsky'; + headers['X-Title'] = 'Tweets to Bluesky'; } const response = await axios.post(url, payload, { headers }); @@ -126,9 +152,16 @@ async function callOpenAICompatible(apiKey: string | undefined, model: string, b return response.data.choices[0]?.message?.content || undefined; } -async function callAnthropic(apiKey: string, model: string, baseUrl: string | undefined, buffer: Buffer, mimeType: string, contextText: string): Promise { +async function callAnthropic( + apiKey: string, + model: string, + baseUrl: string | undefined, + buffer: Buffer, + mimeType: string, + contextText: string, +): Promise { const url = baseUrl ? `${baseUrl.replace(/\/+$/, '')}/v1/messages` : 'https://api.anthropic.com/v1/messages'; - + const base64Data = buffer.toString('base64'); const payload = { @@ -136,31 +169,31 @@ async function callAnthropic(apiKey: string, model: string, baseUrl: string | un max_tokens: 300, messages: [ { - role: "user", + role: 'user', content: [ { - type: "image", + type: 'image', source: { - type: "base64", + type: 'base64', media_type: mimeType, - data: base64Data - } + data: base64Data, + }, }, { - type: "text", - text: `Describe this image for alt text. Be concise but descriptive. Context from the tweet text: "${contextText}".` - } - ] - } - ] + type: 'text', + text: `Describe this image for alt text. Be concise but descriptive. Context from the tweet text: "${contextText}".`, + }, + ], + }, + ], }; const response = await axios.post(url, payload, { headers: { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01', - 'Content-Type': 'application/json' - } + 'Content-Type': 'application/json', + }, }); return response.data.content[0]?.text || undefined; diff --git a/src/cli.ts b/src/cli.ts index e060eec..8fa668b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,6 +1,6 @@ import { Command } from 'commander'; import inquirer from 'inquirer'; -import { addMapping, getConfig, removeMapping, saveConfig, updateTwitterConfig, type AIConfig } from './config-manager.js'; +import { addMapping, getConfig, removeMapping, saveConfig, updateTwitterConfig } from './config-manager.js'; const program = new Command(); @@ -15,10 +15,10 @@ program .action(async () => { const config = getConfig(); const currentAi = config.ai || { provider: 'gemini' }; - + // Check legacy gemini key if not in new config if (!config.ai && config.geminiApiKey) { - currentAi.apiKey = config.geminiApiKey; + currentAi.apiKey = config.geminiApiKey; } const answers = await inquirer.prompt([ @@ -30,9 +30,9 @@ program { name: 'Google Gemini (Default)', value: 'gemini' }, { name: 'OpenAI / OpenRouter', value: 'openai' }, { name: 'Anthropic (Claude)', value: 'anthropic' }, - { name: 'Custom (OpenAI Compatible)', value: 'custom' } + { name: 'Custom (OpenAI Compatible)', value: 'custom' }, ], - default: currentAi.provider + default: currentAi.provider, }, { type: 'input', @@ -40,11 +40,11 @@ program message: 'Enter API Key (optional for some custom providers):', default: currentAi.apiKey, validate: (input: string, answers: any) => { - if (['gemini', 'anthropic'].includes(answers.provider) && !input) { - return 'API Key is required for this provider.'; - } - return true; - } + if (['gemini', 'anthropic'].includes(answers.provider) && !input) { + return 'API Key is required for this provider.'; + } + return true; + }, }, { type: 'input', @@ -57,20 +57,20 @@ program name: 'baseUrl', message: 'Enter Base URL (optional, e.g. for OpenRouter):', default: currentAi.baseUrl, - when: (answers) => ['openai', 'anthropic', 'custom'].includes(answers.provider) - } + when: (answers) => ['openai', 'anthropic', 'custom'].includes(answers.provider), + }, ]); config.ai = { - provider: answers.provider, - apiKey: answers.apiKey, - model: answers.model || undefined, - baseUrl: answers.baseUrl || undefined + provider: answers.provider, + apiKey: answers.apiKey, + model: answers.model || undefined, + baseUrl: answers.baseUrl || undefined, }; - + // Clear legacy key to avoid confusion delete config.geminiApiKey; - + saveConfig(config); console.log('AI configuration updated!'); }); @@ -125,9 +125,12 @@ program default: 'https://bsky.social', }, ]); - - const usernames = answers.twitterUsernames.split(',').map((u: string) => u.trim()).filter((u: string) => u.length > 0); - + + const usernames = answers.twitterUsernames + .split(',') + .map((u: string) => u.trim()) + .filter((u: string) => u.length > 0); + addMapping({ ...answers, twitterUsernames: usernames, @@ -144,7 +147,7 @@ program console.log('No mappings found.'); return; } - + const { id } = await inquirer.prompt([ { type: 'list', @@ -186,27 +189,30 @@ program }, ]); - const usernames = answers.twitterUsernames.split(',').map((u: string) => u.trim()).filter((u: string) => u.length > 0); + const usernames = answers.twitterUsernames + .split(',') + .map((u: string) => u.trim()) + .filter((u: string) => u.length > 0); // Update the mapping directly - const index = config.mappings.findIndex(m => m.id === id); + const index = config.mappings.findIndex((m) => m.id === id); const existingMapping = config.mappings[index]; - + if (index !== -1 && existingMapping) { - const updatedMapping = { - ...existingMapping, - twitterUsernames: usernames, - bskyIdentifier: answers.bskyIdentifier, - bskyServiceUrl: answers.bskyServiceUrl, - }; - - if (answers.bskyPassword && answers.bskyPassword.trim().length > 0) { - updatedMapping.bskyPassword = answers.bskyPassword; - } - - config.mappings[index] = updatedMapping; - saveConfig(config); - console.log('Mapping updated successfully!'); + const updatedMapping = { + ...existingMapping, + twitterUsernames: usernames, + bskyIdentifier: answers.bskyIdentifier, + bskyServiceUrl: answers.bskyServiceUrl, + }; + + if (answers.bskyPassword && answers.bskyPassword.trim().length > 0) { + updatedMapping.bskyPassword = answers.bskyPassword; + } + + config.mappings[index] = updatedMapping; + saveConfig(config); + console.log('Mapping updated successfully!'); } }); diff --git a/src/config-manager.ts b/src/config-manager.ts index fccb61a..bc243d5 100644 --- a/src/config-manager.ts +++ b/src/config-manager.ts @@ -69,7 +69,7 @@ export function getConfig(): AppConfig { export function saveConfig(config: AppConfig): void { // biome-ignore lint/suspicious/noExplicitAny: cleanup before save const configToSave = { ...config } as any; - + // Remove legacy field from saved file configToSave.mappings = configToSave.mappings.map((m: any) => { const { twitterUsername, ...rest } = m; @@ -94,7 +94,7 @@ export function updateMapping(id: string, updates: Partial m.id === id); const existing = config.mappings[index]; - + if (index !== -1 && existing) { config.mappings[index] = { ...existing, ...updates }; saveConfig(config); diff --git a/src/db.ts b/src/db.ts index f2bbe6b..dd0cbfb 100644 --- a/src/db.ts +++ b/src/db.ts @@ -1,7 +1,7 @@ -import Database from 'better-sqlite3'; -import path from 'node:path'; import fs from 'node:fs'; +import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import Database from 'better-sqlite3'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -17,11 +17,11 @@ const db = new Database(path.join(DB_DIR, 'database.sqlite')); db.pragma('journal_mode = WAL'); // --- Migration Support --- -const tableInfo = db.prepare("PRAGMA table_info(processed_tweets)").all() as any[]; +const tableInfo = db.prepare('PRAGMA table_info(processed_tweets)').all() as any[]; if (tableInfo.length > 0) { - const hasBskyIdentifier = tableInfo.some(col => col.name === 'bsky_identifier'); - + const hasBskyIdentifier = tableInfo.some((col) => col.name === 'bsky_identifier'); + if (!hasBskyIdentifier) { console.log('🔄 Upgrading database schema to support multiple accounts...'); // SQLite doesn't support easy PK changes, so we recreate the table @@ -96,7 +96,7 @@ export const dbService = { bsky_cid: row.bsky_cid, bsky_root_uri: row.bsky_root_uri, bsky_root_cid: row.bsky_root_cid, - status: row.status + status: row.status, }; }, @@ -114,7 +114,7 @@ export const dbService = { tweet.bsky_cid || null, tweet.bsky_root_uri || null, tweet.bsky_root_cid || null, - tweet.status + tweet.status, ); }, @@ -128,7 +128,7 @@ export const dbService = { cid: row.bsky_cid, root: row.bsky_root_uri ? { uri: row.bsky_root_uri, cid: row.bsky_root_cid } : undefined, migrated: row.status === 'migrated', - skipped: row.status === 'skipped' + skipped: row.status === 'skipped', }; } return map; @@ -144,7 +144,7 @@ export const dbService = { cid: row.bsky_cid, root: row.bsky_root_uri ? { uri: row.bsky_root_uri, cid: row.bsky_root_cid } : undefined, migrated: row.status === 'migrated', - skipped: row.status === 'skipped' + skipped: row.status === 'skipped', }; } return map; @@ -156,11 +156,13 @@ export const dbService = { }, repairUnknownIdentifiers(twitterUsername: string, bskyIdentifier: string) { - const stmt = db.prepare('UPDATE processed_tweets SET bsky_identifier = ? WHERE bsky_identifier = "unknown" AND twitter_username = ?'); + const stmt = db.prepare( + 'UPDATE processed_tweets SET bsky_identifier = ? WHERE bsky_identifier = "unknown" AND twitter_username = ?', + ); stmt.run(bskyIdentifier.toLowerCase(), twitterUsername.toLowerCase()); }, clearAll() { db.prepare('DELETE FROM processed_tweets').run(); - } + }, }; diff --git a/src/index.ts b/src/index.ts index f0dee4e..6ec45ba 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,12 +7,11 @@ import { BskyAgent, RichText } from '@atproto/api'; import type { BlobRef } from '@atproto/api'; import { TwitterClient } from '@steipete/bird/dist/lib/twitter-client.js'; import axios from 'axios'; +import * as cheerio from 'cheerio'; import { Command } from 'commander'; import * as francModule from 'franc-min'; import iso6391 from 'iso-639-1'; -import os from 'node:os'; import puppeteer from 'puppeteer-core'; -import * as cheerio from 'cheerio'; import sharp from 'sharp'; import { generateAltText } from './ai-manager.js'; @@ -98,6 +97,10 @@ interface Tweet { in_reply_to_status_id?: string; in_reply_to_user_id_str?: string; in_reply_to_user_id?: string; + user?: { + screen_name?: string; + id_str?: string; + }; } interface TwitterSearchResult { @@ -127,23 +130,23 @@ const PROCESSED_DIR = path.join(__dirname, '..', 'processed'); async function migrateJsonToSqlite() { if (!fs.existsSync(PROCESSED_DIR)) return; - - const files = fs.readdirSync(PROCESSED_DIR).filter(f => f.endsWith('.json')); + + const files = fs.readdirSync(PROCESSED_DIR).filter((f) => f.endsWith('.json')); if (files.length === 0) return; console.log(`📦 Found ${files.length} legacy cache files. Migrating to SQLite...`); const config = getConfig(); - + for (const file of files) { const username = file.replace('.json', '').toLowerCase(); // Try to find a matching bskyIdentifier from config - const mapping = config.mappings.find(m => m.twitterUsernames.map(u => u.toLowerCase()).includes(username)); + const mapping = config.mappings.find((m) => m.twitterUsernames.map((u) => u.toLowerCase()).includes(username)); const bskyIdentifier = mapping?.bskyIdentifier || 'unknown'; try { const filePath = path.join(PROCESSED_DIR, file); const data = JSON.parse(fs.readFileSync(filePath, 'utf8')) as ProcessedTweetsMap; - + for (const [twitterId, entry] of Object.entries(data)) { dbService.saveTweet({ twitter_id: twitterId, @@ -153,7 +156,7 @@ async function migrateJsonToSqlite() { bsky_cid: entry.cid, bsky_root_uri: entry.root?.uri, bsky_root_cid: entry.root?.cid, - status: entry.migrated ? 'migrated' : (entry.skipped ? 'skipped' : 'failed') + status: entry.migrated ? 'migrated' : entry.skipped ? 'skipped' : 'failed', }); } // Move file to backup @@ -171,7 +174,7 @@ async function migrateJsonToSqlite() { dbService.repairUnknownIdentifiers(username, mapping.bskyIdentifier); } } - + console.log('✅ Migration complete.'); } @@ -179,7 +182,12 @@ function loadProcessedTweets(bskyIdentifier: string): ProcessedTweetsMap { return dbService.getTweetsByBskyIdentifier(bskyIdentifier); } -function saveProcessedTweet(twitterUsername: string, bskyIdentifier: string, twitterId: string, entry: ProcessedTweetEntry): void { +function saveProcessedTweet( + twitterUsername: string, + bskyIdentifier: string, + twitterId: string, + entry: ProcessedTweetEntry, +): void { dbService.saveTweet({ twitter_id: twitterId, twitter_username: twitterUsername.toLowerCase(), @@ -188,7 +196,7 @@ function saveProcessedTweet(twitterUsername: string, bskyIdentifier: string, twi bsky_cid: entry.cid, bsky_root_uri: entry.root?.uri, bsky_root_cid: entry.root?.cid, - status: entry.migrated || (entry.uri && entry.cid) ? 'migrated' : (entry.skipped ? 'skipped' : 'failed') + status: entry.migrated || (entry.uri && entry.cid) ? 'migrated' : entry.skipped ? 'skipped' : 'failed', }); } @@ -219,6 +227,14 @@ class CustomTwitterClient extends TwitterClient { mapped.in_reply_to_status_id_str = result.legacy.in_reply_to_status_id_str; mapped.in_reply_to_user_id_str = result.legacy.in_reply_to_user_id_str; } + // Capture user info from the Twitter API response + if (mapped && (result as any).core?.user_results?.result?.legacy) { + const userLegacy = (result as any).core.user_results.result.legacy; + mapped.user = { + screen_name: userLegacy.screen_name, + id_str: (result as any).core.user_results.result.rest_id, + }; + } return mapped; } } @@ -229,20 +245,22 @@ let currentTwitterCookies = { authToken: '', ct0: '' }; function getTwitterClient() { const config = getConfig(); if (!config.twitter.authToken || !config.twitter.ct0) return null; - + // Re-initialize if config changed or not yet initialized - if (!twitter || - currentTwitterCookies.authToken !== config.twitter.authToken || - currentTwitterCookies.ct0 !== config.twitter.ct0) { + if ( + !twitter || + currentTwitterCookies.authToken !== config.twitter.authToken || + currentTwitterCookies.ct0 !== config.twitter.ct0 + ) { twitter = new CustomTwitterClient({ cookies: { authToken: config.twitter.authToken, ct0: config.twitter.ct0, }, }); - currentTwitterCookies = { - authToken: config.twitter.authToken, - ct0: config.twitter.ct0 + currentTwitterCookies = { + authToken: config.twitter.authToken, + ct0: config.twitter.ct0, }; } return twitter; @@ -316,7 +334,10 @@ async function uploadToBluesky(agent: BskyAgent, buffer: Buffer, mimeType: strin const isGif = mimeType === 'image/gif'; const isAnimation = isGif || isWebp; - if ((buffer.length > MAX_SIZE && (mimeType.startsWith('image/') || mimeType === 'application/octet-stream')) || (isPng && buffer.length > MAX_SIZE)) { + if ( + (buffer.length > MAX_SIZE && (mimeType.startsWith('image/') || mimeType === 'application/octet-stream')) || + (isPng && buffer.length > MAX_SIZE) + ) { console.log(`[UPLOAD] ⚖️ Image too large (${(buffer.length / 1024).toFixed(2)} KB). Optimizing...`); try { let image = sharp(buffer); @@ -330,48 +351,47 @@ async function uploadToBluesky(agent: BskyAgent, buffer: Buffer, mimeType: strin while (currentBuffer.length > MAX_SIZE && attempts < 5) { attempts++; console.log(`[UPLOAD] 📉 Compression attempt ${attempts}: Width ${width}, Quality ${quality}...`); - + if (isAnimation) { - // For animations (GIF/WebP), we can only do so much without losing frames - // Try to convert to WebP if it's a GIF, or optimize WebP - image = sharp(buffer, { animated: true }); - if (isGif) { - // Convert GIF to WebP for better compression - image = image.webp({ quality: Math.max(quality, 50), effort: 6 }); - finalMimeType = 'image/webp'; - } else { - image = image.webp({ quality: Math.max(quality, 50), effort: 6 }); - } - // Resize if really big - if (metadata.width && metadata.width > 800) { - image = image.resize({ width: 800, withoutEnlargement: true }); - } + // For animations (GIF/WebP), we can only do so much without losing frames + // Try to convert to WebP if it's a GIF, or optimize WebP + image = sharp(buffer, { animated: true }); + if (isGif) { + // Convert GIF to WebP for better compression + image = image.webp({ quality: Math.max(quality, 50), effort: 6 }); + finalMimeType = 'image/webp'; + } else { + image = image.webp({ quality: Math.max(quality, 50), effort: 6 }); + } + // Resize if really big + if (metadata.width && metadata.width > 800) { + image = image.resize({ width: 800, withoutEnlargement: true }); + } } else { - // Static images - if (width > 1600) width = 1600; - else if (attempts > 1) width = Math.floor(width * 0.8); - - quality = Math.max(50, quality - 10); - - image = sharp(buffer) - .resize({ width, withoutEnlargement: true }) - .jpeg({ quality, mozjpeg: true }); - - finalMimeType = 'image/jpeg'; + // Static images + if (width > 1600) width = 1600; + else if (attempts > 1) width = Math.floor(width * 0.8); + + quality = Math.max(50, quality - 10); + + image = sharp(buffer).resize({ width, withoutEnlargement: true }).jpeg({ quality, mozjpeg: true }); + + finalMimeType = 'image/jpeg'; } - + currentBuffer = await image.toBuffer(); if (currentBuffer.length <= MAX_SIZE) { - finalBuffer = currentBuffer; - console.log(`[UPLOAD] ✅ Optimized to ${(finalBuffer.length / 1024).toFixed(2)} KB`); - break; + finalBuffer = currentBuffer; + console.log(`[UPLOAD] ✅ Optimized to ${(finalBuffer.length / 1024).toFixed(2)} KB`); + break; } } - + if (finalBuffer.length > MAX_SIZE) { - console.warn(`[UPLOAD] ⚠️ Could not compress below limit. Current: ${(finalBuffer.length / 1024).toFixed(2)} KB. Upload might fail.`); + console.warn( + `[UPLOAD] ⚠️ Could not compress below limit. Current: ${(finalBuffer.length / 1024).toFixed(2)} KB. Upload might fail.`, + ); } - } catch (err) { console.warn(`[UPLOAD] ⚠️ Optimization failed, attempting original upload:`, (err as Error).message); finalBuffer = buffer; @@ -393,8 +413,8 @@ async function captureTweetScreenshot(tweetUrl: string): Promise 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe', ]; - const executablePath = browserPaths.find(p => fs.existsSync(p)); - + const executablePath = browserPaths.find((p) => fs.existsSync(p)); + if (!executablePath) { console.warn(`[SCREENSHOT] ⏩ Skipping screenshot (no Chrome/Chromium found at common paths).`); return null; @@ -438,12 +458,12 @@ async function captureTweetScreenshot(tweetUrl: string): Promise `; await page.setContent(html, { waitUntil: 'networkidle0' }); - + // Wait for the twitter iframe to load and render try { await page.waitForSelector('iframe', { timeout: 10000 }); // Small extra wait for images inside iframe - await new Promise(r => setTimeout(r, 2000)); + await new Promise((r) => setTimeout(r, 2000)); } catch (e) { console.warn(`[SCREENSHOT] ⚠️ Timeout waiting for tweet iframe, taking screenshot anyway.`); } @@ -469,8 +489,8 @@ async function pollForVideoProcessing(agent: BskyAgent, jobId: string): Promise< while (!blob) { attempts++; - const statusUrl = new URL("https://video.bsky.app/xrpc/app.bsky.video.getJobStatus"); - statusUrl.searchParams.append("jobId", jobId); + const statusUrl = new URL('https://video.bsky.app/xrpc/app.bsky.video.getJobStatus'); + statusUrl.searchParams.append('jobId', jobId); const statusResponse = await fetch(statusUrl); if (!statusResponse.ok) { @@ -488,8 +508,8 @@ async function pollForVideoProcessing(agent: BskyAgent, jobId: string): Promise< if (statusData.jobStatus.blob) { blob = statusData.jobStatus.blob; console.log(`[VIDEO] 🎉 Video processing complete! Blob ref obtained.`); - } else if (state === "JOB_STATE_FAILED") { - throw new Error(`Video processing failed: ${statusData.jobStatus.error || "Unknown error"}`); + } else if (state === 'JOB_STATE_FAILED') { + throw new Error(`Video processing failed: ${statusData.jobStatus.error || 'Unknown error'}`); } else { // Wait before next poll await new Promise((resolve) => setTimeout(resolve, 5000)); @@ -497,7 +517,7 @@ async function pollForVideoProcessing(agent: BskyAgent, jobId: string): Promise< if (attempts > 60) { // ~5 minute timeout - throw new Error("Video processing timed out after 5 minutes."); + throw new Error('Video processing timed out after 5 minutes.'); } } return blob!; @@ -507,49 +527,50 @@ async function fetchEmbedUrlCard(agent: BskyAgent, url: string): Promise { try { const response = await axios.get(url, { headers: { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', - 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8', + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8', 'Accept-Language': 'en-US,en;q=0.9', }, timeout: 10000, }); - + const $ = cheerio.load(response.data); const title = $('meta[property="og:title"]').attr('content') || $('title').text() || ''; - const description = $('meta[property="og:description"]').attr('content') || $('meta[name="description"]').attr('content') || ''; + const description = + $('meta[property="og:description"]').attr('content') || $('meta[name="description"]').attr('content') || ''; let thumbBlob: BlobRef | undefined; let imageUrl = $('meta[property="og:image"]').attr('content'); if (imageUrl) { - if (!imageUrl.startsWith('http')) { - const baseUrl = new URL(url); - imageUrl = new URL(imageUrl, baseUrl.origin).toString(); - } - try { - const { buffer, mimeType } = await downloadMedia(imageUrl); - thumbBlob = await uploadToBluesky(agent, buffer, mimeType); - } catch (e) { - console.warn(`Failed to upload thumbnail for ${url}:`, e); - } + if (!imageUrl.startsWith('http')) { + const baseUrl = new URL(url); + imageUrl = new URL(imageUrl, baseUrl.origin).toString(); + } + try { + const { buffer, mimeType } = await downloadMedia(imageUrl); + thumbBlob = await uploadToBluesky(agent, buffer, mimeType); + } catch (e) { + console.warn(`Failed to upload thumbnail for ${url}:`, e); + } } if (!title && !description) return null; const external: any = { - uri: url, - title: title || url, - description: description, + uri: url, + title: title || url, + description: description, }; if (thumbBlob) { - external.thumb = thumbBlob; + external.thumb = thumbBlob; } return { - $type: 'app.bsky.embed.external', - external, + $type: 'app.bsky.embed.external', + external, }; - } catch (err) { console.warn(`Failed to fetch embed card for ${url}:`, err); return null; @@ -557,7 +578,7 @@ async function fetchEmbedUrlCard(agent: BskyAgent, url: string): Promise { } async function uploadVideoToBluesky(agent: BskyAgent, buffer: Buffer, filename: string): Promise { - const sanitizedFilename = filename.split("?")[0] || "video.mp4"; + const sanitizedFilename = filename.split('?')[0] || 'video.mp4'; console.log( `[VIDEO] 🟢 Starting upload process for ${sanitizedFilename} (${(buffer.length / 1024 / 1024).toFixed(2)} MB)`, ); @@ -570,17 +591,17 @@ async function uploadVideoToBluesky(agent: BskyAgent, buffer: Buffer, filename: // didDoc might be present in repoDesc const pdsService = (repoDesc as any).didDoc?.service?.find( - (s: any) => s.id === "#atproto_pds" || s.type === "AtProtoPds", + (s: any) => s.id === '#atproto_pds' || s.type === 'AtProtoPds', ); const pdsUrl = pdsService?.serviceEndpoint; - const pdsHost = pdsUrl ? new URL(pdsUrl).host : "bsky.social"; + const pdsHost = pdsUrl ? new URL(pdsUrl).host : 'bsky.social'; console.log(`[VIDEO] 🌐 PDS Host detected: ${pdsHost}`); console.log(`[VIDEO] 🔑 Requesting service auth token for audience: did:web:${pdsHost}...`); const { data: serviceAuth } = await agent.com.atproto.server.getServiceAuth({ aud: `did:web:${pdsHost}`, - lxm: "com.atproto.repo.uploadBlob", + lxm: 'com.atproto.repo.uploadBlob', exp: Math.floor(Date.now() / 1000) + 60 * 30, }); console.log(`[VIDEO] ✅ Service auth token obtained.`); @@ -588,16 +609,16 @@ async function uploadVideoToBluesky(agent: BskyAgent, buffer: Buffer, filename: const token = serviceAuth.token; // 2. Upload to Video Service - const uploadUrl = new URL("https://video.bsky.app/xrpc/app.bsky.video.uploadVideo"); - uploadUrl.searchParams.append("did", agent.session!.did!); - uploadUrl.searchParams.append("name", sanitizedFilename); + const uploadUrl = new URL('https://video.bsky.app/xrpc/app.bsky.video.uploadVideo'); + uploadUrl.searchParams.append('did', agent.session!.did!); + uploadUrl.searchParams.append('name', sanitizedFilename); console.log(`[VIDEO] 📤 Uploading to ${uploadUrl.href}...`); const uploadResponse = await fetch(uploadUrl, { - method: "POST", + method: 'POST', headers: { Authorization: `Bearer ${token}`, - "Content-Type": "video/mp4", + 'Content-Type': 'video/mp4', }, body: buffer, }); @@ -608,13 +629,18 @@ async function uploadVideoToBluesky(agent: BskyAgent, buffer: Buffer, filename: try { const errorJson = JSON.parse(errorText); - if (errorJson.error === "already_exists" && errorJson.jobId) { + if (errorJson.error === 'already_exists' && errorJson.jobId) { console.log(`[VIDEO] ♻️ Video already exists. Resuming with Job ID: ${errorJson.jobId}`); return await pollForVideoProcessing(agent, errorJson.jobId); } - if (errorJson.error === "unconfirmed_email" || (errorJson.jobStatus && errorJson.jobStatus.error === "unconfirmed_email")) { - console.error(`[VIDEO] 🛑 BLUESKY ERROR: Your email is unconfirmed. You MUST verify your email on Bluesky to upload videos.`); - throw new Error("Bluesky Email Unconfirmed - Video Upload Rejected"); + if ( + errorJson.error === 'unconfirmed_email' || + (errorJson.jobStatus && errorJson.jobStatus.error === 'unconfirmed_email') + ) { + console.error( + `[VIDEO] 🛑 BLUESKY ERROR: Your email is unconfirmed. You MUST verify your email on Bluesky to upload videos.`, + ); + throw new Error('Bluesky Email Unconfirmed - Video Upload Rejected'); } } catch (e) { // Not JSON or missing fields, proceed with throwing @@ -732,19 +758,31 @@ async function processTweets( tweets: Tweet[], dryRun = false, ): Promise { + // Filter tweets to ensure they're actually from this user + const filteredTweets = tweets.filter((t) => { + const authorScreenName = t.user?.screen_name?.toLowerCase(); + if (authorScreenName && authorScreenName !== twitterUsername.toLowerCase()) { + console.log( + `[${twitterUsername}] ⏩ Skipping tweet ${t.id_str || t.id} - author is @${t.user?.screen_name}, not @${twitterUsername}`, + ); + return false; + } + return true; + }); + const processedTweets = loadProcessedTweets(bskyIdentifier); - const toProcess = tweets.filter(t => !processedTweets[t.id_str || t.id || '']); - + const toProcess = filteredTweets.filter((t) => !processedTweets[t.id_str || t.id || '']); + if (toProcess.length === 0) { console.log(`[${twitterUsername}] ✅ No new tweets to process for ${bskyIdentifier}.`); return; } console.log(`[${twitterUsername}] 🚀 Processing ${toProcess.length} new tweets for ${bskyIdentifier}...`); - - tweets.reverse(); + + filteredTweets.reverse(); let count = 0; - for (const tweet of tweets) { + for (const tweet of filteredTweets) { count++; const tweetId = tweet.id_str || tweet.id; if (!tweetId) continue; @@ -756,7 +794,7 @@ async function processTweets( state: 'processing', currentAccount: twitterUsername, processedCount: count, - totalCount: tweets.length, + totalCount: filteredTweets.length, message: `Processing tweet ${tweetId}`, }); @@ -791,7 +829,7 @@ async function processTweets( .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, "'"); - + // 1. Link Expansion console.log(`[${twitterUsername}] 🔗 Expanding links...`); const urls = tweet.entities?.urls || []; @@ -806,14 +844,14 @@ async function processTweets( const matches = text.match(tcoRegex) || []; for (const tco of matches) { // Avoid re-resolving if we already handled it via entities - if (urls.some(u => u.url === tco)) continue; + if (urls.some((u) => u.url === tco)) continue; console.log(`[${twitterUsername}] 🔍 Resolving fallback link: ${tco}`); const resolved = await expandUrl(tco); if (resolved !== tco) { - text = text.replace(tco, resolved); - // Add to urls array so it can be used for card embedding later - urls.push({ url: tco, expanded_url: resolved }); + text = text.replace(tco, resolved); + // Add to urls array so it can be used for card embedding later + urls.push({ url: tco, expanded_url: resolved }); } } @@ -831,7 +869,7 @@ async function processTweets( mediaLinksToRemove.push(media.url); if (media.expanded_url) mediaLinksToRemove.push(media.expanded_url); } - + let aspectRatio: AspectRatio | undefined; if (media.sizes?.large) { aspectRatio = { width: media.sizes.large.w, height: media.sizes.large.h }; @@ -850,13 +888,13 @@ async function processTweets( console.log(`[${twitterUsername}] 📤 Uploading image to Bluesky...`); updateAppStatus({ message: `Uploading image to Bluesky...` }); const blob = await uploadToBluesky(agent, buffer, mimeType); - + let altText = media.ext_alt_text; if (!altText) { - console.log(`[${twitterUsername}] 🤖 Generating alt text via Gemini...`); - // Use original tweet text for context, not the modified/cleaned one - altText = await generateAltText(buffer, mimeType, tweetText); - if (altText) console.log(`[${twitterUsername}] ✅ Alt text generated: ${altText.substring(0, 50)}...`); + console.log(`[${twitterUsername}] 🤖 Generating alt text via Gemini...`); + // Use original tweet text for context, not the modified/cleaned one + altText = await generateAltText(buffer, mimeType, tweetText); + if (altText) console.log(`[${twitterUsername}] ✅ Alt text generated: ${altText.substring(0, 50)}...`); } images.push({ alt: altText || 'Image from Twitter', image: blob, aspectRatio }); @@ -877,12 +915,13 @@ async function processTweets( } else if (media.type === 'video' || media.type === 'animated_gif') { const variants = media.video_info?.variants || []; const duration = media.video_info?.duration_millis || 0; - - if (duration > 180000) { // 3 minutes - console.warn(`[${twitterUsername}] ⚠️ Video too long (${(duration / 1000).toFixed(1)}s). Fallback to link.`); - const tweetUrl = `https://twitter.com/${twitterUsername}/status/${tweetId}`; - if (!text.includes(tweetUrl)) text += `\n\nVideo: ${tweetUrl}`; - continue; + + if (duration > 180000) { + // 3 minutes + console.warn(`[${twitterUsername}] ⚠️ Video too long (${(duration / 1000).toFixed(1)}s). Fallback to link.`); + const tweetUrl = `https://twitter.com/${twitterUsername}/status/${tweetId}`; + if (!text.includes(tweetUrl)) text += `\n\nVideo: ${tweetUrl}`; + continue; } const mp4s = variants @@ -897,7 +936,7 @@ async function processTweets( console.log(`[${twitterUsername}] 📥 Downloading video: ${videoUrl}`); updateAppStatus({ message: `Downloading video: ${path.basename(videoUrl)}` }); const { buffer, mimeType } = await downloadMedia(videoUrl); - + if (buffer.length <= 90 * 1024 * 1024) { const filename = videoUrl.split('/').pop() || 'video.mp4'; updateAppStatus({ message: `Uploading video to Bluesky...` }); @@ -906,8 +945,10 @@ async function processTweets( console.log(`[${twitterUsername}] ✅ Video upload process complete.`); break; // Prioritize first video } - - console.warn(`[${twitterUsername}] ⚠️ Video too large (${(buffer.length / 1024 / 1024).toFixed(2)}MB). Fallback to link.`); + + console.warn( + `[${twitterUsername}] ⚠️ Video too large (${(buffer.length / 1024 / 1024).toFixed(2)}MB). Fallback to link.`, + ); const tweetUrl = `https://twitter.com/${twitterUsername}/status/${tweetId}`; if (!text.includes(tweetUrl)) text += `\n\nVideo: ${tweetUrl}`; } catch (err) { @@ -938,15 +979,16 @@ async function processTweets( } else { const quoteUrlEntity = urls.find((u) => u.expanded_url?.includes(quoteId)); const qUrl = quoteUrlEntity?.expanded_url || `https://twitter.com/i/status/${quoteId}`; - + // Check if it's a self-quote (same user) - const isSelfQuote = qUrl.toLowerCase().includes(`twitter.com/${twitterUsername.toLowerCase()}/`) || - qUrl.toLowerCase().includes(`x.com/${twitterUsername.toLowerCase()}/`); - + const isSelfQuote = + qUrl.toLowerCase().includes(`twitter.com/${twitterUsername.toLowerCase()}/`) || + qUrl.toLowerCase().includes(`x.com/${twitterUsername.toLowerCase()}/`); + if (!isSelfQuote) { externalQuoteUrl = qUrl; console.log(`[${twitterUsername}] 🔗 Quoted tweet is external: ${externalQuoteUrl}`); - + // Try to capture screenshot for external QTs if we have space for images if (images.length < 4 && !videoBlob) { const ssBuffer = await captureTweetScreenshot(externalQuoteUrl); @@ -964,23 +1006,23 @@ async function processTweets( } } } else if (images.length === 0 && !videoBlob) { - // If no media and no quote, check for external links to embed - // We prioritize the LAST link found as it's often the main content - const potentialLinks = urls - .map(u => u.expanded_url) - .filter(u => u && !u.includes('twitter.com') && !u.includes('x.com')) as string[]; - - if (potentialLinks.length > 0) { - const linkToEmbed = potentialLinks[potentialLinks.length - 1]; - if (linkToEmbed) { - console.log(`[${twitterUsername}] 🃏 Fetching link card for: ${linkToEmbed}`); - linkCard = await fetchEmbedUrlCard(agent, linkToEmbed); - } + // If no media and no quote, check for external links to embed + // We prioritize the LAST link found as it's often the main content + const potentialLinks = urls + .map((u) => u.expanded_url) + .filter((u) => u && !u.includes('twitter.com') && !u.includes('x.com')) as string[]; + + if (potentialLinks.length > 0) { + const linkToEmbed = potentialLinks[potentialLinks.length - 1]; + if (linkToEmbed) { + console.log(`[${twitterUsername}] 🃏 Fetching link card for: ${linkToEmbed}`); + linkCard = await fetchEmbedUrlCard(agent, linkToEmbed); } + } } // Only append link for external quotes IF we couldn't natively embed it OR screenshot it - const hasScreenshot = images.some(img => img.alt.startsWith('Quote Tweet:')); + const hasScreenshot = images.some((img) => img.alt.startsWith('Quote Tweet:')); if (externalQuoteUrl && !quoteEmbed && !hasScreenshot && !text.includes(externalQuoteUrl)) { text += `\n\nQT: ${externalQuoteUrl}`; } @@ -988,14 +1030,14 @@ async function processTweets( // 4. Threading and Posting const chunks = splitText(text); console.log(`[${twitterUsername}] 📝 Splitting text into ${chunks.length} chunks.`); - + let lastPostInfo: ProcessedTweetEntry | null = replyParentInfo; for (let i = 0; i < chunks.length; i++) { const chunk = chunks[i] as string; console.log(`[${twitterUsername}] 📤 Posting chunk ${i + 1}/${chunks.length}...`); updateAppStatus({ message: `Posting chunk ${i + 1}/${chunks.length}...` }); - + const rt = new RichText({ text: chunk }); await rt.detectFacets(agent); const detectedLangs = detectLanguage(chunk); @@ -1048,11 +1090,13 @@ async function processTweets( } catch (err: any) { retries--; if (retries === 0) throw err; - console.warn(`[${twitterUsername}] ⚠️ Post failed (Socket/Network), retrying in 5s... (${retries} retries left)`); - await new Promise(r => setTimeout(r, 5000)); + console.warn( + `[${twitterUsername}] ⚠️ Post failed (Socket/Network), retrying in 5s... (${retries} retries left)`, + ); + await new Promise((r) => setTimeout(r, 5000)); } } - + const currentPostInfo = { uri: response.uri, cid: response.cid, @@ -1062,10 +1106,10 @@ async function processTweets( if (i === 0) { saveProcessedTweet(twitterUsername, bskyIdentifier, tweetId, currentPostInfo); } - + lastPostInfo = currentPostInfo; console.log(`[${twitterUsername}] ✅ Chunk ${i + 1} posted successfully.`); - + if (chunks.length > 1) { await new Promise((r) => setTimeout(r, 3000)); } @@ -1074,7 +1118,7 @@ async function processTweets( break; } } - + const wait = 10000; console.log(`[${twitterUsername}] 😴 Pacing: Waiting ${wait / 1000}s before next tweet.`); updateAppStatus({ state: 'pacing', message: `Pacing: Waiting ${wait / 1000}s...` }); @@ -1105,11 +1149,17 @@ async function getAgent(mapping: { } } - - -async function importHistory(twitterUsername: string, bskyIdentifier: string, limit = 15, dryRun = false, ignoreCancellation = false): Promise { +async function importHistory( + twitterUsername: string, + bskyIdentifier: string, + limit = 15, + dryRun = false, + ignoreCancellation = false, +): Promise { const config = getConfig(); - const mapping = config.mappings.find((m) => m.twitterUsernames.map(u => u.toLowerCase()).includes(twitterUsername.toLowerCase())); + const mapping = config.mappings.find((m) => + m.twitterUsernames.map((u) => u.toLowerCase()).includes(twitterUsername.toLowerCase()), + ); if (!mapping) { console.error(`No mapping found for twitter username: ${twitterUsername}`); return; @@ -1129,7 +1179,7 @@ async function importHistory(twitterUsername: string, bskyIdentifier: string, li while (true) { // Check if this backfill request was cancelled (unless ignoring check) if (!ignoreCancellation) { - const stillPending = getPendingBackfills().some(b => b.id === mapping.id); + const stillPending = getPendingBackfills().some((b) => b.id === mapping.id); if (!stillPending) { console.log(`[${twitterUsername}] 🛑 Backfill cancelled by user.`); return; @@ -1149,6 +1199,14 @@ async function importHistory(twitterUsername: string, bskyIdentifier: string, li for (const t of result.tweets) { const tid = t.id_str || t.id; if (!tid) continue; + + // Validate tweet author + const authorScreenName = t.user?.screen_name?.toLowerCase(); + if (authorScreenName && authorScreenName !== twitterUsername.toLowerCase()) { + console.log(`[${twitterUsername}] ⏩ Skipping tweet ${tid} - author is @${t.user?.screen_name}`); + continue; + } + if (!processedTweets[tid] && !seenIds.has(tid)) { allFoundTweets.push(t); seenIds.add(tid); @@ -1183,60 +1241,70 @@ async function importHistory(twitterUsername: string, bskyIdentifier: string, li const activeTasks = new Map>(); async function runAccountTask(mapping: AccountMapping, forceBackfill = false, dryRun = false) { - if (activeTasks.has(mapping.id)) return; // Already running + if (activeTasks.has(mapping.id)) return; // Already running - const task = (async () => { - try { - const agent = await getAgent(mapping); - if (!agent) return; - - const backfillReq = getPendingBackfills().find(b => b.id === mapping.id); - - if (forceBackfill || backfillReq) { - const limit = backfillReq?.limit || 15; - console.log(`[${mapping.bskyIdentifier}] Running backfill for ${mapping.twitterUsernames.length} accounts (limit ${limit})...`); - - for (const twitterUsername of mapping.twitterUsernames) { - try { - updateAppStatus({ state: 'backfilling', currentAccount: twitterUsername, message: `Starting backfill (limit ${limit})...` }); - await importHistory(twitterUsername, mapping.bskyIdentifier, limit, dryRun); - } catch (err) { - console.error(`❌ Error backfilling ${twitterUsername}:`, err); - } - } - clearBackfill(mapping.id); - console.log(`[${mapping.bskyIdentifier}] Backfill complete.`); - } else { - for (const twitterUsername of mapping.twitterUsernames) { - try { - updateAppStatus({ state: 'checking', currentAccount: twitterUsername, message: 'Fetching latest tweets...' }); - const result = await safeSearch(`from:${twitterUsername}`, 30); - if (!result.success || !result.tweets) continue; - await processTweets(agent, twitterUsername, mapping.bskyIdentifier, result.tweets, dryRun); - } catch (err) { - console.error(`❌ Error checking ${twitterUsername}:`, err); - } - } - } - } catch (err) { - console.error(`Error processing mapping ${mapping.bskyIdentifier}:`, err); - } finally { - activeTasks.delete(mapping.id); + const task = (async () => { + try { + const agent = await getAgent(mapping); + if (!agent) return; + + const backfillReq = getPendingBackfills().find((b) => b.id === mapping.id); + + if (forceBackfill || backfillReq) { + const limit = backfillReq?.limit || 15; + console.log( + `[${mapping.bskyIdentifier}] Running backfill for ${mapping.twitterUsernames.length} accounts (limit ${limit})...`, + ); + + for (const twitterUsername of mapping.twitterUsernames) { + try { + updateAppStatus({ + state: 'backfilling', + currentAccount: twitterUsername, + message: `Starting backfill (limit ${limit})...`, + }); + await importHistory(twitterUsername, mapping.bskyIdentifier, limit, dryRun); + } catch (err) { + console.error(`❌ Error backfilling ${twitterUsername}:`, err); + } + } + clearBackfill(mapping.id); + console.log(`[${mapping.bskyIdentifier}] Backfill complete.`); + } else { + for (const twitterUsername of mapping.twitterUsernames) { + try { + updateAppStatus({ + state: 'checking', + currentAccount: twitterUsername, + message: 'Fetching latest tweets...', + }); + const result = await safeSearch(`from:${twitterUsername}`, 30); + if (!result.success || !result.tweets) continue; + await processTweets(agent, twitterUsername, mapping.bskyIdentifier, result.tweets, dryRun); + } catch (err) { + console.error(`❌ Error checking ${twitterUsername}:`, err); + } } - })(); + } + } catch (err) { + console.error(`Error processing mapping ${mapping.bskyIdentifier}:`, err); + } finally { + activeTasks.delete(mapping.id); + } + })(); - activeTasks.set(mapping.id, task); + activeTasks.set(mapping.id, task); } +import type { AccountMapping } from './config-manager.js'; import { - startServer, - updateLastCheckTime, - getPendingBackfills, clearBackfill, getNextCheckTime, + getPendingBackfills, + startServer, updateAppStatus, + updateLastCheckTime, } from './server.js'; -import { AccountMapping } from './config-manager.js'; async function main(): Promise { const program = new Command(); @@ -1277,7 +1345,9 @@ async function main(): Promise { console.error('Twitter credentials not set. Cannot import history.'); process.exit(1); } - const mapping = config.mappings.find(m => m.twitterUsernames.map(u => u.toLowerCase()).includes(options.username.toLowerCase())); + const mapping = config.mappings.find((m) => + m.twitterUsernames.map((u) => u.toLowerCase()).includes(options.username.toLowerCase()), + ); if (!mapping) { console.error(`No mapping found for ${options.username}`); process.exit(1); @@ -1299,29 +1369,29 @@ async function main(): Promise { const now = Date.now(); const config = getConfig(); // Reload config to get new mappings/settings const nextTime = getNextCheckTime(); - + // Check if it's time for a scheduled run OR if we have pending backfills const isScheduledRun = now >= nextTime; const pendingBackfills = getPendingBackfills(); - + if (isScheduledRun) { - console.log(`[${new Date().toISOString()}] ⏰ Scheduled check triggered.`); - updateLastCheckTime(); + console.log(`[${new Date().toISOString()}] ⏰ Scheduled check triggered.`); + updateLastCheckTime(); } for (const mapping of config.mappings) { - if (!mapping.enabled) continue; - - const hasPendingBackfill = pendingBackfills.some(b => b.id === mapping.id); - - // Run if scheduled OR backfill requested - if (isScheduledRun || hasPendingBackfill) { - runAccountTask(mapping, hasPendingBackfill, options.dryRun); - } + if (!mapping.enabled) continue; + + const hasPendingBackfill = pendingBackfills.some((b) => b.id === mapping.id); + + // Run if scheduled OR backfill requested + if (isScheduledRun || hasPendingBackfill) { + runAccountTask(mapping, hasPendingBackfill, options.dryRun); + } } - + // Sleep for 5 seconds - await new Promise(resolve => setTimeout(resolve, 5000)); + await new Promise((resolve) => setTimeout(resolve, 5000)); } } diff --git a/src/run-tests.js b/src/run-tests.js index 83d6177..900e78a 100644 --- a/src/run-tests.js +++ b/src/run-tests.js @@ -21,11 +21,14 @@ console.log('Test 1: Twitter URL Manipulation (High Quality Download)'); const url1 = 'https://pbs.twimg.com/media/ABC123.jpg'; const highQuality1 = url1.includes('?') ? url1.replace('?', ':orig?') : url1 + ':orig'; assert(highQuality1 === 'https://pbs.twimg.com/media/ABC123.jpg:orig', 'Should append :orig to plain URLs'); - + const url2 = 'https://pbs.twimg.com/media/ABC123.jpg?format=jpg&name=small'; const highQuality2 = url2.includes('?') ? url2.replace('?', ':orig?') : url2 + ':orig'; - assert(highQuality2 === 'https://pbs.twimg.com/media/ABC123.jpg:orig?format=jpg&name=small', 'Should replace ? with :orig? for query URLs'); - + assert( + highQuality2 === 'https://pbs.twimg.com/media/ABC123.jpg:orig?format=jpg&name=small', + 'Should replace ? with :orig? for query URLs', + ); + const url3 = 'https://pbs.twimg.com/media/DEF456.png?name=large'; const highQuality3 = url3.includes('?') ? url3.replace('?', ':orig?') : url3 + ':orig'; assert(highQuality3 === 'https://pbs.twimg.com/media/DEF456.png:orig?name=large', 'Should work with PNGs too'); @@ -70,7 +73,7 @@ console.log('Test 2: Text Splitting Logic'); const text2 = 'First paragraph.\n\nSecond paragraph.\n\nThird paragraph.'; const result2 = splitText(text2, 50); assert(result2.length >= 2, `Should split at paragraph breaks (got ${result2.length} chunks)`); - const allHaveContent = result2.every(c => c.length > 0); + const allHaveContent = result2.every((c) => c.length > 0); assert(allHaveContent, 'All chunks have content'); console.log(); } @@ -102,7 +105,7 @@ console.log('Test 4: Aspect Ratio Calculation'); const sizes = { large: { w: 1200, h: 800 }, medium: { w: 600, h: 400 }, - small: { w: 300, h: 200 } + small: { w: 300, h: 200 }, }; const getAspectRatio = (mediaSizes, originalInfo) => { @@ -132,7 +135,7 @@ console.log('Test 5: Video Variant Sorting (Highest Quality First)'); { content_type: 'video/mp4', url: 'low.mp4', bitrate: 500000 }, { content_type: 'video/mp4', url: 'high.mp4', bitrate: 2000000 }, { content_type: 'video/mp4', url: 'medium.mp4', bitrate: 1000000 }, - { content_type: 'audio/mp4', url: 'audio.mp4', bitrate: 128000 } + { content_type: 'audio/mp4', url: 'audio.mp4', bitrate: 128000 }, ]; const mp4s = variants @@ -196,16 +199,16 @@ console.log('Test 8: Retry Logic Simulation (High Quality -> Standard)'); const downloadWithRetry = async (url) => { callCount++; const isHighQuality = url.includes(':orig'); - + if (isHighQuality && callCount === 1) { throw new Error('High quality download failed'); } - + if (isHighQuality && callCount === 2) { const fallbackUrl = url.replace(':orig?', '?'); return { buffer: Buffer.from('standard quality'), mimeType: 'image/jpeg' }; } - + return { buffer: Buffer.from('standard quality'), mimeType: 'image/jpeg' }; }; @@ -228,7 +231,7 @@ console.log('Test 8: Retry Logic Simulation (High Quality -> Standard)'); } }; - runRetryTests().catch(err => { + runRetryTests().catch((err) => { console.log(` ✗ Retry test error: ${err.message}`); testsFailed++; }); @@ -242,7 +245,7 @@ console.log('Test 9: Image Compression Quality Settings'); jpeg: { quality: 92, mozjpeg: true }, jpegFallback: { quality: 85, mozjpeg: true }, png: { compressionLevel: 9, adaptiveFiltering: true }, - webp: { quality: 90, effort: 6 } + webp: { quality: 90, effort: 6 }, }; assert(settings.jpeg.quality === 92, 'JPEG quality is 92%'); diff --git a/src/server.ts b/src/server.ts index 73195c3..40d2eb6 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,4 +1,3 @@ -import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import bcrypt from 'bcryptjs'; @@ -35,7 +34,7 @@ interface AppStatus { let currentAppStatus: AppStatus = { state: 'idle', - lastUpdate: Date.now() + lastUpdate: Date.now(), }; app.use(cors()); @@ -119,7 +118,10 @@ app.post('/api/mappings', authenticateToken, (req, res) => { if (Array.isArray(twitterUsernames)) { usernames = twitterUsernames; } else if (typeof twitterUsernames === 'string') { - usernames = twitterUsernames.split(',').map(u => u.trim()).filter(u => u.length > 0); + usernames = twitterUsernames + .split(',') + .map((u) => u.trim()) + .filter((u) => u.length > 0); } const newMapping = { @@ -141,10 +143,10 @@ app.put('/api/mappings/:id', authenticateToken, (req, res) => { const { id } = req.params; const { twitterUsernames, bskyIdentifier, bskyPassword, bskyServiceUrl, owner } = req.body; const config = getConfig(); - + const index = config.mappings.findIndex((m) => m.id === id); const existingMapping = config.mappings[index]; - + if (index === -1 || !existingMapping) { res.status(404).json({ error: 'Mapping not found' }); return; @@ -155,7 +157,10 @@ app.put('/api/mappings/:id', authenticateToken, (req, res) => { if (Array.isArray(twitterUsernames)) { usernames = twitterUsernames; } else if (typeof twitterUsernames === 'string') { - usernames = twitterUsernames.split(',').map(u => u.trim()).filter(u => u.length > 0); + usernames = twitterUsernames + .split(',') + .map((u) => u.trim()) + .filter((u) => u.length > 0); } } @@ -164,7 +169,7 @@ app.put('/api/mappings/:id', authenticateToken, (req, res) => { twitterUsernames: usernames, bskyIdentifier: bskyIdentifier || existingMapping.bskyIdentifier, // Only update password if provided - bskyPassword: bskyPassword || existingMapping.bskyPassword, + bskyPassword: bskyPassword || existingMapping.bskyPassword, bskyServiceUrl: bskyServiceUrl || existingMapping.bskyServiceUrl, owner: owner || existingMapping.owner, }; @@ -194,7 +199,7 @@ app.delete('/api/mappings/:id/cache', authenticateToken, requireAdmin, (req, res for (const username of mapping.twitterUsernames) { dbService.deleteTweetsByUsername(username); } - + res.json({ success: true, message: 'Cache cleared for all associated accounts' }); }); @@ -214,31 +219,31 @@ app.post('/api/twitter-config', authenticateToken, requireAdmin, (req, res) => { }); app.get('/api/ai-config', authenticateToken, requireAdmin, (_req, res) => { - const config = getConfig(); - // Return legacy gemini key as part of new structure if needed - const aiConfig = config.ai || { - provider: 'gemini', - apiKey: config.geminiApiKey || '' - }; - res.json(aiConfig); + const config = getConfig(); + // Return legacy gemini key as part of new structure if needed + const aiConfig = config.ai || { + provider: 'gemini', + apiKey: config.geminiApiKey || '', + }; + res.json(aiConfig); }); - + app.post('/api/ai-config', authenticateToken, requireAdmin, (req, res) => { - const { provider, apiKey, model, baseUrl } = req.body; - const config = getConfig(); - - config.ai = { - provider, - apiKey, - model: model || undefined, - baseUrl: baseUrl || undefined - }; - - // Clear legacy key to avoid confusion - delete config.geminiApiKey; - - saveConfig(config); - res.json({ success: true }); + const { provider, apiKey, model, baseUrl } = req.body; + const config = getConfig(); + + config.ai = { + provider, + apiKey, + model: model || undefined, + baseUrl: baseUrl || undefined, + }; + + // Clear legacy key to avoid confusion + delete config.geminiApiKey; + + saveConfig(config); + res.json({ success: true }); }); // --- Status & Actions Routes --- @@ -276,7 +281,7 @@ app.post('/api/backfill/:id', authenticateToken, requireAdmin, (req, res) => { return; } - if (!pendingBackfills.find(b => b.id === id)) { + if (!pendingBackfills.find((b) => b.id === id)) { pendingBackfills.push({ id, limit: limit ? Number(limit) : undefined }); } @@ -307,7 +312,7 @@ export function updateAppStatus(status: Partial) { currentAppStatus = { ...currentAppStatus, ...status, - lastUpdate: Date.now() + lastUpdate: Date.now(), }; } @@ -333,4 +338,4 @@ export function startServer() { console.log(`🚀 Web interface running at http://localhost:${PORT}`); console.log('📡 Accessible on your local network/Tailscale via your IP.'); }); -} \ No newline at end of file +}