forked from
j4ck.xyz/tweets2bsky
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.
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});