diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..baaa30b --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +# --- Twitter Configuration --- +# 1. Log in to x.com (RECOMMENDED: Use a separate "burner" account!) +# 2. Open Developer Tools (F12) -> Application -> Cookies +# 3. Copy the values for 'auth_token' and 'ct0' +TWITTER_AUTH_TOKEN= +TWITTER_CT0= + +# The username of the account you want to MIRROR (e.g., your main account) +# If left empty, it tries to mirror the account you logged in with. +TWITTER_TARGET_USERNAME= + +# --- Bluesky Configuration --- +BLUESKY_IDENTIFIER= +# Generate an App Password in Bluesky Settings -> Privacy & Security +BLUESKY_PASSWORD= + +# --- Optional --- +CHECK_INTERVAL_MINUTES=5 +# BLUESKY_SERVICE_URL=https://bsky.social diff --git a/.gitignore b/.gitignore index 1b4392d..da173e4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules/ +dist/ .env processed_tweets.json npm-debug.log diff --git a/README.md b/README.md index a89a4b2..3aac511 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ Most crossposters are either paid services or lack key features. This tool is de ### 1. Installation ```bash -git clone https://github.com/yourusername/tweets-2-bsky.git +git clone https://github.com/j4ckxyz/tweets-2-bsky.git cd tweets-2-bsky npm install ``` @@ -73,36 +73,61 @@ The tool will use the alt account to "view" your main account's profile and copy ### 3. Usage -**Start the Crossposter (Run 24/7):** -Checks for new tweets every 5 minutes. +**Development (with hot reload):** ```bash -node index.js +npm run dev +``` + +**Production:** +```bash +npm run build +npm start ``` **Import History:** Migrate your old tweets. This runs once and stops. ```bash -node index.js --import-history +npm run import ``` +### 4. Other Commands + +| Command | Description | +|---------|-------------| +| `npm run build` | Compile TypeScript to `dist/` | +| `npm run dev` | Run directly with tsx (no build needed) | +| `npm run lint` | Run Biome linter with auto-fix | +| `npm run format` | Format code with Biome | +| `npm run typecheck` | Type-check without emitting | + ## Running on a Server (VPS) To keep this running 24/7 on a Linux server (e.g., Ubuntu): -1. **Install PM2 (Process Manager):** +1. **Build the project:** + ```bash + npm run build + ``` +2. **Install PM2 (Process Manager):** ```bash sudo npm install -g pm2 ``` -2. **Start the tool:** +3. **Start the tool:** ```bash - pm2 start index.js --name "twitter-mirror" + pm2 start dist/index.js --name "twitter-mirror" ``` -3. **Check logs:** +4. **Check logs:** ```bash pm2 logs twitter-mirror ``` -4. **Enable startup on reboot:** +5. **Enable startup on reboot:** ```bash pm2 startup pm2 save ``` + +## Tech Stack + +- **TypeScript** – Full type safety +- **Biome** – Fast linting & formatting +- **tsx** – TypeScript execution for development diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..1bddda3 --- /dev/null +++ b/biome.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "organizeImports": { + "enabled": true + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "correctness": { + "noUnusedVariables": "warn", + "noUnusedImports": "warn" + }, + "suspicious": { + "noExplicitAny": "warn" + }, + "style": { + "useConst": "error", + "noNonNullAssertion": "warn" + } + } + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 120 + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "semicolons": "always" + } + }, + "files": { + "ignore": ["node_modules", "dist", "*.json"] + } +} diff --git a/index.js b/index.js deleted file mode 100644 index 6dab4de..0000000 --- a/index.js +++ /dev/null @@ -1,522 +0,0 @@ -require('dotenv').config(); -const { BskyAgent, RichText } = require('@atproto/api'); -const fs = require('fs'); -const path = require('path'); -const axios = require('axios'); -const cron = require('node-cron'); -const { TwitterClient } = require('@steipete/bird/dist/lib/twitter-client'); -const franc = require('franc-min'); -const iso6391 = require('iso-639-1'); -const { exec } = require('child_process'); - -// Configuration -const TWITTER_AUTH_TOKEN = process.env.TWITTER_AUTH_TOKEN; -const TWITTER_CT0 = process.env.TWITTER_CT0; -const TWITTER_TARGET_USERNAME = process.env.TWITTER_TARGET_USERNAME; // Optional target -const BLUESKY_IDENTIFIER = process.env.BLUESKY_IDENTIFIER; -const BLUESKY_PASSWORD = process.env.BLUESKY_PASSWORD; -const BLUESKY_SERVICE_URL = process.env.BLUESKY_SERVICE_URL || 'https://bsky.social'; -const CHECK_INTERVAL_MINUTES = process.env.CHECK_INTERVAL_MINUTES || 5; -const PROCESSED_TWEETS_FILE = path.join(__dirname, 'processed_tweets.json'); - -// State Management -// Format: { "twitter_id": { uri: "bsky_uri", cid: "bsky_cid", root: { uri, cid } } } -let processedTweets = {}; - -function loadProcessedTweets() { - try { - if (fs.existsSync(PROCESSED_TWEETS_FILE)) { - const raw = JSON.parse(fs.readFileSync(PROCESSED_TWEETS_FILE, 'utf8')); - if (Array.isArray(raw)) { - // Migration from v1 (Array of IDs) to v2 (Object map) - console.log("Migrating processed_tweets.json from v1 to v2..."); - processedTweets = raw.reduce((acc, id) => { - acc[id] = { migrated: true }; // Marker for old tweets (can't reply to them easily) - return acc; - }, {}); - saveProcessedTweets(); - } else { - processedTweets = raw; - } - } - } catch (err) { - console.error('Error loading processed tweets:', err); - } -} - -function saveProcessedTweets() { - try { - fs.writeFileSync(PROCESSED_TWEETS_FILE, JSON.stringify(processedTweets, null, 2)); - } catch (err) { - console.error('Error saving processed tweets:', err); - } -} - -loadProcessedTweets(); - -// Bluesky Agent -const agent = new BskyAgent({ - service: BLUESKY_SERVICE_URL, -}); - -// Custom Twitter Client -class CustomTwitterClient extends TwitterClient { - mapTweetResult(result) { - const mapped = super.mapTweetResult(result); - if (mapped && result.legacy) { - mapped.entities = result.legacy.entities; - mapped.extended_entities = result.legacy.extended_entities; - mapped.quoted_status_id_str = result.legacy.quoted_status_id_str; - mapped.is_quote_status = result.legacy.is_quote_status; - 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; - } - return mapped; - } -} - -const twitter = new CustomTwitterClient({ - cookies: { - authToken: TWITTER_AUTH_TOKEN, - ct0: TWITTER_CT0 - } -}); - -// --- Helper Functions --- - -function detectLanguage(text) { - if (!text || text.trim().length === 0) return ['en']; - try { - const code3 = franc(text); - if (code3 === 'und') return ['en']; // Undetermined - const code2 = iso6391.getCode(code3); - return code2 ? [code2] : ['en']; - } catch (e) { - return ['en']; - } -} - -async function expandUrl(shortUrl) { - try { - const response = await axios.head(shortUrl, { - maxRedirects: 10, - validateStatus: (status) => status >= 200 && status < 400 - }); - return response.request.res.responseUrl || shortUrl; - } catch (err) { - try { - const response = await axios.get(shortUrl, { - responseType: 'stream', - maxRedirects: 10 - }); - response.data.destroy(); - return response.request.res.responseUrl || shortUrl; - } catch (e) { - // console.warn(`Failed to expand URL ${shortUrl}:`, e.message); - return shortUrl; - } - } -} - -async function downloadMedia(url) { - const response = await axios({ - url, - method: 'GET', - responseType: 'arraybuffer' - }); - return { - buffer: Buffer.from(response.data), - mimeType: response.headers['content-type'] - }; -} - -async function uploadToBluesky(buffer, mimeType) { - const { data } = await agent.uploadBlob(buffer, { encoding: mimeType }); - return data.blob; -} - -async function getUsername() { - if (TWITTER_TARGET_USERNAME) return TWITTER_TARGET_USERNAME; - try { - const res = await twitter.getCurrentUser(); - if (res.success && res.user) { - return res.user.username; - } - } catch (e) { - console.warn("Failed to get 'whoami'. defaulting to 'me'.", e.message); - } - return "me"; -} - -function getRandomDelay(min = 1000, max = 4000) { - return Math.floor(Math.random() * (max - min + 1) + min); -} - -function refreshQueryIds() { - return new Promise((resolve) => { - console.log("⚠️ Attempting to refresh Twitter Query IDs via 'bird' CLI..."); - exec('./node_modules/.bin/bird query-ids --fresh', (error, stdout, stderr) => { - if (error) { - console.error(`Error refreshing IDs: ${error.message}`); - console.error(`Stderr: ${stderr}`); - } else { - console.log("✅ Query IDs refreshed successfully."); - } - resolve(); - }); - }); -} - -/** - * Wraps twitter.search with auto-recovery for stale Query IDs - */ -async function safeSearch(query, limit) { - try { - const result = await twitter.search(query, limit); - // Sometimes it returns success: false but no throw - if (!result.success && result.error && - (result.error.toString().includes('GraphQL') || result.error.toString().includes('404'))) { - throw new Error(result.error); - } - return result; - } catch (err) { - console.warn(`Search encountered an error: ${err.message || err}`); - if (err.message && (err.message.includes('GraphQL') || err.message.includes('404') || err.message.includes('Bad Guest Token'))) { - await refreshQueryIds(); - console.log("Retrying search..."); - return await twitter.search(query, limit); - } - return { success: false, error: err }; - } -} - -// --- Main Processing Logic --- - -async function processTweets(tweets) { - // Ensure chronological order - tweets.reverse(); - - for (const tweet of tweets) { - const tweetId = tweet.id_str || tweet.id; - if (processedTweets[tweetId]) { - continue; - } - - // --- Filter Replies (unless we are maintaining a thread) --- - // If it's a reply, but the parent IS in our DB, we want to post it as a reply. - // If it's a reply to someone else (or a thread we missed), we skip it based on user preference (only original tweets). - - const replyStatusId = tweet.in_reply_to_status_id_str || tweet.in_reply_to_status_id; - const replyUserId = tweet.in_reply_to_user_id_str || tweet.in_reply_to_user_id; - const isReply = !!replyStatusId || !!replyUserId || (tweet.full_text || tweet.text || "").trim().startsWith('@'); - - let replyParentInfo = null; - - if (isReply) { - if (replyStatusId && processedTweets[replyStatusId] && !processedTweets[replyStatusId].migrated) { - // We have the parent! We can thread this. - console.log(`Threading reply to ${replyStatusId}`); - replyParentInfo = processedTweets[replyStatusId]; - } else { - // Reply to unknown or external -> Skip - console.log(`Skipping reply: ${tweetId}`); - processedTweets[tweetId] = { skipped: true }; // Mark as skipped - saveProcessedTweets(); - continue; - } - } - - console.log(`Processing tweet: ${tweetId}`); - - let text = tweet.full_text || tweet.text || ""; - - // --- 1. Link Expansion --- - const urls = tweet.entities?.urls || []; - for (const urlEntity of urls) { - const tco = urlEntity.url; - const expanded = urlEntity.expanded_url; - if (tco && expanded) { - text = text.replace(tco, expanded); - } - } - - // Manual cleanup of remaining t.co - const tcoRegex = /https:\/\/t\.co\/[a-zA-Z0-9]+/g; - const matches = text.match(tcoRegex) || []; - for (const tco of matches) { - const resolved = await expandUrl(tco); - if (resolved !== tco) { - text = text.replace(tco, resolved); - } - } - - // --- 2. Media Handling --- - let images = []; - let videoBlob = null; - let videoAspectRatio = null; - - const mediaEntities = tweet.extended_entities?.media || tweet.entities?.media || []; - let mediaLinksToRemove = []; - - for (const media of mediaEntities) { - if (media.url) { - mediaLinksToRemove.push(media.url); - if (media.expanded_url) mediaLinksToRemove.push(media.expanded_url); - } - - // Aspect Ratio Extraction - let aspectRatio = undefined; - if (media.sizes?.large) { - aspectRatio = { width: media.sizes.large.w, height: media.sizes.large.h }; - } else if (media.original_info) { - aspectRatio = { width: media.original_info.width, height: media.original_info.height }; - } - - if (media.type === 'photo') { - const url = media.media_url_https; - try { - const { buffer, mimeType } = await downloadMedia(url); - const blob = await uploadToBluesky(buffer, mimeType); - images.push({ - alt: media.ext_alt_text || "Image from Twitter", - image: blob, - aspectRatio: aspectRatio - }); - } catch (err) { - console.error(`Failed to upload image ${url}:`, err.message); - } - } else if (media.type === 'video' || media.type === 'animated_gif') { - const variants = media.video_info?.variants || []; - const mp4s = variants.filter(v => v.content_type === 'video/mp4').sort((a, b) => (b.bitrate || 0) - (a.bitrate || 0)); - - if (mp4s.length > 0) { - const videoUrl = mp4s[0].url; - try { - const { buffer, mimeType } = await downloadMedia(videoUrl); - - if (buffer.length > 95 * 1024 * 1024) { - console.warn("Video too large (>95MB). Linking instead."); - text += `\n[Video: ${media.media_url_https}]`; - continue; - } - - const blob = await uploadToBluesky(buffer, mimeType); - videoBlob = blob; - videoAspectRatio = aspectRatio; - break; - } catch (err) { - console.error(`Failed to upload video ${videoUrl}:`, err.message); - text += `\n${media.media_url_https}`; - } - } - } - } - - // Remove media links from text - for (const link of mediaLinksToRemove) { - text = text.split(link).join('').trim(); - } - text = text.replace(/\n\s*\n/g, '\n\n').trim(); - - // --- 3. Quoting Logic --- - let quoteEmbed = null; - if (tweet.is_quote_status && tweet.quoted_status_id_str) { - const quoteId = tweet.quoted_status_id_str; - if (processedTweets[quoteId] && !processedTweets[quoteId].migrated) { - const ref = processedTweets[quoteId]; - quoteEmbed = { - $type: 'app.bsky.embed.record', - record: { - uri: ref.uri, - cid: ref.cid - } - }; - } - } - - // --- 4. Construct Post --- - const rt = new RichText({ text }); - await rt.detectFacets(agent); - const detectedLangs = detectLanguage(text); - - const postRecord = { - text: rt.text, - facets: rt.facets, - langs: detectedLangs, - createdAt: tweet.created_at ? new Date(tweet.created_at).toISOString() : new Date().toISOString() - }; - - // Attach Embeds - if (videoBlob) { - postRecord.embed = { - $type: 'app.bsky.embed.video', - video: videoBlob, - aspectRatio: videoAspectRatio - }; - } else if (images.length > 0) { - const imagesEmbed = { - $type: 'app.bsky.embed.images', - images: images - }; - - if (quoteEmbed) { - postRecord.embed = { - $type: 'app.bsky.embed.recordWithMedia', - media: imagesEmbed, - record: quoteEmbed - }; - } else { - postRecord.embed = imagesEmbed; - } - } else if (quoteEmbed) { - postRecord.embed = quoteEmbed; - } - - // Attach Reply info - if (replyParentInfo) { - postRecord.reply = { - root: replyParentInfo.root || { uri: replyParentInfo.uri, cid: replyParentInfo.cid }, - parent: { uri: replyParentInfo.uri, cid: replyParentInfo.cid } - }; - } - - // --- 5. Post & Save --- - try { - const response = await agent.post(postRecord); - // console.log(`Posted: ${tweetId}`); - - const newEntry = { - uri: response.uri, - cid: response.cid, - root: postRecord.reply ? postRecord.reply.root : { uri: response.uri, cid: response.cid } - }; - - processedTweets[tweetId] = newEntry; - saveProcessedTweets(); - - // Random Pacing (1s - 4s) - const sleepTime = getRandomDelay(1000, 4000); - // console.log(`Sleeping ${sleepTime}ms...`); - await new Promise(r => setTimeout(r, sleepTime)); - - } catch (err) { - console.error(`Failed to post ${tweetId}:`, err); - } - } -} - -async function checkAndPost() { - console.log(`[${new Date().toISOString()}] Checking...`); - - try { - const username = await getUsername(); - - // Use safeSearch with auto-refresh for IDs - const query = `from:${username}`; - const result = await safeSearch(query, 30); - - if (!result.success) { - console.error("Failed to fetch tweets:", result.error); - return; - } - - const tweets = result.tweets || []; - if (tweets.length === 0) return; - - await processTweets(tweets); - - } catch (err) { - console.error("Error in checkAndPost:", err); - } -} - -async function importHistory() { - console.log("Starting full history import..."); - const username = await getUsername(); - console.log(`Importing history for: ${username}`); - - let maxId = null; - let keepGoing = true; - const count = 100; - let allFoundTweets = []; - const seenIds = new Set(); - - while (keepGoing) { - let query = `from:${username}`; - if (maxId) { - query += ` max_id:${maxId}`; - } - - console.log(`Fetching batch... (Collected: ${allFoundTweets.length})`); - - const result = await safeSearch(query, count); - - if (!result.success) { - console.error("Fetch failed:", result.error); - break; - } - - const tweets = result.tweets || []; - if (tweets.length === 0) break; - - let newOnes = 0; - for (const t of tweets) { - const tid = t.id_str || t.id; - if (!processedTweets[tid] && !seenIds.has(tid)) { - allFoundTweets.push(t); - seenIds.add(tid); - newOnes++; - } - } - - if (newOnes === 0 && tweets.length > 0) { - const lastId = tweets[tweets.length - 1].id_str || tweets[tweets.length - 1].id; - if (lastId === maxId) break; - } - - const lastTweet = tweets[tweets.length - 1]; - maxId = lastTweet.id_str || lastTweet.id; - - // Rate limit protection - await new Promise(r => setTimeout(r, 2000)); - } - - console.log(`Fetch complete. Found ${allFoundTweets.length} new tweets to import.`); - - if (allFoundTweets.length > 0) { - console.log("Starting processing (Oldest -> Newest) with random pacing..."); - await processTweets(allFoundTweets); - console.log("History import complete."); - } else { - console.log("Nothing new to import."); - } -} - -// Start -(async () => { - if (!TWITTER_AUTH_TOKEN || !TWITTER_CT0 || !BLUESKY_IDENTIFIER || !BLUESKY_PASSWORD) { - console.error("Missing credentials in .env file."); - process.exit(1); - } - - try { - await agent.login({ identifier: BLUESKY_IDENTIFIER, password: BLUESKY_PASSWORD }); - console.log("Logged in to Bluesky."); - } catch (err) { - console.error("Failed to login to Bluesky:", err); - process.exit(1); - } - - if (process.argv.includes('--import-history')) { - await importHistory(); - process.exit(0); - } - - // Refresh IDs on startup just to be safe/fresh - // await refreshQueryIds(); - - await checkAndPost(); - - console.log(`Scheduling check every ${CHECK_INTERVAL_MINUTES} minutes.`); - cron.schedule(`*/${CHECK_INTERVAL_MINUTES} * * * *`, checkAndPost); -})(); \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 220e49a..d6f01e2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "tweets-2-bsky", - "version": "1.0.1", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tweets-2-bsky", - "version": "1.0.1", + "version": "2.0.0", "license": "MIT", "dependencies": { "@atproto/api": "^0.18.9", @@ -14,8 +14,14 @@ "axios": "^1.13.2", "dotenv": "^17.2.3", "franc-min": "^6.2.0", - "iso-639-1": "^3.1.5", + "iso-639-1": "^3.1.2", "node-cron": "^4.2.1" + }, + "devDependencies": { + "@biomejs/biome": "^1.9.4", + "@types/node": "^22.10.2", + "tsx": "^4.19.2", + "typescript": "^5.7.2" } }, "node_modules/@atproto/api": { @@ -97,6 +103,612 @@ "zod": "^3.23.8" } }, + "node_modules/@biomejs/biome": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-1.9.4.tgz", + "integrity": "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog==", + "dev": true, + "hasInstallScript": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "1.9.4", + "@biomejs/cli-darwin-x64": "1.9.4", + "@biomejs/cli-linux-arm64": "1.9.4", + "@biomejs/cli-linux-arm64-musl": "1.9.4", + "@biomejs/cli-linux-x64": "1.9.4", + "@biomejs/cli-linux-x64-musl": "1.9.4", + "@biomejs/cli-win32-arm64": "1.9.4", + "@biomejs/cli-win32-x64": "1.9.4" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-1.9.4.tgz", + "integrity": "sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-1.9.4.tgz", + "integrity": "sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-1.9.4.tgz", + "integrity": "sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-1.9.4.tgz", + "integrity": "sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-1.9.4.tgz", + "integrity": "sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-1.9.4.tgz", + "integrity": "sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-1.9.4.tgz", + "integrity": "sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-1.9.4.tgz", + "integrity": "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@steipete/bird": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@steipete/bird/-/bird-0.4.0.tgz", @@ -113,6 +725,16 @@ "node": ">=20" } }, + "node_modules/@types/node": { + "version": "22.19.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz", + "integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -260,6 +882,48 @@ "node": ">= 0.4" } }, + "node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, "node_modules/follow-redirects": { "version": "1.15.11", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", @@ -309,6 +973,21 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -355,6 +1034,19 @@ "node": ">= 0.4" } }, + "node_modules/get-tsconfig": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", + "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -503,6 +1195,16 @@ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "license": "MIT" }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/tlds": { "version": "1.261.0", "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.261.0.tgz", @@ -532,6 +1234,40 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/uint8arrays": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-3.0.0.tgz", @@ -541,6 +1277,13 @@ "multiformats": "^9.4.2" } }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, "node_modules/unicode-segmenter": { "version": "0.14.5", "resolved": "https://registry.npmjs.org/unicode-segmenter/-/unicode-segmenter-0.14.5.tgz", diff --git a/package.json b/package.json index 8252e63..baf684d 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,17 @@ { "name": "tweets-2-bsky", - "version": "1.0.3", + "version": "2.0.0", "description": "A powerful tool to crosspost Tweets to Bluesky, supporting threads, videos, and high-quality images.", - "main": "index.js", + "type": "module", + "main": "dist/index.js", "scripts": { - "start": "node index.js", - "import": "node index.js --import-history" + "build": "tsc", + "start": "node dist/index.js", + "dev": "tsx src/index.ts", + "import": "tsx src/index.ts --import-history", + "lint": "biome check --write .", + "format": "biome format --write .", + "typecheck": "tsc --noEmit" }, "keywords": [ "bluesky", @@ -25,5 +31,11 @@ "franc-min": "^6.2.0", "iso-639-1": "^3.1.2", "node-cron": "^4.2.1" + }, + "devDependencies": { + "@biomejs/biome": "^1.9.4", + "@types/node": "^22.10.2", + "tsx": "^4.19.2", + "typescript": "^5.7.2" } -} +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..cb5bd1c --- /dev/null +++ b/src/index.ts @@ -0,0 +1,670 @@ +import 'dotenv/config'; +import { exec } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { BskyAgent, RichText } from '@atproto/api'; +import type { BlobRef } from '@atproto/api'; +import { TwitterClient } from '@steipete/bird/dist/lib/twitter-client'; +import axios from 'axios'; +import * as francModule from 'franc-min'; +import iso6391 from 'iso-639-1'; +import cron from 'node-cron'; + +// ESM __dirname equivalent +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// ============================================================================ +// Type Definitions +// ============================================================================ + +interface ProcessedTweetEntry { + uri?: string; + cid?: string; + root?: { uri: string; cid: string }; + migrated?: boolean; + skipped?: boolean; +} + +interface ProcessedTweetsMap { + [twitterId: string]: ProcessedTweetEntry; +} + +interface UrlEntity { + url?: string; + expanded_url?: string; +} + +interface MediaSize { + w: number; + h: number; +} + +interface MediaSizes { + large?: MediaSize; +} + +interface OriginalInfo { + width: number; + height: number; +} + +interface VideoVariant { + content_type: string; + url: string; + bitrate?: number; +} + +interface VideoInfo { + variants?: VideoVariant[]; +} + +interface MediaEntity { + url?: string; + expanded_url?: string; + media_url_https?: string; + type?: 'photo' | 'video' | 'animated_gif'; + ext_alt_text?: string; + sizes?: MediaSizes; + original_info?: OriginalInfo; + video_info?: VideoInfo; +} + +interface TweetEntities { + urls?: UrlEntity[]; + media?: MediaEntity[]; +} + +interface Tweet { + id?: string; + id_str?: string; + text?: string; + full_text?: string; + created_at?: string; + entities?: TweetEntities; + extended_entities?: TweetEntities; + quoted_status_id_str?: string; + is_quote_status?: boolean; + in_reply_to_status_id_str?: string; + in_reply_to_status_id?: string; + in_reply_to_user_id_str?: string; + in_reply_to_user_id?: string; +} + +interface TwitterSearchResult { + success: boolean; + tweets?: Tweet[]; + error?: Error | string; +} + +interface TwitterUserResult { + success: boolean; + user?: { username: string }; +} + +interface AspectRatio { + width: number; + height: number; +} + +interface ImageEmbed { + alt: string; + image: BlobRef; + aspectRatio?: AspectRatio; +} + +// PostRecord is built dynamically for agent.post() + +// ============================================================================ +// Configuration +// ============================================================================ + +const TWITTER_AUTH_TOKEN = process.env.TWITTER_AUTH_TOKEN; +const TWITTER_CT0 = process.env.TWITTER_CT0; +const TWITTER_TARGET_USERNAME = process.env.TWITTER_TARGET_USERNAME; +const BLUESKY_IDENTIFIER = process.env.BLUESKY_IDENTIFIER; +const BLUESKY_PASSWORD = process.env.BLUESKY_PASSWORD; +const BLUESKY_SERVICE_URL = process.env.BLUESKY_SERVICE_URL || 'https://bsky.social'; +const CHECK_INTERVAL_MINUTES = Number(process.env.CHECK_INTERVAL_MINUTES) || 5; +const PROCESSED_TWEETS_FILE = path.join(__dirname, '..', 'processed_tweets.json'); + +// ============================================================================ +// State Management +// ============================================================================ + +let processedTweets: ProcessedTweetsMap = {}; + +function loadProcessedTweets(): void { + try { + if (fs.existsSync(PROCESSED_TWEETS_FILE)) { + const raw: unknown = JSON.parse(fs.readFileSync(PROCESSED_TWEETS_FILE, 'utf8')); + if (Array.isArray(raw)) { + // Migration from v1 (Array of IDs) to v2 (Object map) + console.log('Migrating processed_tweets.json from v1 to v2...'); + processedTweets = (raw as string[]).reduce((acc, id) => { + acc[id] = { migrated: true }; + return acc; + }, {}); + saveProcessedTweets(); + } else if (typeof raw === 'object' && raw !== null) { + processedTweets = raw as ProcessedTweetsMap; + } + } + } catch (err) { + console.error('Error loading processed tweets:', err); + } +} + +function saveProcessedTweets(): void { + try { + fs.writeFileSync(PROCESSED_TWEETS_FILE, JSON.stringify(processedTweets, null, 2)); + } catch (err) { + console.error('Error saving processed tweets:', err); + } +} + +loadProcessedTweets(); + +// ============================================================================ +// Bluesky Agent +// ============================================================================ + +const agent = new BskyAgent({ + service: BLUESKY_SERVICE_URL, +}); + +// ============================================================================ +// Custom Twitter Client +// ============================================================================ + +interface TwitterLegacyResult { + legacy?: { + entities?: TweetEntities; + extended_entities?: TweetEntities; + quoted_status_id_str?: string; + is_quote_status?: boolean; + in_reply_to_status_id_str?: string; + in_reply_to_user_id_str?: string; + }; +} + +class CustomTwitterClient extends TwitterClient { + mapTweetResult(result: TwitterLegacyResult): Tweet | null { + // biome-ignore lint/suspicious/noExplicitAny: parent class is untyped + const mapped = (super.mapTweetResult as any)(result) as Tweet | null; + if (mapped && result.legacy) { + mapped.entities = result.legacy.entities; + mapped.extended_entities = result.legacy.extended_entities; + mapped.quoted_status_id_str = result.legacy.quoted_status_id_str; + mapped.is_quote_status = result.legacy.is_quote_status; + 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; + } + return mapped; + } +} + +const twitter = new CustomTwitterClient({ + cookies: { + authToken: TWITTER_AUTH_TOKEN ?? '', + ct0: TWITTER_CT0 ?? '', + }, +}); + +// ============================================================================ +// Helper Functions +// ============================================================================ + +function detectLanguage(text: string): string[] { + if (!text || text.trim().length === 0) return ['en']; + try { + const code3 = (francModule as unknown as (text: string) => string)(text); + if (code3 === 'und') return ['en']; + const code2 = iso6391.getCode(code3); + return code2 ? [code2] : ['en']; + } catch { + return ['en']; + } +} + +async function expandUrl(shortUrl: string): Promise { + try { + const response = await axios.head(shortUrl, { + maxRedirects: 10, + validateStatus: (status) => status >= 200 && status < 400, + }); + // biome-ignore lint/suspicious/noExplicitAny: axios internal types + return (response.request as any)?.res?.responseUrl || shortUrl; + } catch { + try { + const response = await axios.get(shortUrl, { + responseType: 'stream', + maxRedirects: 10, + }); + response.data.destroy(); + // biome-ignore lint/suspicious/noExplicitAny: axios internal types + return (response.request as any)?.res?.responseUrl || shortUrl; + } catch { + return shortUrl; + } + } +} + +interface DownloadedMedia { + buffer: Buffer; + mimeType: string; +} + +async function downloadMedia(url: string): Promise { + const response = await axios({ + url, + method: 'GET', + responseType: 'arraybuffer', + }); + return { + buffer: Buffer.from(response.data as ArrayBuffer), + mimeType: (response.headers['content-type'] as string) || 'application/octet-stream', + }; +} + +async function uploadToBluesky(buffer: Buffer, mimeType: string): Promise { + const { data } = await agent.uploadBlob(buffer, { encoding: mimeType }); + return data.blob; +} + +async function getUsername(): Promise { + if (TWITTER_TARGET_USERNAME) return TWITTER_TARGET_USERNAME; + try { + const res = (await twitter.getCurrentUser()) as TwitterUserResult; + if (res.success && res.user) { + return res.user.username; + } + } catch (e) { + console.warn("Failed to get 'whoami'. defaulting to 'me'.", (e as Error).message); + } + return 'me'; +} + +function getRandomDelay(min = 1000, max = 4000): number { + return Math.floor(Math.random() * (max - min + 1) + min); +} + +function refreshQueryIds(): Promise { + return new Promise((resolve) => { + console.log("⚠️ Attempting to refresh Twitter Query IDs via 'bird' CLI..."); + exec('./node_modules/.bin/bird query-ids --fresh', (error, _stdout, stderr) => { + if (error) { + console.error(`Error refreshing IDs: ${error.message}`); + console.error(`Stderr: ${stderr}`); + } else { + console.log('✅ Query IDs refreshed successfully.'); + } + resolve(); + }); + }); +} + +/** + * Wraps twitter.search with auto-recovery for stale Query IDs + */ +async function safeSearch(query: string, limit: number): Promise { + try { + const result = (await twitter.search(query, limit)) as TwitterSearchResult; + if (!result.success && result.error) { + const errorStr = result.error.toString(); + if (errorStr.includes('GraphQL') || errorStr.includes('404')) { + throw new Error(errorStr); + } + } + return result; + } catch (err) { + const error = err as Error; + console.warn(`Search encountered an error: ${error.message || err}`); + if ( + error.message && + (error.message.includes('GraphQL') || error.message.includes('404') || error.message.includes('Bad Guest Token')) + ) { + await refreshQueryIds(); + console.log('Retrying search...'); + return (await twitter.search(query, limit)) as TwitterSearchResult; + } + return { success: false, error }; + } +} + +// ============================================================================ +// Main Processing Logic +// ============================================================================ + +async function processTweets(tweets: Tweet[]): Promise { + // Ensure chronological order + tweets.reverse(); + + for (const tweet of tweets) { + const tweetId = tweet.id_str || tweet.id; + if (!tweetId) continue; + + if (processedTweets[tweetId]) { + continue; + } + + // --- Filter Replies (unless we are maintaining a thread) --- + const replyStatusId = tweet.in_reply_to_status_id_str || tweet.in_reply_to_status_id; + const replyUserId = tweet.in_reply_to_user_id_str || tweet.in_reply_to_user_id; + const tweetText = tweet.full_text || tweet.text || ''; + const isReply = !!replyStatusId || !!replyUserId || tweetText.trim().startsWith('@'); + + let replyParentInfo: ProcessedTweetEntry | null = null; + + if (isReply) { + if (replyStatusId && processedTweets[replyStatusId] && !processedTweets[replyStatusId]?.migrated) { + console.log(`Threading reply to ${replyStatusId}`); + replyParentInfo = processedTweets[replyStatusId] ?? null; + } else { + console.log(`Skipping reply: ${tweetId}`); + processedTweets[tweetId] = { skipped: true }; + saveProcessedTweets(); + continue; + } + } + + console.log(`Processing tweet: ${tweetId}`); + + let text = tweetText; + + // --- 1. Link Expansion --- + const urls = tweet.entities?.urls || []; + for (const urlEntity of urls) { + const tco = urlEntity.url; + const expanded = urlEntity.expanded_url; + if (tco && expanded) { + text = text.replace(tco, expanded); + } + } + + // Manual cleanup of remaining t.co + const tcoRegex = /https:\/\/t\.co\/[a-zA-Z0-9]+/g; + const matches = text.match(tcoRegex) || []; + for (const tco of matches) { + const resolved = await expandUrl(tco); + if (resolved !== tco) { + text = text.replace(tco, resolved); + } + } + + // --- 2. Media Handling --- + const images: ImageEmbed[] = []; + let videoBlob: BlobRef | null = null; + let videoAspectRatio: AspectRatio | undefined; + + const mediaEntities = tweet.extended_entities?.media || tweet.entities?.media || []; + const mediaLinksToRemove: string[] = []; + + for (const media of mediaEntities) { + if (media.url) { + mediaLinksToRemove.push(media.url); + if (media.expanded_url) mediaLinksToRemove.push(media.expanded_url); + } + + // Aspect Ratio Extraction + let aspectRatio: AspectRatio | undefined; + if (media.sizes?.large) { + aspectRatio = { width: media.sizes.large.w, height: media.sizes.large.h }; + } else if (media.original_info) { + aspectRatio = { width: media.original_info.width, height: media.original_info.height }; + } + + if (media.type === 'photo') { + const url = media.media_url_https; + if (!url) continue; + try { + const { buffer, mimeType } = await downloadMedia(url); + const blob = await uploadToBluesky(buffer, mimeType); + images.push({ + alt: media.ext_alt_text || 'Image from Twitter', + image: blob, + aspectRatio, + }); + } catch (err) { + console.error(`Failed to upload image ${url}:`, (err as Error).message); + } + } else if (media.type === 'video' || media.type === 'animated_gif') { + const variants = media.video_info?.variants || []; + const mp4s = variants + .filter((v) => v.content_type === 'video/mp4') + .sort((a, b) => (b.bitrate || 0) - (a.bitrate || 0)); + + if (mp4s.length > 0 && mp4s[0]) { + const videoUrl = mp4s[0].url; + try { + const { buffer, mimeType } = await downloadMedia(videoUrl); + + if (buffer.length > 95 * 1024 * 1024) { + console.warn('Video too large (>95MB). Linking instead.'); + text += `\n[Video: ${media.media_url_https}]`; + continue; + } + + const blob = await uploadToBluesky(buffer, mimeType); + videoBlob = blob; + videoAspectRatio = aspectRatio; + break; + } catch (err) { + console.error(`Failed to upload video ${videoUrl}:`, (err as Error).message); + text += `\n${media.media_url_https}`; + } + } + } + } + + // Remove media links from text + for (const link of mediaLinksToRemove) { + text = text.split(link).join('').trim(); + } + text = text.replace(/\n\s*\n/g, '\n\n').trim(); + + // --- 3. Quoting Logic --- + let quoteEmbed: { $type: string; record: { uri: string; cid: string } } | null = null; + if (tweet.is_quote_status && tweet.quoted_status_id_str) { + const quoteId = tweet.quoted_status_id_str; + const quoteRef = processedTweets[quoteId]; + if (quoteRef && !quoteRef.migrated && quoteRef.uri && quoteRef.cid) { + quoteEmbed = { + $type: 'app.bsky.embed.record', + record: { + uri: quoteRef.uri, + cid: quoteRef.cid, + }, + }; + } + } + + // --- 4. Construct Post --- + const rt = new RichText({ text }); + await rt.detectFacets(agent); + const detectedLangs = detectLanguage(text); + + // biome-ignore lint/suspicious/noExplicitAny: dynamic record construction + const postRecord: Record = { + text: rt.text, + facets: rt.facets, + langs: detectedLangs, + createdAt: tweet.created_at ? new Date(tweet.created_at).toISOString() : new Date().toISOString(), + }; + + // Attach Embeds + if (videoBlob) { + postRecord.embed = { + $type: 'app.bsky.embed.video', + video: videoBlob, + aspectRatio: videoAspectRatio, + }; + } else if (images.length > 0) { + const imagesEmbed = { + $type: 'app.bsky.embed.images', + images, + }; + + if (quoteEmbed) { + postRecord.embed = { + $type: 'app.bsky.embed.recordWithMedia', + media: imagesEmbed, + record: quoteEmbed, + }; + } else { + postRecord.embed = imagesEmbed; + } + } else if (quoteEmbed) { + postRecord.embed = quoteEmbed; + } + + // Attach Reply info + if (replyParentInfo?.uri && replyParentInfo?.cid) { + postRecord.reply = { + root: replyParentInfo.root || { uri: replyParentInfo.uri, cid: replyParentInfo.cid }, + parent: { uri: replyParentInfo.uri, cid: replyParentInfo.cid }, + }; + } + + // --- 5. Post & Save --- + try { + const response = await agent.post(postRecord); + + const newEntry: ProcessedTweetEntry = { + uri: response.uri, + cid: response.cid, + root: postRecord.reply ? postRecord.reply.root : { uri: response.uri, cid: response.cid }, + }; + + processedTweets[tweetId] = newEntry; + saveProcessedTweets(); + + // Random Pacing (1s - 4s) + const sleepTime = getRandomDelay(1000, 4000); + await new Promise((r) => setTimeout(r, sleepTime)); + } catch (err) { + console.error(`Failed to post ${tweetId}:`, err); + } + } +} + +async function checkAndPost(): Promise { + console.log(`[${new Date().toISOString()}] Checking...`); + + try { + const username = await getUsername(); + + const query = `from:${username}`; + const result = await safeSearch(query, 30); + + if (!result.success) { + console.error('Failed to fetch tweets:', result.error); + return; + } + + const tweets = result.tweets || []; + if (tweets.length === 0) return; + + await processTweets(tweets); + } catch (err) { + console.error('Error in checkAndPost:', err); + } +} + +async function importHistory(): Promise { + console.log('Starting full history import...'); + const username = await getUsername(); + console.log(`Importing history for: ${username}`); + + let maxId: string | null = null; + const keepGoing = true; + const count = 100; + const allFoundTweets: Tweet[] = []; + const seenIds = new Set(); + + while (keepGoing) { + let query = `from:${username}`; + if (maxId) { + query += ` max_id:${maxId}`; + } + + console.log(`Fetching batch... (Collected: ${allFoundTweets.length})`); + + const result = await safeSearch(query, count); + + if (!result.success) { + console.error('Fetch failed:', result.error); + break; + } + + const tweets = result.tweets || []; + if (tweets.length === 0) break; + + let newOnes = 0; + for (const t of tweets) { + const tid = t.id_str || t.id; + if (!tid) continue; + if (!processedTweets[tid] && !seenIds.has(tid)) { + allFoundTweets.push(t); + seenIds.add(tid); + newOnes++; + } + } + + if (newOnes === 0 && tweets.length > 0) { + const lastTweet = tweets[tweets.length - 1]; + const lastId = lastTweet?.id_str || lastTweet?.id; + if (lastId === maxId) break; + } + + const lastTweet = tweets[tweets.length - 1]; + maxId = lastTweet?.id_str || lastTweet?.id || null; + + // Rate limit protection + await new Promise((r) => setTimeout(r, 2000)); + } + + console.log(`Fetch complete. Found ${allFoundTweets.length} new tweets to import.`); + + if (allFoundTweets.length > 0) { + console.log('Starting processing (Oldest -> Newest) with random pacing...'); + await processTweets(allFoundTweets); + console.log('History import complete.'); + } else { + console.log('Nothing new to import.'); + } +} + +// ============================================================================ +// Entry Point +// ============================================================================ + +async function main(): Promise { + if (!TWITTER_AUTH_TOKEN || !TWITTER_CT0 || !BLUESKY_IDENTIFIER || !BLUESKY_PASSWORD) { + console.error('Missing credentials in .env file.'); + process.exit(1); + } + + try { + await agent.login({ identifier: BLUESKY_IDENTIFIER, password: BLUESKY_PASSWORD }); + console.log('Logged in to Bluesky.'); + } catch (err) { + console.error('Failed to login to Bluesky:', err); + process.exit(1); + } + + if (process.argv.includes('--import-history')) { + await importHistory(); + process.exit(0); + } + + await checkAndPost(); + + console.log(`Scheduling check every ${CHECK_INTERVAL_MINUTES} minutes.`); + cron.schedule(`*/${CHECK_INTERVAL_MINUTES} * * * *`, () => { + checkAndPost(); + }); +} + +main(); diff --git a/src/types.d.ts b/src/types.d.ts new file mode 100644 index 0000000..ab23217 --- /dev/null +++ b/src/types.d.ts @@ -0,0 +1,49 @@ +declare module '@steipete/bird/dist/lib/twitter-client' { + export interface TwitterClientOptions { + cookies: { + authToken: string; + ct0: string; + }; + } + + export interface TwitterUser { + username: string; + id?: string; + name?: string; + } + + export interface TwitterUserResult { + success: boolean; + user?: TwitterUser; + error?: Error | string; + } + + export interface TwitterSearchResult { + success: boolean; + tweets?: unknown[]; + error?: Error | string; + } + + export class TwitterClient { + constructor(options: TwitterClientOptions); + getCurrentUser(): Promise; + search(query: string, limit: number): Promise; + mapTweetResult(result: unknown): unknown; + } +} + +declare module 'franc-min' { + const franc: (text: string) => string; + export default franc; + export = franc; +} + +declare module 'iso-639-1' { + const iso6391: { + getCode(name: string): string | undefined; + getName(code: string): string | undefined; + getAllNames(): string[]; + getAllCodes(): string[]; + }; + export default iso6391; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..0dab6f6 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": [ + "ES2022" + ], + "types": [ + "node" + ], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": [ + "src/**/*" + ], + "exclude": [ + "node_modules", + "dist" + ] +} \ No newline at end of file