diff --git a/public/index.html b/public/index.html index f8bc926..69d77ef 100644 --- a/public/index.html +++ b/public/index.html @@ -360,6 +360,19 @@ } }; + const deleteAllPosts = async (id) => { + if (!confirm('DANGER: This will delete ALL posts on the associated Bluesky account. Are you absolutely sure?')) return; + const confirmName = prompt('Type "DELETE" to confirm:'); + if (confirmName !== 'DELETE') return; + + try { + const res = await axios.post(`/api/mappings/${id}/delete-all-posts`, {}, { headers: { Authorization: `Bearer ${token}` } }); + alert(res.data.message); + } catch (err) { + alert('Failed to delete posts: ' + (err.response?.data?.error || err.message)); + } + }; + const updateTwitter = async (e) => { e.preventDefault(); const formData = new FormData(e.target); @@ -603,6 +616,7 @@
  • +

  • )} diff --git a/src/bsky.ts b/src/bsky.ts new file mode 100644 index 0000000..418d955 --- /dev/null +++ b/src/bsky.ts @@ -0,0 +1,81 @@ +import { BskyAgent } from '@atproto/api'; +import { getConfig } from './config-manager.js'; + +const activeAgents = new Map(); + +export async function getAgent(mapping: { + bskyIdentifier: string; + bskyPassword: string; + bskyServiceUrl?: string; +}): Promise { + const serviceUrl = mapping.bskyServiceUrl || 'https://bsky.social'; + const cacheKey = `${mapping.bskyIdentifier}-${serviceUrl}`; + const existing = activeAgents.get(cacheKey); + if (existing) return existing; + + const agent = new BskyAgent({ service: serviceUrl }); + try { + await agent.login({ identifier: mapping.bskyIdentifier, password: mapping.bskyPassword }); + activeAgents.set(cacheKey, agent); + return agent; + } catch (err) { + console.error(`Failed to login to Bluesky for ${mapping.bskyIdentifier} on ${serviceUrl}:`, err); + return null; + } +} + +export async function deleteAllPosts(mappingId: string): Promise { + const config = getConfig(); + const mapping = config.mappings.find(m => m.id === mappingId); + if (!mapping) throw new Error('Mapping not found'); + + const agent = await getAgent(mapping); + if (!agent) throw new Error('Failed to authenticate with Bluesky'); + + let cursor: string | undefined; + let deletedCount = 0; + + console.log(`[${mapping.bskyIdentifier}] 🗑️ Starting deletion of all posts...`); + + // Safety loop limit to prevent infinite loops + let loops = 0; + while (loops < 1000) { + loops++; + try { + const { data } = await agent.com.atproto.repo.listRecords({ + repo: agent.session!.did, + collection: 'app.bsky.feed.post', + limit: 50, // Keep batch size reasonable + cursor, + }); + + if (data.records.length === 0) break; + + console.log(`[${mapping.bskyIdentifier}] 🗑️ Deleting batch of ${data.records.length} posts...`); + + // Use p-limit like approach or just Promise.all since 50 is manageable + await Promise.all(data.records.map(r => + agent.com.atproto.repo.deleteRecord({ + repo: agent.session!.did, + collection: 'app.bsky.feed.post', + rkey: r.uri.split('/').pop()!, + }).catch(e => console.warn(`Failed to delete record ${r.uri}:`, e)) + )); + + deletedCount += data.records.length; + cursor = data.cursor; + + if (!cursor) break; + + // Small delay to be nice to the server + await new Promise(r => setTimeout(r, 500)); + + } catch (err) { + console.error(`[${mapping.bskyIdentifier}] ❌ Error during deletion loop:`, err); + throw err; + } + } + + console.log(`[${mapping.bskyIdentifier}] ✅ Deleted ${deletedCount} posts.`); + return deletedCount; +} diff --git a/src/db.ts b/src/db.ts index c78ef38..f5314c1 100644 --- a/src/db.ts +++ b/src/db.ts @@ -233,6 +233,11 @@ export const dbService = { stmt.run(username.toLowerCase()); }, + deleteTweetsByBskyIdentifier(bskyIdentifier: string) { + const stmt = db.prepare('DELETE FROM processed_tweets WHERE bsky_identifier = ?'); + stmt.run(bskyIdentifier.toLowerCase()); + }, + repairUnknownIdentifiers(twitterUsername: string, bskyIdentifier: string) { const stmt = db.prepare( 'UPDATE processed_tweets SET bsky_identifier = ? WHERE bsky_identifier = "unknown" AND twitter_username = ?', diff --git a/src/index.ts b/src/index.ts index a963926..abe0137 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1369,30 +1369,7 @@ async function processTweets( } } -const activeAgents = new Map(); - -async function getAgent(mapping: { - bskyIdentifier: string; - bskyPassword: string; - bskyServiceUrl?: string; -}): Promise { - const serviceUrl = mapping.bskyServiceUrl || 'https://bsky.social'; - const cacheKey = `${mapping.bskyIdentifier}-${serviceUrl}`; - const existing = activeAgents.get(cacheKey); - if (existing) return existing; - - const agent = new BskyAgent({ service: serviceUrl }); - try { - await agent.login({ identifier: mapping.bskyIdentifier, password: mapping.bskyPassword }); - activeAgents.set(cacheKey, agent); - return agent; - } catch (err) { - console.error(`Failed to login to Bluesky for ${mapping.bskyIdentifier} on ${serviceUrl}:`, err); - return null; - } -} - - +import { getAgent } from './bsky.js'; async function importHistory(twitterUsername: string, bskyIdentifier: string, limit = 15, dryRun = false, ignoreCancellation = false): Promise { const config = getConfig(); diff --git a/src/server.ts b/src/server.ts index dede06b..34504ad 100644 --- a/src/server.ts +++ b/src/server.ts @@ -4,6 +4,7 @@ import bcrypt from 'bcryptjs'; import cors from 'cors'; import express from 'express'; import jwt from 'jsonwebtoken'; +import { deleteAllPosts } from './bsky.js'; import { getConfig, saveConfig } from './config-manager.js'; import { dbService } from './db.js'; @@ -203,6 +204,31 @@ app.delete('/api/mappings/:id/cache', authenticateToken, requireAdmin, (req, res res.json({ success: true, message: 'Cache cleared for all associated accounts' }); }); +app.post('/api/mappings/:id/delete-all-posts', authenticateToken, requireAdmin, async (req, res) => { + const { id } = req.params; + const config = getConfig(); + const mapping = config.mappings.find((m) => m.id === id); + if (!mapping) { + res.status(404).json({ error: 'Mapping not found' }); + return; + } + + try { + const deletedCount = await deleteAllPosts(id); + + // Clear local cache to stay in sync + dbService.deleteTweetsByBskyIdentifier(mapping.bskyIdentifier); + + res.json({ + success: true, + message: `Deleted ${deletedCount} posts from ${mapping.bskyIdentifier} and cleared local cache.` + }); + } catch (err) { + console.error('Failed to delete all posts:', err); + res.status(500).json({ error: (err as Error).message }); + } +}); + // --- Twitter Config Routes (Admin Only) --- app.get('/api/twitter-config', authenticateToken, requireAdmin, (_req, res) => {