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.

feat: multi-account support, edit functionality, and update script

jack 8419396d 4d3c5eab

+409 -111
+72 -61
README.md
··· 1 - # ๐Ÿฆ Tweets-2-Bsky 1 + # Tweets-2-Bsky 2 2 3 - > **Note**: This project is built on top of [**bird**](https://github.com/steipete/bird) by [@steipete](https://github.com/steipete), which provides the core Twitter interaction capabilities. 3 + A powerful tool to crosspost Tweets to Bluesky, supporting threads, videos, and high-quality images. 4 4 5 - A powerful tool to crosspost your Tweets to Bluesky automatically. Now features a **Web Dashboard** for easy management, **Multi-account support** for different owners, and **Custom PDS** hosting support. 5 + ## Features 6 6 7 - ## โœจ Features 7 + - ๐Ÿ”„ **Crossposting**: Automatically mirrors your Tweets to Bluesky. 8 + - ๐Ÿงต **Thread Support**: INTELLIGENTLY handles threads, posting them as Bluesky threads. 9 + - ๐Ÿ“น **Video & GIF Support**: Downloads and uploads videos/GIFs natively to Bluesky (not just links!). 10 + - ๐Ÿ–ผ๏ธ **High-Quality Images**: Fetches the highest resolution images available. 11 + - ๐Ÿ”— **Smart Link Expansion**: Resolves `t.co` links to their original URLs. 12 + - ๐Ÿ‘ฅ **Multiple Source Accounts**: Map multiple Twitter accounts to a single Bluesky profile. 13 + - โš™๏ธ **Web Dashboard**: Manage accounts, view status, and trigger runs via a modern UI. 14 + - ๐Ÿ› ๏ธ **CLI & Web Support**: Use the command line or the web interface. 8 15 9 - - **Web Dashboard**: Modern interface to manage all your sync tasks. 10 - - **Multi-User Mapping**: Let others add their accounts (e.g., Dan, Josh) with their own owners. 11 - - **Multi-Account Support**: Sync Twitter A -> Bluesky A, Twitter B -> Bluesky B, etc. 12 - - **Tailscale Ready**: Accessible over your local network or VPN. 13 - - **Interactive CLI**: Manage everything from the terminal with `./crosspost`. 14 - - **High Quality**: Supports threads, high-quality images, and videos. 16 + ## Quick Start 17 + 18 + 1. **Clone the repository:** 19 + ```bash 20 + git clone https://github.com/yourusername/tweets-2-bsky.git 21 + cd tweets-2-bsky 22 + ``` 23 + 24 + 2. **Install dependencies:** 25 + ```bash 26 + npm install 27 + ``` 28 + 29 + 3. **Build the project:** 30 + ```bash 31 + npm run build 32 + ``` 15 33 16 - --- 34 + 4. **Start the server:** 35 + ```bash 36 + npm start 37 + ``` 38 + Access the dashboard at `http://localhost:3000`. 17 39 18 - ## ๐Ÿš€ Quick Start 40 + ## Updating 19 41 20 - ### 1. Prerequisites 21 - - **Node.js** installed. 22 - - A Twitter account (burner recommended) for global cookies. 23 - - Bluesky account(s) with **App Passwords**. 42 + To update to the latest version without losing your configuration: 24 43 25 - ### 2. Installation 26 44 ```bash 27 - git clone https://github.com/j4ckxyz/tweets-2-bsky.git 28 - cd tweets-2-bsky 29 - npm install 30 - npm run build 45 + ./update.sh 31 46 ``` 32 47 33 - ### 3. Start Syncing & Web UI 34 - ```bash 35 - # This starts both the sync daemon AND the web dashboard 36 - npm start 48 + This script will pull the latest code, install dependencies, and rebuild the project. **Restart your application** after running the update. 49 + 50 + ## Configuration & Security 51 + 52 + ### Environment Variables 53 + 54 + Create a `.env` file for security (optional but recommended): 55 + 56 + ```env 57 + PORT=3000 58 + JWT_SECRET=your-super-secret-key-change-this 37 59 ``` 38 - By default, the web interface runs at **http://localhost:3000**. If you are using Tailscale, it's accessible at `http://your-tailscale-ip:3000`. 39 60 40 - ### 4. Setup (Web Dashboard) 41 - 1. Open the dashboard in your browser. 42 - 2. **Register** a new account (email/password). 43 - 3. Log in and go to **Global Twitter Config** to enter your cookies. 44 - 4. Use **Add New Mapping** to connect a Twitter handle to a Bluesky account. 61 + > **โš ๏ธ Security Note:** If you do not set `JWT_SECRET`, a fallback secret is used. For production or public-facing deployments, **YOU MUST SET A STRONG SECRET**. 45 62 46 - --- 63 + ### Data Storage 47 64 48 - ## ๐Ÿ›  Advanced Usage 65 + - **`config.json`**: Stores your account mappings and encrypted web user passwords. Note that Bluesky app passwords are stored in plain text here to facilitate automated login. **Do not share this file.** 66 + - **`data/database.sqlite`**: Stores the history of processed tweets to prevent duplicates. 49 67 50 - ### Disable Web Interface 51 - If you only want to run the sync daemon without the web UI: 52 - ```bash 53 - npm start -- --no-web 54 - ``` 68 + ## Usage 55 69 56 - ### Command Line Interface (CLI) 57 - You can still manage everything via the terminal: 58 - ```bash 59 - # Set Twitter cookies 60 - ./crosspost setup-twitter 70 + ### Web Interface 61 71 62 - # Add a mapping 63 - ./crosspost add-mapping 72 + 1. Register your first account (this user becomes the **Admin**). 73 + 2. Go to settings to configure your Twitter Auth Token and CT0 (cookies). 74 + 3. Add mappings: 75 + * Enter one or more **Twitter Usernames** (comma-separated). 76 + * Enter your **Bluesky Handle** and **App Password**. 77 + 4. The system will check for new tweets every 5 minutes (configurable). 64 78 65 - # List/Remove 66 - ./crosspost list 67 - ./crosspost remove 68 - ``` 79 + ### CLI 80 + 81 + - **Add Mapping**: `npm run cli add-mapping` 82 + - **Edit Mapping**: `npm run cli edit-mapping` 83 + - **Import History**: `npm run cli import-history` 84 + - **List Accounts**: `npm run cli list` 69 85 70 - ### Backfilling Old Tweets 71 - ```bash 72 - # Example: Import the last 20 tweets for a user 73 - npm run import -- --username YOUR_TWITTER_HANDLE --limit 20 74 - ``` 86 + ## Twitter Cookies (Auth) 75 87 76 - --- 88 + You need your Twitter `auth_token` and `ct0` cookies. 89 + 1. Log in to Twitter/X in your browser. 90 + 2. Open Developer Tools (F12) -> Application -> Cookies. 91 + 3. Copy the values for `auth_token` and `ct0`. 77 92 78 - ## โš™๏ธ How to get Twitter Cookies 79 - 1. Log in to Twitter in your browser. 80 - 2. Open **Developer Tools** (F12) -> **Application** tab -> **Cookies**. 81 - 3. Copy `auth_token` and `ct0` values. 93 + ## License 82 94 83 - ## โš–๏ธ License 84 - MIT 95 + MIT
+93 -21
public/index.html
··· 48 48 const [loading, setLoading] = useState(false); 49 49 const [error, setError] = useState(''); 50 50 const [countdown, setCountdown] = useState(''); 51 + 52 + // Edit Modal State 53 + const [editingMapping, setEditingMapping] = useState(null); 51 54 52 55 const handleLogout = useCallback(() => { 53 56 localStorage.removeItem('token'); ··· 104 107 } 105 108 }, [token, fetchData]); 106 109 107 - // Separate effect for intervals to handle status updates correctly 108 110 useEffect(() => { 109 111 if (view !== 'dashboard' || !token) return; 110 112 ··· 112 114 return () => clearInterval(statusTimer); 113 115 }, [view, token, fetchStatus]); 114 116 115 - // Effect for countdown timer - depends on status.nextCheckTime 116 117 useEffect(() => { 117 118 if (!status.nextCheckTime) return; 118 119 ··· 168 169 try { 169 170 await axios.post('/api/mappings', { 170 171 owner: formData.get('owner'), 171 - twitterUsername: formData.get('twitterUsername'), 172 + twitterUsernames: formData.get('twitterUsernames'), 172 173 bskyIdentifier: formData.get('bskyIdentifier'), 173 174 bskyPassword: formData.get('bskyPassword'), 174 175 bskyServiceUrl: formData.get('bskyServiceUrl') ··· 181 182 setLoading(false); 182 183 }; 183 184 185 + const updateMapping = async (e) => { 186 + e.preventDefault(); 187 + setLoading(true); 188 + const formData = new FormData(e.target); 189 + try { 190 + await axios.put(`/api/mappings/${editingMapping.id}`, { 191 + owner: formData.get('owner'), 192 + twitterUsernames: formData.get('twitterUsernames'), 193 + bskyIdentifier: formData.get('bskyIdentifier'), 194 + bskyPassword: formData.get('bskyPassword'), 195 + bskyServiceUrl: formData.get('bskyServiceUrl') 196 + }, { headers: { Authorization: `Bearer ${token}` } }); 197 + fetchData(); 198 + setEditingMapping(null); 199 + } catch (err) { 200 + alert('Failed to update mapping'); 201 + } 202 + setLoading(false); 203 + }; 204 + 184 205 const deleteMapping = async (id) => { 185 206 if (!confirm('Are you sure?')) return; 186 207 try { ··· 206 227 } 207 228 }; 208 229 209 - const runBackfill = async (id, twitterUsername) => { 210 - const limit = prompt(`How many tweets to backfill for @${twitterUsername}?`, "15"); 211 - if (limit === null) return; // Cancelled 230 + const runBackfill = async (id, twitterUsernames) => { 231 + // If multiple usernames, we could ask which one, but backend handles "all for this mapping" currently 232 + // or we can prompt for a specific one if we want. 233 + // For simplicity, let's just trigger backfill for the mapping, which iterates all usernames. 234 + 235 + const limit = prompt(`How many tweets to backfill per account?`, "15"); 236 + if (limit === null) return; 212 237 213 238 try { 214 239 await axios.post(`/api/backfill/${id}`, { limit: parseInt(limit) || 15 }, { 215 240 headers: { Authorization: `Bearer ${token}` } 216 241 }); 217 - alert(`Backfill queued for @${twitterUsername}`); 242 + alert(`Backfill queued`); 218 243 setTimeout(fetchStatus, 1000); 219 244 } catch (err) { 220 245 alert(err.response?.data?.error || 'Failed to queue backfill'); ··· 244 269 } 245 270 }; 246 271 247 - const clearCache = async (id, twitterUsername) => { 248 - if (!confirm(`Clear processed tweets cache for @${twitterUsername}? This will make the next run re-process recent tweets (potentially causing duplicates if they were already posted).`)) return; 272 + const clearCache = async (id) => { 273 + if (!confirm(`Clear processed tweets cache for all accounts in this mapping?`)) return; 249 274 try { 250 275 await axios.delete(`/api/mappings/${id}/cache`, { 251 276 headers: { Authorization: `Bearer ${token}` } 252 277 }); 253 - alert(`Cache cleared for @${twitterUsername}`); 278 + alert(`Cache cleared`); 254 279 } catch (err) { 255 280 alert('Failed to clear cache'); 256 281 } 257 282 }; 258 283 259 - const resetAndBackfill = async (id, twitterUsername) => { 260 - const limit = prompt(`Reset cache and backfill how many tweets for @${twitterUsername}?`, "15"); 284 + const resetAndBackfill = async (id) => { 285 + const limit = prompt(`Reset cache and backfill how many tweets?`, "15"); 261 286 if (limit === null) return; 262 287 263 288 try { 264 - // 1. Clear Cache 265 289 await axios.delete(`/api/mappings/${id}/cache`, { 266 290 headers: { Authorization: `Bearer ${token}` } 267 291 }); 268 - // 2. Queue Backfill 269 292 await axios.post(`/api/backfill/${id}`, { limit: parseInt(limit) || 15 }, { 270 293 headers: { Authorization: `Bearer ${token}` } 271 294 }); 272 - alert(`Cache cleared and backfill queued for @${twitterUsername}`); 295 + alert(`Cache cleared and backfill queued`); 273 296 setTimeout(fetchStatus, 1000); 274 297 } catch (err) { 275 298 alert('Reset & Backfill failed'); ··· 419 442 <input name="owner" placeholder="Owner (e.g. My Brand)" className="form-control form-control-sm" required /> 420 443 </div> 421 444 <div className="mb-2"> 422 - <input name="twitterUsername" placeholder="Twitter Username" className="form-control form-control-sm" required /> 445 + <input name="twitterUsernames" placeholder="Twitter Username(s) (comma separated)" className="form-control form-control-sm" required /> 423 446 </div> 424 447 425 448 <div className="mt-3 mb-2"> ··· 471 494 <thead> 472 495 <tr> 473 496 <th>Owner</th> 474 - <th>Twitter</th> 497 + <th>Twitter Sources</th> 475 498 <th>Bluesky</th> 476 499 <th>Status</th> 477 500 <th className="text-end">Actions</th> ··· 481 504 {mappings.map(m => ( 482 505 <tr key={m.id}> 483 506 <td><span className="owner-badge">{m.owner || 'System'}</span></td> 484 - <td>@{m.twitterUsername}</td> 507 + <td> 508 + {m.twitterUsernames.map(u => ( 509 + <span key={u} className="badge bg-secondary me-1">@{u}</span> 510 + ))} 511 + </td> 485 512 <td className="small text-muted">{m.bskyIdentifier}</td> 486 513 <td> 487 514 <span className={`status-dot ${isBackfillQueued(m.id) ? 'status-queued' : 'status-active'}`}></span> ··· 495 522 <td className="text-end"> 496 523 {isAdmin && ( 497 524 <> 498 - <button onClick={() => runBackfill(m.id, m.twitterUsername)} className="btn btn-link btn-sm text-warning p-0 me-2" title="Backfill (Keep Cache)"> 525 + <button onClick={() => setEditingMapping(m)} className="btn btn-link btn-sm text-primary p-0 me-2" title="Edit"> 526 + <span className="material-icons">edit</span> 527 + </button> 528 + <button onClick={() => runBackfill(m.id, m.twitterUsernames)} className="btn btn-link btn-sm text-warning p-0 me-2" title="Backfill (Keep Cache)"> 499 529 <span className="material-icons">history</span> 500 530 </button> 501 - <button onClick={() => resetAndBackfill(m.id, m.twitterUsername)} className="btn btn-link btn-sm text-danger p-0 me-2" title="Reset & Backfill (Clears Cache)"> 531 + <button onClick={() => resetAndBackfill(m.id, m.twitterUsernames)} className="btn btn-link btn-sm text-danger p-0 me-2" title="Reset & Backfill (Clears Cache)"> 502 532 <span className="material-icons">restart_alt</span> 503 533 </button> 504 - <button onClick={() => clearCache(m.id, m.twitterUsername)} className="btn btn-link btn-sm text-secondary p-0 me-2" title="Clear Cache Only"> 534 + <button onClick={() => clearCache(m.id, m.twitterUsernames)} className="btn btn-link btn-sm text-secondary p-0 me-2" title="Clear Cache Only"> 505 535 <span className="material-icons">delete_sweep</span> 506 536 </button> 507 537 </> ··· 519 549 </div> 520 550 </div> 521 551 </div> 552 + 553 + {/* Edit Modal */} 554 + {editingMapping && ( 555 + <div className="modal d-block" style={{backgroundColor: 'rgba(0,0,0,0.5)'}} tabIndex="-1"> 556 + <div className="modal-dialog"> 557 + <div className="modal-content"> 558 + <div className="modal-header"> 559 + <h5 className="modal-title">Edit Mapping</h5> 560 + <button type="button" className="btn-close" onClick={() => setEditingMapping(null)}></button> 561 + </div> 562 + <form onSubmit={updateMapping}> 563 + <div className="modal-body"> 564 + <div className="mb-3"> 565 + <label className="form-label">Owner</label> 566 + <input name="owner" defaultValue={editingMapping.owner} className="form-control" required /> 567 + </div> 568 + <div className="mb-3"> 569 + <label className="form-label">Twitter Usernames (comma separated)</label> 570 + <input name="twitterUsernames" defaultValue={editingMapping.twitterUsernames.join(', ')} className="form-control" required /> 571 + </div> 572 + <div className="mb-3"> 573 + <label className="form-label">Bluesky Handle</label> 574 + <input name="bskyIdentifier" defaultValue={editingMapping.bskyIdentifier} className="form-control" required /> 575 + </div> 576 + <div className="mb-3"> 577 + <label className="form-label">Bluesky App Password (leave blank to keep current)</label> 578 + <input name="bskyPassword" type="password" className="form-control" /> 579 + </div> 580 + <div className="mb-3"> 581 + <label className="form-label">Service URL</label> 582 + <input name="bskyServiceUrl" defaultValue={editingMapping.bskyServiceUrl} className="form-control" /> 583 + </div> 584 + </div> 585 + <div className="modal-footer"> 586 + <button type="button" className="btn btn-secondary" onClick={() => setEditingMapping(null)}>Close</button> 587 + <button type="submit" className="btn btn-primary" disabled={loading}>{loading ? 'Saving...' : 'Save changes'}</button> 588 + </div> 589 + </form> 590 + </div> 591 + </div> 592 + </div> 593 + )} 522 594 </div> 523 595 ); 524 596 }
+92 -9
src/cli.ts
··· 39 39 const answers = await inquirer.prompt([ 40 40 { 41 41 type: 'input', 42 - name: 'twitterUsername', 43 - message: 'Twitter username to watch (without @):', 42 + name: 'twitterUsernames', 43 + message: 'Twitter username(s) to watch (comma separated, without @):', 44 44 }, 45 45 { 46 46 type: 'input', ··· 59 59 default: 'https://bsky.social', 60 60 }, 61 61 ]); 62 - addMapping(answers); 62 + 63 + const usernames = answers.twitterUsernames.split(',').map((u: string) => u.trim()).filter((u: string) => u.length > 0); 64 + 65 + addMapping({ 66 + ...answers, 67 + twitterUsernames: usernames, 68 + }); 63 69 console.log('Mapping added successfully!'); 64 70 }); 65 71 66 72 program 73 + .command('edit-mapping') 74 + .description('Edit an existing mapping') 75 + .action(async () => { 76 + const config = getConfig(); 77 + if (config.mappings.length === 0) { 78 + console.log('No mappings found.'); 79 + return; 80 + } 81 + 82 + const { id } = await inquirer.prompt([ 83 + { 84 + type: 'list', 85 + name: 'id', 86 + message: 'Select a mapping to edit:', 87 + choices: config.mappings.map((m) => ({ 88 + name: `${m.twitterUsernames.join(', ')} -> ${m.bskyIdentifier}`, 89 + value: m.id, 90 + })), 91 + }, 92 + ]); 93 + 94 + const mapping = config.mappings.find((m) => m.id === id); 95 + if (!mapping) return; 96 + 97 + const answers = await inquirer.prompt([ 98 + { 99 + type: 'input', 100 + name: 'twitterUsernames', 101 + message: 'Twitter username(s) (comma separated):', 102 + default: mapping.twitterUsernames.join(', '), 103 + }, 104 + { 105 + type: 'input', 106 + name: 'bskyIdentifier', 107 + message: 'Bluesky identifier:', 108 + default: mapping.bskyIdentifier, 109 + }, 110 + { 111 + type: 'password', 112 + name: 'bskyPassword', 113 + message: 'Bluesky app password (leave empty to keep current):', 114 + }, 115 + { 116 + type: 'input', 117 + name: 'bskyServiceUrl', 118 + message: 'Bluesky service URL:', 119 + default: mapping.bskyServiceUrl || 'https://bsky.social', 120 + }, 121 + ]); 122 + 123 + const usernames = answers.twitterUsernames.split(',').map((u: string) => u.trim()).filter((u: string) => u.length > 0); 124 + 125 + // Update the mapping directly 126 + const index = config.mappings.findIndex(m => m.id === id); 127 + const existingMapping = config.mappings[index]; 128 + 129 + if (index !== -1 && existingMapping) { 130 + const updatedMapping = { 131 + ...existingMapping, 132 + twitterUsernames: usernames, 133 + bskyIdentifier: answers.bskyIdentifier, 134 + bskyServiceUrl: answers.bskyServiceUrl, 135 + }; 136 + 137 + if (answers.bskyPassword && answers.bskyPassword.trim().length > 0) { 138 + updatedMapping.bskyPassword = answers.bskyPassword; 139 + } 140 + 141 + config.mappings[index] = updatedMapping; 142 + saveConfig(config); 143 + console.log('Mapping updated successfully!'); 144 + } 145 + }); 146 + 147 + program 67 148 .command('list') 68 149 .description('List all mappings') 69 150 .action(() => { ··· 75 156 console.table( 76 157 config.mappings.map((m) => ({ 77 158 id: m.id, 78 - twitter: m.twitterUsername, 159 + twitter: m.twitterUsernames.join(', '), 79 160 bsky: m.bskyIdentifier, 80 161 enabled: m.enabled, 81 162 })), ··· 97 178 name: 'id', 98 179 message: 'Select a mapping to remove:', 99 180 choices: config.mappings.map((m) => ({ 100 - name: `${m.twitterUsername} -> ${m.bskyIdentifier}`, 181 + name: `${m.twitterUsernames.join(', ')} -> ${m.bskyIdentifier}`, 101 182 value: m.id, 102 183 })), 103 184 }, ··· 121 202 name: 'id', 122 203 message: 'Select a mapping to import history for:', 123 204 choices: config.mappings.map((m) => ({ 124 - name: `${m.twitterUsername} -> ${m.bskyIdentifier}`, 205 + name: `${m.twitterUsernames.join(', ')} -> ${m.bskyIdentifier}`, 125 206 value: m.id, 126 207 })), 127 208 }, ··· 131 212 if (!mapping) return; 132 213 133 214 console.log(` 134 - To import history for ${mapping.twitterUsername}, run:`); 135 - console.log(` npm run import -- --username ${mapping.twitterUsername}`); 215 + To import history, run one of the following commands:`); 216 + for (const username of mapping.twitterUsernames) { 217 + console.log(` npm run import -- --username ${username}`); 218 + } 136 219 console.log(` 137 220 You can also use additional flags:`); 138 221 console.log(' --limit <number> Limit the number of tweets to import'); 139 222 console.log(' --dry-run Fetch and show tweets without posting'); 140 223 console.log(` 141 224 Example:`); 142 - console.log(` npm run import -- --username ${mapping.twitterUsername} --limit 10 --dry-run 225 + console.log(` npm run import -- --username ${mapping.twitterUsernames[0]} --limit 10 --dry-run 143 226 `); 144 227 }); 145 228
+35 -2
src/config-manager.ts
··· 19 19 20 20 export interface AccountMapping { 21 21 id: string; 22 - twitterUsername: string; 22 + twitterUsernames: string[]; 23 23 bskyIdentifier: string; 24 24 bskyPassword: string; 25 25 bskyServiceUrl?: string; ··· 46 46 try { 47 47 const config = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')); 48 48 if (!config.users) config.users = []; 49 + 50 + // Migration: twitterUsername (string) -> twitterUsernames (string[]) 51 + // biome-ignore lint/suspicious/noExplicitAny: migration logic 52 + config.mappings = config.mappings.map((m: any) => { 53 + if (m.twitterUsername && !m.twitterUsernames) { 54 + return { 55 + ...m, 56 + twitterUsernames: [m.twitterUsername], 57 + }; 58 + } 59 + return m; 60 + }); 61 + 49 62 return config; 50 63 } catch (err) { 51 64 console.error('Error reading config:', err); ··· 58 71 } 59 72 } 60 73 export function saveConfig(config: AppConfig): void { 61 - fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2)); 74 + // biome-ignore lint/suspicious/noExplicitAny: cleanup before save 75 + const configToSave = { ...config } as any; 76 + 77 + // Remove legacy field from saved file 78 + configToSave.mappings = configToSave.mappings.map((m: any) => { 79 + const { twitterUsername, ...rest } = m; 80 + return rest; 81 + }); 82 + 83 + fs.writeFileSync(CONFIG_FILE, JSON.stringify(configToSave, null, 2)); 62 84 } 63 85 64 86 export function addMapping(mapping: Omit<AccountMapping, 'id' | 'enabled'>): void { ··· 70 92 }; 71 93 config.mappings.push(newMapping); 72 94 saveConfig(config); 95 + } 96 + 97 + export function updateMapping(id: string, updates: Partial<Omit<AccountMapping, 'id'>>): void { 98 + const config = getConfig(); 99 + const index = config.mappings.findIndex((m) => m.id === id); 100 + const existing = config.mappings[index]; 101 + 102 + if (index !== -1 && existing) { 103 + config.mappings[index] = { ...existing, ...updates }; 104 + saveConfig(config); 105 + } 73 106 } 74 107 75 108 export function removeMapping(id: string): void {
+26 -13
src/index.ts
··· 133 133 for (const file of files) { 134 134 const username = file.replace('.json', '').toLowerCase(); 135 135 // Try to find a matching bskyIdentifier from config 136 - const mapping = config.mappings.find(m => m.twitterUsername.toLowerCase() === username); 136 + const mapping = config.mappings.find(m => m.twitterUsernames.map(u => u.toLowerCase()).includes(username)); 137 137 const bskyIdentifier = mapping?.bskyIdentifier || 'unknown'; 138 138 139 139 try { ··· 163 163 164 164 // REPAIR STEP: Fix any 'unknown' records in SQLite that came from the broken schema migration 165 165 for (const mapping of config.mappings) { 166 - dbService.repairUnknownIdentifiers(mapping.twitterUsername, mapping.bskyIdentifier); 166 + for (const username of mapping.twitterUsernames) { 167 + dbService.repairUnknownIdentifiers(username, mapping.bskyIdentifier); 168 + } 167 169 } 168 170 169 171 console.log('โœ… Migration complete.'); ··· 1013 1015 if (!agent) continue; 1014 1016 1015 1017 const backfillReq = getPendingBackfills().find(b => b.id === mapping.id); 1018 + 1019 + // If backfill is requested, we might need to know WHICH username to backfill if specified, 1020 + // but current logic assumes backfill is for the mapping ID. 1021 + // If we want to support per-username backfill, we'd need to update the backfill request structure. 1022 + // For now, if backfill is requested for the MAPPING, we'll backfill ALL usernames in that mapping. 1023 + 1016 1024 if (forceBackfill || backfillReq) { 1017 1025 const limit = backfillReq?.limit || 15; 1018 - console.log(`[${mapping.twitterUsername}] Running backfill (limit ${limit})...`); 1019 - updateAppStatus({ state: 'backfilling', currentAccount: mapping.twitterUsername, message: `Starting backfill (limit ${limit})...` }); 1020 - await importHistory(mapping.twitterUsername, mapping.bskyIdentifier, limit, dryRun); 1026 + console.log(`[${mapping.bskyIdentifier}] Running backfill for ${mapping.twitterUsernames.length} accounts (limit ${limit})...`); 1027 + 1028 + for (const twitterUsername of mapping.twitterUsernames) { 1029 + updateAppStatus({ state: 'backfilling', currentAccount: twitterUsername, message: `Starting backfill (limit ${limit})...` }); 1030 + await importHistory(twitterUsername, mapping.bskyIdentifier, limit, dryRun); 1031 + } 1021 1032 clearBackfill(mapping.id); 1022 - console.log(`[${mapping.twitterUsername}] Backfill complete.`); 1033 + console.log(`[${mapping.bskyIdentifier}] Backfill complete.`); 1023 1034 } else { 1024 - updateAppStatus({ state: 'checking', currentAccount: mapping.twitterUsername, message: 'Fetching latest tweets...' }); 1025 - const result = await safeSearch(`from:${mapping.twitterUsername}`, 30); 1026 - if (!result.success || !result.tweets) continue; 1027 - await processTweets(agent, mapping.twitterUsername, mapping.bskyIdentifier, result.tweets, dryRun); 1035 + for (const twitterUsername of mapping.twitterUsernames) { 1036 + updateAppStatus({ state: 'checking', currentAccount: twitterUsername, message: 'Fetching latest tweets...' }); 1037 + const result = await safeSearch(`from:${twitterUsername}`, 30); 1038 + if (!result.success || !result.tweets) continue; 1039 + await processTweets(agent, twitterUsername, mapping.bskyIdentifier, result.tweets, dryRun); 1040 + } 1028 1041 } 1029 1042 } catch (err) { 1030 - console.error(`Error processing mapping ${mapping.twitterUsername}:`, err); 1043 + console.error(`Error processing mapping ${mapping.bskyIdentifier}:`, err); 1031 1044 } 1032 1045 } 1033 1046 ··· 1040 1053 1041 1054 async function importHistory(twitterUsername: string, bskyIdentifier: string, limit = 15, dryRun = false): Promise<void> { 1042 1055 const config = getConfig(); 1043 - const mapping = config.mappings.find((m) => m.twitterUsername.toLowerCase() === twitterUsername.toLowerCase()); 1056 + const mapping = config.mappings.find((m) => m.twitterUsernames.map(u => u.toLowerCase()).includes(twitterUsername.toLowerCase())); 1044 1057 if (!mapping) { 1045 1058 console.error(`No mapping found for twitter username: ${twitterUsername}`); 1046 1059 return; ··· 1154 1167 console.error('Twitter credentials not set. Cannot import history.'); 1155 1168 process.exit(1); 1156 1169 } 1157 - const mapping = config.mappings.find(m => m.twitterUsername.toLowerCase() === options.username.toLowerCase()); 1170 + const mapping = config.mappings.find(m => m.twitterUsernames.map(u => u.toLowerCase()).includes(options.username.toLowerCase())); 1158 1171 if (!mapping) { 1159 1172 console.error(`No mapping found for ${options.username}`); 1160 1173 process.exit(1);
+53 -5
src/server.ts
··· 111 111 }); 112 112 113 113 app.post('/api/mappings', authenticateToken, (req, res) => { 114 - const { twitterUsername, bskyIdentifier, bskyPassword, bskyServiceUrl, owner } = req.body; 114 + const { twitterUsernames, bskyIdentifier, bskyPassword, bskyServiceUrl, owner } = req.body; 115 115 const config = getConfig(); 116 116 117 + // Handle both array and comma-separated string 118 + let usernames: string[] = []; 119 + if (Array.isArray(twitterUsernames)) { 120 + usernames = twitterUsernames; 121 + } else if (typeof twitterUsernames === 'string') { 122 + usernames = twitterUsernames.split(',').map(u => u.trim()).filter(u => u.length > 0); 123 + } 124 + 117 125 const newMapping = { 118 126 id: Math.random().toString(36).substring(7), 119 - twitterUsername, 127 + twitterUsernames: usernames, 120 128 bskyIdentifier, 121 129 bskyPassword, 122 130 bskyServiceUrl: bskyServiceUrl || 'https://bsky.social', ··· 129 137 res.json(newMapping); 130 138 }); 131 139 140 + app.put('/api/mappings/:id', authenticateToken, (req, res) => { 141 + const { id } = req.params; 142 + const { twitterUsernames, bskyIdentifier, bskyPassword, bskyServiceUrl, owner } = req.body; 143 + const config = getConfig(); 144 + 145 + const index = config.mappings.findIndex((m) => m.id === id); 146 + const existingMapping = config.mappings[index]; 147 + 148 + if (index === -1 || !existingMapping) { 149 + res.status(404).json({ error: 'Mapping not found' }); 150 + return; 151 + } 152 + 153 + let usernames: string[] = existingMapping.twitterUsernames; 154 + if (twitterUsernames !== undefined) { 155 + if (Array.isArray(twitterUsernames)) { 156 + usernames = twitterUsernames; 157 + } else if (typeof twitterUsernames === 'string') { 158 + usernames = twitterUsernames.split(',').map(u => u.trim()).filter(u => u.length > 0); 159 + } 160 + } 161 + 162 + const updatedMapping = { 163 + ...existingMapping, 164 + twitterUsernames: usernames, 165 + bskyIdentifier: bskyIdentifier || existingMapping.bskyIdentifier, 166 + // Only update password if provided 167 + bskyPassword: bskyPassword || existingMapping.bskyPassword, 168 + bskyServiceUrl: bskyServiceUrl || existingMapping.bskyServiceUrl, 169 + owner: owner || existingMapping.owner, 170 + }; 171 + 172 + config.mappings[index] = updatedMapping; 173 + saveConfig(config); 174 + res.json(updatedMapping); 175 + }); 176 + 132 177 app.delete('/api/mappings/:id', authenticateToken, (req, res) => { 133 178 const { id } = req.params; 134 179 const config = getConfig(); ··· 146 191 return; 147 192 } 148 193 149 - dbService.deleteTweetsByUsername(mapping.twitterUsername); 150 - res.json({ success: true, message: 'Cache cleared' }); 194 + for (const username of mapping.twitterUsernames) { 195 + dbService.deleteTweetsByUsername(username); 196 + } 197 + 198 + res.json({ success: true, message: 'Cache cleared for all associated accounts' }); 151 199 }); 152 200 153 201 // --- Twitter Config Routes (Admin Only) --- ··· 206 254 207 255 lastCheckTime = 0; 208 256 nextCheckTime = Date.now() + 1000; 209 - res.json({ success: true, message: `Backfill queued for @${mapping.twitterUsername}` }); 257 + res.json({ success: true, message: `Backfill queued for @${mapping.twitterUsernames.join(', ')}` }); 210 258 }); 211 259 212 260 app.delete('/api/backfill/:id', authenticateToken, (req, res) => {
+38
update.sh
··· 1 + #!/bin/bash 2 + 3 + echo "๐Ÿ”„ Tweets-2-Bsky Updater" 4 + echo "=========================" 5 + 6 + # Check if git is available 7 + if ! command -v git &> /dev/null; then 8 + echo "โŒ Git is not installed. Please install git to update." 9 + exit 1 10 + fi 11 + 12 + echo "โฌ‡๏ธ Pulling latest changes..." 13 + git pull 14 + 15 + if [ $? -ne 0 ]; then 16 + echo "โŒ Git pull failed. You might have local changes." 17 + echo " Try 'git stash' to save your changes, then run this script again." 18 + exit 1 19 + fi 20 + 21 + echo "๐Ÿ“ฆ Installing dependencies..." 22 + npm install 23 + 24 + if [ $? -ne 0 ]; then 25 + echo "โŒ npm install failed." 26 + exit 1 27 + fi 28 + 29 + echo "๐Ÿ—๏ธ Building project..." 30 + npm run build 31 + 32 + if [ $? -ne 0 ]; then 33 + echo "โŒ Build failed." 34 + exit 1 35 + fi 36 + 37 + echo "โœ… Update complete!" 38 + echo "โš ๏ธ Please restart your application service now (e.g., 'pm2 restart tweets-2-bsky' or stop and start the node process)."