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.
11
fork

Configure Feed

Select the types of activity you want to include in your feed.

at ba862fc033036c612fb93d276ca35f74408f85d5 312 lines 8.7 kB view raw
1import { Command } from 'commander'; 2import inquirer from 'inquirer'; 3import { addMapping, getConfig, removeMapping, saveConfig, updateTwitterConfig } from './config-manager.js'; 4 5const program = new Command(); 6 7program 8 .name('tweets-2-bsky-cli') 9 .description('CLI to manage Twitter to Bluesky crossposting mappings') 10 .version('1.0.0'); 11 12program 13 .command('setup-ai') 14 .description('Configure AI settings for alt text generation') 15 .action(async () => { 16 const config = getConfig(); 17 const currentAi = config.ai || { provider: 'gemini' }; 18 19 // Check legacy gemini key if not in new config 20 if (!config.ai && config.geminiApiKey) { 21 currentAi.apiKey = config.geminiApiKey; 22 } 23 24 const answers = await inquirer.prompt([ 25 { 26 type: 'list', 27 name: 'provider', 28 message: 'Select AI Provider:', 29 choices: [ 30 { name: 'Google Gemini (Default)', value: 'gemini' }, 31 { name: 'OpenAI / OpenRouter', value: 'openai' }, 32 { name: 'Anthropic (Claude)', value: 'anthropic' }, 33 { name: 'Custom (OpenAI Compatible)', value: 'custom' }, 34 ], 35 default: currentAi.provider, 36 }, 37 { 38 type: 'input', 39 name: 'apiKey', 40 message: 'Enter API Key (optional for some custom providers):', 41 default: currentAi.apiKey, 42 validate: (input: string, answers: any) => { 43 if (['gemini', 'anthropic'].includes(answers.provider) && !input) { 44 return 'API Key is required for this provider.'; 45 } 46 return true; 47 }, 48 }, 49 { 50 type: 'input', 51 name: 'model', 52 message: 'Enter Model ID (optional, leave empty for default):', 53 default: currentAi.model, 54 }, 55 { 56 type: 'input', 57 name: 'baseUrl', 58 message: 'Enter Base URL (optional, e.g. for OpenRouter):', 59 default: currentAi.baseUrl, 60 when: (answers) => ['openai', 'anthropic', 'custom'].includes(answers.provider), 61 }, 62 ]); 63 64 config.ai = { 65 provider: answers.provider, 66 apiKey: answers.apiKey, 67 model: answers.model || undefined, 68 baseUrl: answers.baseUrl || undefined, 69 }; 70 71 // Clear legacy key to avoid confusion 72 delete config.geminiApiKey; 73 74 saveConfig(config); 75 console.log('AI configuration updated!'); 76 }); 77 78program 79 .command('setup-twitter') 80 .description('Setup Twitter auth cookies') 81 .action(async () => { 82 const config = getConfig(); 83 const answers = await inquirer.prompt([ 84 { 85 type: 'input', 86 name: 'authToken', 87 message: 'Enter Twitter auth_token:', 88 default: config.twitter.authToken, 89 }, 90 { 91 type: 'input', 92 name: 'ct0', 93 message: 'Enter Twitter ct0:', 94 default: config.twitter.ct0, 95 }, 96 ]); 97 updateTwitterConfig(answers); 98 console.log('Twitter config updated!'); 99 }); 100 101program 102 .command('add-mapping') 103 .description('Add a new Twitter to Bluesky mapping') 104 .action(async () => { 105 const answers = await inquirer.prompt([ 106 { 107 type: 'input', 108 name: 'twitterUsernames', 109 message: 'Twitter username(s) to watch (comma separated, without @):', 110 }, 111 { 112 type: 'input', 113 name: 'bskyIdentifier', 114 message: 'Bluesky identifier (handle or email):', 115 }, 116 { 117 type: 'password', 118 name: 'bskyPassword', 119 message: 'Bluesky app password:', 120 }, 121 { 122 type: 'input', 123 name: 'bskyServiceUrl', 124 message: 'Bluesky service URL:', 125 default: 'https://bsky.social', 126 }, 127 ]); 128 129 const usernames = answers.twitterUsernames 130 .split(',') 131 .map((u: string) => u.trim()) 132 .filter((u: string) => u.length > 0); 133 134 addMapping({ 135 ...answers, 136 twitterUsernames: usernames, 137 }); 138 console.log('Mapping added successfully!'); 139 }); 140 141program 142 .command('edit-mapping') 143 .description('Edit an existing mapping') 144 .action(async () => { 145 const config = getConfig(); 146 if (config.mappings.length === 0) { 147 console.log('No mappings found.'); 148 return; 149 } 150 151 const { id } = await inquirer.prompt([ 152 { 153 type: 'list', 154 name: 'id', 155 message: 'Select a mapping to edit:', 156 choices: config.mappings.map((m) => ({ 157 name: `${m.twitterUsernames.join(', ')} -> ${m.bskyIdentifier}`, 158 value: m.id, 159 })), 160 }, 161 ]); 162 163 const mapping = config.mappings.find((m) => m.id === id); 164 if (!mapping) return; 165 166 const answers = await inquirer.prompt([ 167 { 168 type: 'input', 169 name: 'twitterUsernames', 170 message: 'Twitter username(s) (comma separated):', 171 default: mapping.twitterUsernames.join(', '), 172 }, 173 { 174 type: 'input', 175 name: 'bskyIdentifier', 176 message: 'Bluesky identifier:', 177 default: mapping.bskyIdentifier, 178 }, 179 { 180 type: 'password', 181 name: 'bskyPassword', 182 message: 'Bluesky app password (leave empty to keep current):', 183 }, 184 { 185 type: 'input', 186 name: 'bskyServiceUrl', 187 message: 'Bluesky service URL:', 188 default: mapping.bskyServiceUrl || 'https://bsky.social', 189 }, 190 ]); 191 192 const usernames = answers.twitterUsernames 193 .split(',') 194 .map((u: string) => u.trim()) 195 .filter((u: string) => u.length > 0); 196 197 // Update the mapping directly 198 const index = config.mappings.findIndex((m) => m.id === id); 199 const existingMapping = config.mappings[index]; 200 201 if (index !== -1 && existingMapping) { 202 const updatedMapping = { 203 ...existingMapping, 204 twitterUsernames: usernames, 205 bskyIdentifier: answers.bskyIdentifier, 206 bskyServiceUrl: answers.bskyServiceUrl, 207 }; 208 209 if (answers.bskyPassword && answers.bskyPassword.trim().length > 0) { 210 updatedMapping.bskyPassword = answers.bskyPassword; 211 } 212 213 config.mappings[index] = updatedMapping; 214 saveConfig(config); 215 console.log('Mapping updated successfully!'); 216 } 217 }); 218 219program 220 .command('list') 221 .description('List all mappings') 222 .action(() => { 223 const config = getConfig(); 224 if (config.mappings.length === 0) { 225 console.log('No mappings found.'); 226 return; 227 } 228 console.table( 229 config.mappings.map((m) => ({ 230 id: m.id, 231 twitter: m.twitterUsernames.join(', '), 232 bsky: m.bskyIdentifier, 233 enabled: m.enabled, 234 })), 235 ); 236 }); 237 238program 239 .command('remove') 240 .description('Remove a mapping') 241 .action(async () => { 242 const config = getConfig(); 243 if (config.mappings.length === 0) { 244 console.log('No mappings to remove.'); 245 return; 246 } 247 const { id } = await inquirer.prompt([ 248 { 249 type: 'list', 250 name: 'id', 251 message: 'Select a mapping to remove:', 252 choices: config.mappings.map((m) => ({ 253 name: `${m.twitterUsernames.join(', ')} -> ${m.bskyIdentifier}`, 254 value: m.id, 255 })), 256 }, 257 ]); 258 removeMapping(id); 259 console.log('Mapping removed.'); 260 }); 261 262program 263 .command('import-history') 264 .description('Import history for a specific mapping') 265 .action(async () => { 266 const config = getConfig(); 267 if (config.mappings.length === 0) { 268 console.log('No mappings found.'); 269 return; 270 } 271 const { id } = await inquirer.prompt([ 272 { 273 type: 'list', 274 name: 'id', 275 message: 'Select a mapping to import history for:', 276 choices: config.mappings.map((m) => ({ 277 name: `${m.twitterUsernames.join(', ')} -> ${m.bskyIdentifier}`, 278 value: m.id, 279 })), 280 }, 281 ]); 282 283 const mapping = config.mappings.find((m) => m.id === id); 284 if (!mapping) return; 285 286 console.log(` 287To import history, run one of the following commands:`); 288 for (const username of mapping.twitterUsernames) { 289 console.log(` npm run import -- --username ${username}`); 290 } 291 console.log(` 292You can also use additional flags:`); 293 console.log(' --limit <number> Limit the number of tweets to import'); 294 console.log(' --dry-run Fetch and show tweets without posting'); 295 console.log(` 296Example:`); 297 console.log(` npm run import -- --username ${mapping.twitterUsernames[0]} --limit 10 --dry-run 298`); 299 }); 300 301program 302 .command('set-interval') 303 .description('Set check interval in minutes') 304 .argument('<minutes>', 'Interval in minutes') 305 .action((minutes) => { 306 const config = getConfig(); 307 config.checkIntervalMinutes = Number.parseInt(minutes, 10); 308 saveConfig(config); 309 console.log(`Interval set to ${minutes} minutes.`); 310 }); 311 312program.parse();