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: add delete all posts functionality

- Refactor Bluesky agent logic to src/bsky.ts\n- Add deleteAllPosts utility\n- Add admin route and UI button to wipe account posts and cache

jack 49dbca47 9170a34c

+127 -24
+14
public/index.html
··· 360 360 } 361 361 }; 362 362 363 + const deleteAllPosts = async (id) => { 364 + if (!confirm('DANGER: This will delete ALL posts on the associated Bluesky account. Are you absolutely sure?')) return; 365 + const confirmName = prompt('Type "DELETE" to confirm:'); 366 + if (confirmName !== 'DELETE') return; 367 + 368 + try { 369 + const res = await axios.post(`/api/mappings/${id}/delete-all-posts`, {}, { headers: { Authorization: `Bearer ${token}` } }); 370 + alert(res.data.message); 371 + } catch (err) { 372 + alert('Failed to delete posts: ' + (err.response?.data?.error || err.message)); 373 + } 374 + }; 375 + 363 376 const updateTwitter = async (e) => { 364 377 e.preventDefault(); 365 378 const formData = new FormData(e.target); ··· 603 616 <li><button className="dropdown-item" onClick={() => setEditingMapping(m)}>Edit</button></li> 604 617 <li><button className="dropdown-item" onClick={() => runBackfill(m.id)}>Backfill History</button></li> 605 618 <li><button className="dropdown-item text-warning" onClick={() => resetAndBackfill(m.id)}>Reset Cache & Backfill</button></li> 619 + <li><button className="dropdown-item text-danger fw-bold" onClick={() => deleteAllPosts(m.id)}>Danger: Delete All Posts</button></li> 606 620 <li><hr className="dropdown-divider"/></li> 607 621 </> 608 622 )}
+81
src/bsky.ts
··· 1 + import { BskyAgent } from '@atproto/api'; 2 + import { getConfig } from './config-manager.js'; 3 + 4 + const activeAgents = new Map<string, BskyAgent>(); 5 + 6 + export async function getAgent(mapping: { 7 + bskyIdentifier: string; 8 + bskyPassword: string; 9 + bskyServiceUrl?: string; 10 + }): Promise<BskyAgent | null> { 11 + const serviceUrl = mapping.bskyServiceUrl || 'https://bsky.social'; 12 + const cacheKey = `${mapping.bskyIdentifier}-${serviceUrl}`; 13 + const existing = activeAgents.get(cacheKey); 14 + if (existing) return existing; 15 + 16 + const agent = new BskyAgent({ service: serviceUrl }); 17 + try { 18 + await agent.login({ identifier: mapping.bskyIdentifier, password: mapping.bskyPassword }); 19 + activeAgents.set(cacheKey, agent); 20 + return agent; 21 + } catch (err) { 22 + console.error(`Failed to login to Bluesky for ${mapping.bskyIdentifier} on ${serviceUrl}:`, err); 23 + return null; 24 + } 25 + } 26 + 27 + export async function deleteAllPosts(mappingId: string): Promise<number> { 28 + const config = getConfig(); 29 + const mapping = config.mappings.find(m => m.id === mappingId); 30 + if (!mapping) throw new Error('Mapping not found'); 31 + 32 + const agent = await getAgent(mapping); 33 + if (!agent) throw new Error('Failed to authenticate with Bluesky'); 34 + 35 + let cursor: string | undefined; 36 + let deletedCount = 0; 37 + 38 + console.log(`[${mapping.bskyIdentifier}] 🗑️ Starting deletion of all posts...`); 39 + 40 + // Safety loop limit to prevent infinite loops 41 + let loops = 0; 42 + while (loops < 1000) { 43 + loops++; 44 + try { 45 + const { data } = await agent.com.atproto.repo.listRecords({ 46 + repo: agent.session!.did, 47 + collection: 'app.bsky.feed.post', 48 + limit: 50, // Keep batch size reasonable 49 + cursor, 50 + }); 51 + 52 + if (data.records.length === 0) break; 53 + 54 + console.log(`[${mapping.bskyIdentifier}] 🗑️ Deleting batch of ${data.records.length} posts...`); 55 + 56 + // Use p-limit like approach or just Promise.all since 50 is manageable 57 + await Promise.all(data.records.map(r => 58 + agent.com.atproto.repo.deleteRecord({ 59 + repo: agent.session!.did, 60 + collection: 'app.bsky.feed.post', 61 + rkey: r.uri.split('/').pop()!, 62 + }).catch(e => console.warn(`Failed to delete record ${r.uri}:`, e)) 63 + )); 64 + 65 + deletedCount += data.records.length; 66 + cursor = data.cursor; 67 + 68 + if (!cursor) break; 69 + 70 + // Small delay to be nice to the server 71 + await new Promise(r => setTimeout(r, 500)); 72 + 73 + } catch (err) { 74 + console.error(`[${mapping.bskyIdentifier}] ❌ Error during deletion loop:`, err); 75 + throw err; 76 + } 77 + } 78 + 79 + console.log(`[${mapping.bskyIdentifier}] ✅ Deleted ${deletedCount} posts.`); 80 + return deletedCount; 81 + }
+5
src/db.ts
··· 233 233 stmt.run(username.toLowerCase()); 234 234 }, 235 235 236 + deleteTweetsByBskyIdentifier(bskyIdentifier: string) { 237 + const stmt = db.prepare('DELETE FROM processed_tweets WHERE bsky_identifier = ?'); 238 + stmt.run(bskyIdentifier.toLowerCase()); 239 + }, 240 + 236 241 repairUnknownIdentifiers(twitterUsername: string, bskyIdentifier: string) { 237 242 const stmt = db.prepare( 238 243 'UPDATE processed_tweets SET bsky_identifier = ? WHERE bsky_identifier = "unknown" AND twitter_username = ?',
+1 -24
src/index.ts
··· 1369 1369 } 1370 1370 } 1371 1371 1372 - const activeAgents = new Map<string, BskyAgent>(); 1373 - 1374 - async function getAgent(mapping: { 1375 - bskyIdentifier: string; 1376 - bskyPassword: string; 1377 - bskyServiceUrl?: string; 1378 - }): Promise<BskyAgent | null> { 1379 - const serviceUrl = mapping.bskyServiceUrl || 'https://bsky.social'; 1380 - const cacheKey = `${mapping.bskyIdentifier}-${serviceUrl}`; 1381 - const existing = activeAgents.get(cacheKey); 1382 - if (existing) return existing; 1383 - 1384 - const agent = new BskyAgent({ service: serviceUrl }); 1385 - try { 1386 - await agent.login({ identifier: mapping.bskyIdentifier, password: mapping.bskyPassword }); 1387 - activeAgents.set(cacheKey, agent); 1388 - return agent; 1389 - } catch (err) { 1390 - console.error(`Failed to login to Bluesky for ${mapping.bskyIdentifier} on ${serviceUrl}:`, err); 1391 - return null; 1392 - } 1393 - } 1394 - 1395 - 1372 + import { getAgent } from './bsky.js'; 1396 1373 1397 1374 async function importHistory(twitterUsername: string, bskyIdentifier: string, limit = 15, dryRun = false, ignoreCancellation = false): Promise<void> { 1398 1375 const config = getConfig();
+26
src/server.ts
··· 4 4 import cors from 'cors'; 5 5 import express from 'express'; 6 6 import jwt from 'jsonwebtoken'; 7 + import { deleteAllPosts } from './bsky.js'; 7 8 import { getConfig, saveConfig } from './config-manager.js'; 8 9 import { dbService } from './db.js'; 9 10 ··· 201 202 } 202 203 203 204 res.json({ success: true, message: 'Cache cleared for all associated accounts' }); 205 + }); 206 + 207 + app.post('/api/mappings/:id/delete-all-posts', authenticateToken, requireAdmin, async (req, res) => { 208 + const { id } = req.params; 209 + const config = getConfig(); 210 + const mapping = config.mappings.find((m) => m.id === id); 211 + if (!mapping) { 212 + res.status(404).json({ error: 'Mapping not found' }); 213 + return; 214 + } 215 + 216 + try { 217 + const deletedCount = await deleteAllPosts(id); 218 + 219 + // Clear local cache to stay in sync 220 + dbService.deleteTweetsByBskyIdentifier(mapping.bskyIdentifier); 221 + 222 + res.json({ 223 + success: true, 224 + message: `Deleted ${deletedCount} posts from ${mapping.bskyIdentifier} and cleared local cache.` 225 + }); 226 + } catch (err) { 227 + console.error('Failed to delete all posts:', err); 228 + res.status(500).json({ error: (err as Error).message }); 229 + } 204 230 }); 205 231 206 232 // --- Twitter Config Routes (Admin Only) ---