Add Bluesky replies, quotes, and reposts to any web page. Handy for adding a comments section anywhere.
at main 267 lines 8.5 kB view raw
1#!/usr/bin/env node 2 3/** 4 * Hides a reply or detaches a quote post from the conversation component. 5 * 6 * For replies: adds the reply URI to the root post's threadgate hiddenReplies 7 * ("hide reply for everyone" on bsky.app). 8 * 9 * For quote posts: adds the quote URI to the root post's postgate 10 * detachedEmbeddingUris ("detach quote" on bsky.app). 11 * 12 * The script auto-detects whether the URL is a reply or quote post. 13 * 14 * Usage: 15 * npm run hide-reply <post-url> 16 * 17 * Examples: 18 * npm run hide-reply https://bsky.app/profile/did:plc:.../post/... (reply) 19 * npm run hide-reply https://bsky.app/profile/did:plc:.../post/... (quote) 20 * 21 * Requirements: 22 * - ATPROTO_HANDLE and ATPROTO_APP_PASSWORD in .env 23 */ 24 25import { Client } from '@atproto/lex' 26import { PasswordSession } from '@atproto/lex-password-session' 27 28const PUBLIC_API = 'https://public.api.bsky.app/xrpc' 29 30/** 31 * Convert a bsky.app URL to an AT URI. 32 */ 33function toAtUri(url) { 34 const m = url.match(/bsky\.app\/profile\/([^/]+)\/post\/([^/?#]+)/) 35 if (!m) return null 36 return `at://${m[1]}/app.bsky.feed.post/${m[2]}` 37} 38 39/** 40 * Extract the rkey from an AT URI. 41 */ 42function rkey(atUri) { 43 return atUri.split('/').pop() 44} 45 46/** 47 * Find which of our posts this quote embeds, if any. 48 * Handles both plain quotes (app.bsky.embed.record) and 49 * quotes with media (app.bsky.embed.recordWithMedia). 50 */ 51function findQuotedUri(post, ownerDid) { 52 const record = post.record 53 if (!record?.embed) return null 54 55 let ref = null 56 if (record.embed.$type === 'app.bsky.embed.record') { 57 ref = record.embed.record 58 } else if (record.embed.$type === 'app.bsky.embed.recordWithMedia') { 59 ref = record.embed.record?.record 60 } 61 62 if (!ref?.uri) return null 63 // Only match if the quoted post belongs to the authenticated user 64 if (ref.uri.startsWith(`at://${ownerDid}/`)) return ref.uri 65 return null 66} 67 68async function main() { 69 const postUrl = process.argv[2] 70 71 if (!postUrl) { 72 console.error('Usage: npm run hide-reply <post-url>') 73 console.error('Example: npm run hide-reply https://bsky.app/profile/did:plc:.../post/...') 74 process.exit(1) 75 } 76 77 const postUri = toAtUri(postUrl) 78 if (!postUri) { 79 console.error('Error: Could not parse bsky.app URL.') 80 console.error('Expected format: https://bsky.app/profile/<did-or-handle>/post/<rkey>') 81 process.exit(1) 82 } 83 84 // Load environment variables 85 const { ATPROTO_HANDLE, ATPROTO_APP_PASSWORD, ATPROTO_PDS_URL } = process.env 86 87 if (!ATPROTO_HANDLE || !ATPROTO_APP_PASSWORD) { 88 console.error('Error: Missing required environment variables.') 89 console.error('Please set ATPROTO_HANDLE and ATPROTO_APP_PASSWORD in your .env file.') 90 process.exit(1) 91 } 92 93 const service = ATPROTO_PDS_URL || 'https://bsky.social' 94 95 // Step 1: Fetch the post to determine what it is 96 console.log(`\n🔍 Analyzing post...`) 97 console.log(` URI: ${postUri}`) 98 99 const threadRes = await fetch( 100 `${PUBLIC_API}/app.bsky.feed.getPostThread?uri=${encodeURIComponent(postUri)}&depth=0&parentHeight=100` 101 ) 102 if (!threadRes.ok) { 103 console.error(`Error: Failed to fetch post (${threadRes.status})`) 104 process.exit(1) 105 } 106 107 const threadData = await threadRes.json() 108 const thread = threadData.thread 109 110 if (!thread || thread.$type !== 'app.bsky.feed.defs#threadViewPost') { 111 console.error('Error: Could not load post. It may have been deleted or blocked.') 112 process.exit(1) 113 } 114 115 // Step 2: Authenticate 116 console.log('\n🔐 Authenticating...') 117 118 const session = await PasswordSession.create({ 119 service, 120 identifier: ATPROTO_HANDLE, 121 password: ATPROTO_APP_PASSWORD, 122 onUpdated: () => {}, 123 onDeleted: () => {}, 124 }) 125 126 const client = new Client(session) 127 console.log(` ✓ Authenticated as ${session.handle}`) 128 129 // Step 3: Determine if this is a reply or a quote post 130 const hasParent = thread.parent && thread.parent.$type === 'app.bsky.feed.defs#threadViewPost' 131 const quotedUri = findQuotedUri(thread.post, session.did) 132 133 if (hasParent) { 134 await hideReply(client, session, thread, postUri) 135 } else if (quotedUri) { 136 await detachQuote(client, session, quotedUri, postUri) 137 } else { 138 console.error('\nError: This post is neither a reply to one of your posts nor a quote of one of your posts.') 139 process.exit(1) 140 } 141} 142 143/** 144 * Hide a reply by adding it to the root post's threadgate hiddenReplies. 145 */ 146async function hideReply(client, session, thread, replyUri) { 147 // Walk up to the root 148 let node = thread 149 while (node.parent && node.parent.$type === 'app.bsky.feed.defs#threadViewPost') { 150 node = node.parent 151 } 152 153 const rootUri = node.post.uri 154 const rootRkey = rkey(rootUri) 155 const rootDid = rootUri.replace('at://', '').split('/')[0] 156 157 console.log(`\n Type: Reply`) 158 console.log(` Root: ${rootUri}`) 159 console.log(` By: ${node.post.author.handle}`) 160 161 if (session.did !== rootDid) { 162 console.error(`\nError: You are authenticated as ${session.did} but the root post belongs to ${rootDid}.`) 163 console.error('You can only hide replies on your own posts.') 164 process.exit(1) 165 } 166 167 // Get existing threadgate 168 console.log('\n📋 Checking for existing threadgate...') 169 170 let existingValue = null 171 try { 172 const res = await client.getRecord('app.bsky.feed.threadgate', rootRkey) 173 existingValue = res.payload.body.value 174 const hidden = existingValue.hiddenReplies || [] 175 console.log(` Found threadgate with ${hidden.length} hidden replies`) 176 177 if (hidden.includes(replyUri)) { 178 console.log('\n⚠️ This reply is already hidden. Nothing to do.') 179 process.exit(0) 180 } 181 } catch (err) { 182 // If the record genuinely doesn't exist, create a new one. 183 // But if it's a network/auth error, bail out to avoid overwriting existing data. 184 const status = err?.status ?? err?.response?.status 185 if (status && status !== 400 && status !== 404) { 186 console.error(`\nError: Failed to read existing threadgate (${status}). Aborting to avoid data loss.`) 187 process.exit(1) 188 } 189 console.log(' No existing threadgate — will create one') 190 } 191 192 const record = existingValue 193 ? { 194 ...existingValue, 195 hiddenReplies: [...(existingValue.hiddenReplies || []), replyUri], 196 } 197 : { 198 $type: 'app.bsky.feed.threadgate', 199 post: rootUri, 200 createdAt: new Date().toISOString(), 201 hiddenReplies: [replyUri], 202 } 203 204 console.log('\n📤 Updating threadgate...') 205 const result = await client.putRecord(record, rootRkey) 206 207 console.log('\n✅ Reply hidden successfully!') 208 console.log(` URI: ${result.uri}`) 209 console.log(` Hidden replies: ${record.hiddenReplies.length}`) 210} 211 212/** 213 * Detach a quote post by adding it to the root post's postgate detachedEmbeddingUris. 214 */ 215async function detachQuote(client, session, quotedUri, quoteUri) { 216 const quotedRkey = rkey(quotedUri) 217 218 console.log(`\n Type: Quote post`) 219 console.log(` Quotes: ${quotedUri}`) 220 221 // Get existing postgate 222 console.log('\n📋 Checking for existing postgate...') 223 224 let existingValue = null 225 try { 226 const res = await client.getRecord('app.bsky.feed.postgate', quotedRkey) 227 existingValue = res.payload.body.value 228 const detached = existingValue.detachedEmbeddingUris || [] 229 console.log(` Found postgate with ${detached.length} detached quotes`) 230 231 if (detached.includes(quoteUri)) { 232 console.log('\n⚠️ This quote is already detached. Nothing to do.') 233 process.exit(0) 234 } 235 } catch (err) { 236 const status = err?.status ?? err?.response?.status 237 if (status && status !== 400 && status !== 404) { 238 console.error(`\nError: Failed to read existing postgate (${status}). Aborting to avoid data loss.`) 239 process.exit(1) 240 } 241 console.log(' No existing postgate — will create one') 242 } 243 244 const record = existingValue 245 ? { 246 ...existingValue, 247 detachedEmbeddingUris: [...(existingValue.detachedEmbeddingUris || []), quoteUri], 248 } 249 : { 250 $type: 'app.bsky.feed.postgate', 251 post: quotedUri, 252 createdAt: new Date().toISOString(), 253 detachedEmbeddingUris: [quoteUri], 254 } 255 256 console.log('\n📤 Updating postgate...') 257 const result = await client.putRecord(record, quotedRkey) 258 259 console.log('\n✅ Quote detached successfully!') 260 console.log(` URI: ${result.uri}`) 261 console.log(` Detached quotes: ${record.detachedEmbeddingUris.length}`) 262} 263 264main().catch((err) => { 265 console.error('Unexpected error:', err) 266 process.exit(1) 267})