A simple tool which lets you scrape twitter accounts and crosspost them to bluesky accounts! Comes with a CLI and a webapp for managing profiles! Works with images/videos/link embeds/threads.
at main 643 lines 19 kB view raw
1import { spawn } from 'node:child_process'; 2import fs from 'node:fs'; 3import path from 'node:path'; 4import { fileURLToPath } from 'node:url'; 5import { Command } from 'commander'; 6import inquirer from 'inquirer'; 7import { deleteAllPosts } from './bsky.js'; 8import { 9 addMapping, 10 getConfig, 11 removeMapping, 12 saveConfig, 13 updateTwitterConfig, 14 type AccountMapping, 15 type AppConfig, 16} from './config-manager.js'; 17import { dbService } from './db.js'; 18 19const __filename = fileURLToPath(import.meta.url); 20const __dirname = path.dirname(__filename); 21const ROOT_DIR = path.join(__dirname, '..'); 22 23const normalizeHandle = (value: string) => value.trim().replace(/^@/, '').toLowerCase(); 24 25const parsePositiveInt = (value: string, defaultValue: number): number => { 26 const parsed = Number.parseInt(value, 10); 27 if (!Number.isFinite(parsed) || parsed <= 0) { 28 return defaultValue; 29 } 30 return parsed; 31}; 32 33const findMappingByRef = (config: AppConfig, ref: string): AccountMapping | undefined => { 34 const needle = normalizeHandle(ref); 35 return config.mappings.find( 36 (mapping) => 37 mapping.id === ref || 38 normalizeHandle(mapping.bskyIdentifier) === needle || 39 mapping.twitterUsernames.some((username) => normalizeHandle(username) === needle), 40 ); 41}; 42 43const selectMapping = async (message: string): Promise<AccountMapping | null> => { 44 const config = getConfig(); 45 if (config.mappings.length === 0) { 46 console.log('No mappings found.'); 47 return null; 48 } 49 50 const { id } = await inquirer.prompt([ 51 { 52 type: 'list', 53 name: 'id', 54 message, 55 choices: config.mappings.map((mapping) => ({ 56 name: `${mapping.owner || 'System'}: ${mapping.twitterUsernames.join(', ')} -> ${mapping.bskyIdentifier}`, 57 value: mapping.id, 58 })), 59 }, 60 ]); 61 62 return config.mappings.find((mapping) => mapping.id === id) ?? null; 63}; 64 65const spawnAndWait = async (command: string, args: string[], cwd: string): Promise<void> => 66 new Promise((resolve, reject) => { 67 const child = spawn(command, args, { 68 cwd, 69 stdio: 'inherit', 70 env: process.env, 71 }); 72 73 child.on('error', reject); 74 child.on('exit', (code) => { 75 if (code === 0) { 76 resolve(); 77 return; 78 } 79 reject(new Error(`Process exited with code ${code}`)); 80 }); 81 }); 82 83const runCoreCommand = async (args: string[]): Promise<void> => { 84 const distEntry = path.join(ROOT_DIR, 'dist', 'index.js'); 85 if (fs.existsSync(distEntry)) { 86 await spawnAndWait(process.execPath, [distEntry, ...args], ROOT_DIR); 87 return; 88 } 89 90 const tsxBin = 91 process.platform === 'win32' 92 ? path.join(ROOT_DIR, 'node_modules', '.bin', 'tsx.cmd') 93 : path.join(ROOT_DIR, 'node_modules', '.bin', 'tsx'); 94 95 const sourceEntry = path.join(ROOT_DIR, 'src', 'index.ts'); 96 if (fs.existsSync(tsxBin) && fs.existsSync(sourceEntry)) { 97 await spawnAndWait(tsxBin, [sourceEntry, ...args], ROOT_DIR); 98 return; 99 } 100 101 throw new Error('Could not find dist/index.js or tsx runtime. Run npm run build first.'); 102}; 103 104const ensureMapping = async (mappingRef?: string): Promise<AccountMapping | null> => { 105 const config = getConfig(); 106 if (config.mappings.length === 0) { 107 console.log('No mappings found.'); 108 return null; 109 } 110 111 if (mappingRef) { 112 const mapping = findMappingByRef(config, mappingRef); 113 if (!mapping) { 114 console.log(`No mapping found for '${mappingRef}'.`); 115 return null; 116 } 117 return mapping; 118 } 119 120 return selectMapping('Select a mapping:'); 121}; 122 123const exportConfig = (outputFile: string) => { 124 const config = getConfig(); 125 const { users, ...cleanConfig } = config; 126 const outputPath = path.resolve(outputFile); 127 fs.writeFileSync(outputPath, JSON.stringify(cleanConfig, null, 2)); 128 console.log(`Exported config to ${outputPath}.`); 129}; 130 131const importConfig = (inputFile: string) => { 132 const inputPath = path.resolve(inputFile); 133 if (!fs.existsSync(inputPath)) { 134 throw new Error(`File not found: ${inputPath}`); 135 } 136 137 const parsed = JSON.parse(fs.readFileSync(inputPath, 'utf8')); 138 if (!parsed.mappings || !Array.isArray(parsed.mappings)) { 139 throw new Error('Invalid config format: missing mappings array.'); 140 } 141 142 const currentConfig = getConfig(); 143 const nextConfig: AppConfig = { 144 ...currentConfig, 145 mappings: parsed.mappings, 146 groups: Array.isArray(parsed.groups) ? parsed.groups : currentConfig.groups, 147 twitter: parsed.twitter || currentConfig.twitter, 148 ai: parsed.ai || currentConfig.ai, 149 checkIntervalMinutes: parsed.checkIntervalMinutes || currentConfig.checkIntervalMinutes, 150 }; 151 152 saveConfig(nextConfig); 153 console.log('Config imported successfully. Existing users were preserved.'); 154}; 155 156const program = new Command(); 157 158program 159 .name('tweets-2-bsky-cli') 160 .description('CLI for full Tweets -> Bluesky dashboard workflows') 161 .version('2.1.0'); 162 163program 164 .command('setup-ai') 165 .description('Configure AI settings for alt text generation') 166 .action(async () => { 167 const config = getConfig(); 168 const currentAi = config.ai || { provider: 'gemini' }; 169 170 if (!config.ai && config.geminiApiKey) { 171 currentAi.apiKey = config.geminiApiKey; 172 } 173 174 const answers = await inquirer.prompt([ 175 { 176 type: 'list', 177 name: 'provider', 178 message: 'Select AI Provider:', 179 choices: [ 180 { name: 'Google Gemini (Default)', value: 'gemini' }, 181 { name: 'OpenAI / OpenRouter', value: 'openai' }, 182 { name: 'Anthropic (Claude)', value: 'anthropic' }, 183 { name: 'Custom (OpenAI Compatible)', value: 'custom' }, 184 ], 185 default: currentAi.provider, 186 }, 187 { 188 type: 'input', 189 name: 'apiKey', 190 message: 'Enter API Key (optional for some custom providers):', 191 default: currentAi.apiKey, 192 }, 193 { 194 type: 'input', 195 name: 'model', 196 message: 'Enter Model ID (optional, leave empty for default):', 197 default: currentAi.model, 198 }, 199 { 200 type: 'input', 201 name: 'baseUrl', 202 message: 'Enter Base URL (optional):', 203 default: currentAi.baseUrl, 204 when: (answers) => ['openai', 'anthropic', 'custom'].includes(answers.provider), 205 }, 206 ]); 207 208 config.ai = { 209 provider: answers.provider, 210 apiKey: answers.apiKey, 211 model: answers.model || undefined, 212 baseUrl: answers.baseUrl || undefined, 213 }; 214 215 delete config.geminiApiKey; 216 saveConfig(config); 217 console.log('AI configuration updated.'); 218 }); 219 220program 221 .command('setup-twitter') 222 .description('Setup Twitter auth cookies (primary + backup)') 223 .action(async () => { 224 const config = getConfig(); 225 const answers = await inquirer.prompt([ 226 { 227 type: 'input', 228 name: 'authToken', 229 message: 'Primary Twitter auth_token:', 230 default: config.twitter.authToken, 231 }, 232 { 233 type: 'input', 234 name: 'ct0', 235 message: 'Primary Twitter ct0:', 236 default: config.twitter.ct0, 237 }, 238 { 239 type: 'input', 240 name: 'backupAuthToken', 241 message: 'Backup Twitter auth_token (optional):', 242 default: config.twitter.backupAuthToken, 243 }, 244 { 245 type: 'input', 246 name: 'backupCt0', 247 message: 'Backup Twitter ct0 (optional):', 248 default: config.twitter.backupCt0, 249 }, 250 ]); 251 252 updateTwitterConfig(answers); 253 console.log('Twitter credentials updated.'); 254 }); 255 256program 257 .command('add-mapping') 258 .description('Add a new Twitter -> Bluesky mapping') 259 .action(async () => { 260 const answers = await inquirer.prompt([ 261 { 262 type: 'input', 263 name: 'owner', 264 message: 'Owner name:', 265 }, 266 { 267 type: 'input', 268 name: 'twitterUsernames', 269 message: 'Twitter username(s) to watch (comma separated, without @):', 270 }, 271 { 272 type: 'input', 273 name: 'bskyIdentifier', 274 message: 'Bluesky identifier (handle or email):', 275 }, 276 { 277 type: 'password', 278 name: 'bskyPassword', 279 message: 'Bluesky app password:', 280 }, 281 { 282 type: 'input', 283 name: 'bskyServiceUrl', 284 message: 'Bluesky service URL:', 285 default: 'https://bsky.social', 286 }, 287 { 288 type: 'input', 289 name: 'groupName', 290 message: 'Group/folder name (optional):', 291 }, 292 { 293 type: 'input', 294 name: 'groupEmoji', 295 message: 'Group emoji icon (optional):', 296 }, 297 ]); 298 299 const usernames = answers.twitterUsernames 300 .split(',') 301 .map((username: string) => username.trim()) 302 .filter((username: string) => username.length > 0); 303 304 addMapping({ 305 owner: answers.owner, 306 twitterUsernames: usernames, 307 bskyIdentifier: answers.bskyIdentifier, 308 bskyPassword: answers.bskyPassword, 309 bskyServiceUrl: answers.bskyServiceUrl, 310 groupName: answers.groupName?.trim() || undefined, 311 groupEmoji: answers.groupEmoji?.trim() || undefined, 312 }); 313 console.log('Mapping added successfully.'); 314 }); 315 316program 317 .command('edit-mapping [mapping]') 318 .description('Edit a mapping by id/handle/twitter username') 319 .action(async (mappingRef?: string) => { 320 const mapping = await ensureMapping(mappingRef); 321 if (!mapping) return; 322 323 const config = getConfig(); 324 const answers = await inquirer.prompt([ 325 { 326 type: 'input', 327 name: 'owner', 328 message: 'Owner:', 329 default: mapping.owner || '', 330 }, 331 { 332 type: 'input', 333 name: 'twitterUsernames', 334 message: 'Twitter username(s) (comma separated):', 335 default: mapping.twitterUsernames.join(', '), 336 }, 337 { 338 type: 'input', 339 name: 'bskyIdentifier', 340 message: 'Bluesky identifier:', 341 default: mapping.bskyIdentifier, 342 }, 343 { 344 type: 'password', 345 name: 'bskyPassword', 346 message: 'Bluesky app password (leave empty to keep current):', 347 }, 348 { 349 type: 'input', 350 name: 'bskyServiceUrl', 351 message: 'Bluesky service URL:', 352 default: mapping.bskyServiceUrl || 'https://bsky.social', 353 }, 354 { 355 type: 'input', 356 name: 'groupName', 357 message: 'Group/folder name (optional):', 358 default: mapping.groupName || '', 359 }, 360 { 361 type: 'input', 362 name: 'groupEmoji', 363 message: 'Group emoji icon (optional):', 364 default: mapping.groupEmoji || '', 365 }, 366 ]); 367 368 const usernames = answers.twitterUsernames 369 .split(',') 370 .map((username: string) => username.trim()) 371 .filter((username: string) => username.length > 0); 372 373 const index = config.mappings.findIndex((entry) => entry.id === mapping.id); 374 if (index === -1) return; 375 376 const existingMapping = config.mappings[index]; 377 if (!existingMapping) return; 378 379 const updatedMapping = { 380 ...existingMapping, 381 owner: answers.owner, 382 twitterUsernames: usernames, 383 bskyIdentifier: answers.bskyIdentifier, 384 bskyServiceUrl: answers.bskyServiceUrl, 385 groupName: answers.groupName?.trim() || undefined, 386 groupEmoji: answers.groupEmoji?.trim() || undefined, 387 }; 388 389 if (answers.bskyPassword && answers.bskyPassword.trim().length > 0) { 390 updatedMapping.bskyPassword = answers.bskyPassword; 391 } 392 393 config.mappings[index] = updatedMapping; 394 saveConfig(config); 395 console.log('Mapping updated successfully.'); 396 }); 397 398program 399 .command('list') 400 .description('List all mappings') 401 .action(() => { 402 const config = getConfig(); 403 if (config.mappings.length === 0) { 404 console.log('No mappings found.'); 405 return; 406 } 407 408 console.table( 409 config.mappings.map((mapping) => ({ 410 id: mapping.id, 411 owner: mapping.owner || 'System', 412 twitter: mapping.twitterUsernames.join(', '), 413 bsky: mapping.bskyIdentifier, 414 group: `${mapping.groupEmoji || '📁'} ${mapping.groupName || 'Ungrouped'}`, 415 enabled: mapping.enabled, 416 })), 417 ); 418 }); 419 420program 421 .command('remove [mapping]') 422 .description('Remove a mapping by id/handle/twitter username') 423 .action(async (mappingRef?: string) => { 424 const mapping = await ensureMapping(mappingRef); 425 if (!mapping) return; 426 427 const { confirmed } = await inquirer.prompt([ 428 { 429 type: 'confirm', 430 name: 'confirmed', 431 message: `Remove mapping ${mapping.twitterUsernames.join(', ')} -> ${mapping.bskyIdentifier}?`, 432 default: false, 433 }, 434 ]); 435 436 if (!confirmed) { 437 console.log('Cancelled.'); 438 return; 439 } 440 441 removeMapping(mapping.id); 442 console.log('Mapping removed.'); 443 }); 444 445program 446 .command('import-history [mapping]') 447 .description('Import history immediately for one mapping') 448 .option('-l, --limit <number>', 'Tweet limit', '15') 449 .option('--dry-run', 'Do not post to Bluesky', false) 450 .option('--web', 'Keep web server enabled during import', false) 451 .action(async (mappingRef: string | undefined, options) => { 452 const mapping = await ensureMapping(mappingRef); 453 if (!mapping) return; 454 455 let username = mapping.twitterUsernames[0] ?? ''; 456 if (!username) { 457 console.log('Mapping has no Twitter usernames.'); 458 return; 459 } 460 461 if (mapping.twitterUsernames.length > 1) { 462 const answer = await inquirer.prompt([ 463 { 464 type: 'list', 465 name: 'username', 466 message: 'Select Twitter username to import:', 467 choices: mapping.twitterUsernames, 468 default: username, 469 }, 470 ]); 471 username = String(answer.username || '').trim(); 472 } 473 474 const args: string[] = [ 475 '--import-history', 476 '--username', 477 username, 478 '--limit', 479 String(parsePositiveInt(options.limit, 15)), 480 ]; 481 if (options.dryRun) args.push('--dry-run'); 482 if (!options.web) args.push('--no-web'); 483 484 await runCoreCommand(args); 485 }); 486 487program 488 .command('set-interval <minutes>') 489 .description('Set scheduler interval in minutes') 490 .action((minutes) => { 491 const parsed = parsePositiveInt(minutes, 5); 492 const config = getConfig(); 493 config.checkIntervalMinutes = parsed; 494 saveConfig(config); 495 console.log(`Interval set to ${parsed} minutes.`); 496 }); 497 498program 499 .command('run-now') 500 .description('Run one sync cycle now (ideal for cronjobs)') 501 .option('--dry-run', 'Fetch but do not post', false) 502 .option('--web', 'Keep web server enabled during this run', false) 503 .action(async (options) => { 504 const args = ['--run-once']; 505 if (options.dryRun) args.push('--dry-run'); 506 if (!options.web) args.push('--no-web'); 507 await runCoreCommand(args); 508 }); 509 510program 511 .command('backfill [mapping]') 512 .description('Run backfill now for one mapping (id/handle/twitter username)') 513 .option('-l, --limit <number>', 'Tweet limit', '15') 514 .option('--dry-run', 'Fetch but do not post', false) 515 .option('--web', 'Keep web server enabled during this run', false) 516 .action(async (mappingRef: string | undefined, options) => { 517 const mapping = await ensureMapping(mappingRef); 518 if (!mapping) return; 519 520 const args = ['--run-once', '--backfill-mapping', mapping.id, '--backfill-limit', String(parsePositiveInt(options.limit, 15))]; 521 if (options.dryRun) args.push('--dry-run'); 522 if (!options.web) args.push('--no-web'); 523 524 await runCoreCommand(args); 525 }); 526 527program 528 .command('clear-cache [mapping]') 529 .description('Clear cached tweet history for a mapping') 530 .action(async (mappingRef?: string) => { 531 const mapping = await ensureMapping(mappingRef); 532 if (!mapping) return; 533 534 for (const username of mapping.twitterUsernames) { 535 dbService.deleteTweetsByUsername(username); 536 } 537 538 console.log(`Cache cleared for ${mapping.twitterUsernames.join(', ')}.`); 539 }); 540 541program 542 .command('delete-all-posts [mapping]') 543 .description('Delete all posts on mapped Bluesky account and clear local cache') 544 .action(async (mappingRef?: string) => { 545 const mapping = await ensureMapping(mappingRef); 546 if (!mapping) return; 547 548 const { confirmed } = await inquirer.prompt([ 549 { 550 type: 'confirm', 551 name: 'confirmed', 552 message: `Delete ALL posts for ${mapping.bskyIdentifier}? This cannot be undone.`, 553 default: false, 554 }, 555 ]); 556 557 if (!confirmed) { 558 console.log('Cancelled.'); 559 return; 560 } 561 562 const { typed } = await inquirer.prompt([ 563 { 564 type: 'input', 565 name: 'typed', 566 message: 'Type DELETE to confirm:', 567 }, 568 ]); 569 570 if (typed !== 'DELETE') { 571 console.log('Confirmation failed. Aborting.'); 572 return; 573 } 574 575 const deleted = await deleteAllPosts(mapping.id); 576 dbService.deleteTweetsByBskyIdentifier(mapping.bskyIdentifier); 577 console.log(`Deleted ${deleted} posts for ${mapping.bskyIdentifier} and cleared local cache.`); 578 }); 579 580program 581 .command('recent-activity') 582 .description('Show recent processed tweets') 583 .option('-l, --limit <number>', 'Number of rows', '20') 584 .action((options) => { 585 const limit = parsePositiveInt(options.limit, 20); 586 const rows = dbService.getRecentProcessedTweets(limit); 587 588 if (rows.length === 0) { 589 console.log('No recent activity found.'); 590 return; 591 } 592 593 console.table( 594 rows.map((row) => ({ 595 time: row.created_at, 596 twitter: row.twitter_username, 597 bsky: row.bsky_identifier, 598 status: row.status, 599 text: row.tweet_text ? row.tweet_text.slice(0, 80) : row.twitter_id, 600 })), 601 ); 602 }); 603 604program 605 .command('config-export [file]') 606 .description('Export dashboard config (without users/password hashes)') 607 .action((file = 'tweets-2-bsky-config.json') => { 608 exportConfig(file); 609 }); 610 611program 612 .command('config-import <file>') 613 .description('Import dashboard config (preserves existing users)') 614 .action((file) => { 615 importConfig(file); 616 }); 617 618program 619 .command('status') 620 .description('Show local CLI status summary') 621 .action(() => { 622 const config = getConfig(); 623 const recent = dbService.getRecentProcessedTweets(5); 624 625 console.log('Tweets-2-Bsky status'); 626 console.log('--------------------'); 627 console.log(`Mappings: ${config.mappings.length}`); 628 console.log(`Enabled mappings: ${config.mappings.filter((mapping) => mapping.enabled).length}`); 629 console.log(`Check interval: ${config.checkIntervalMinutes} minute(s)`); 630 console.log(`Twitter configured: ${Boolean(config.twitter.authToken && config.twitter.ct0)}`); 631 console.log(`AI provider: ${config.ai?.provider || 'gemini (default)'}`); 632 console.log(`Recent processed tweets: ${recent.length > 0 ? recent.length : 0}`); 633 634 if (recent.length > 0) { 635 const last = recent[0]; 636 console.log(`Latest activity: ${last?.created_at || 'unknown'} (${last?.status || 'unknown'})`); 637 } 638 }); 639 640program.parseAsync().catch((error) => { 641 console.error(error instanceof Error ? error.message : error); 642 process.exit(1); 643});