Add Bluesky replies, quotes, and reposts to any web page. Handy for adding a comments section anywhere.
9
fork

Configure Feed

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

Add hide-reply moderation script and .env.example

CLI tool to hide replies (threadgate) or detach quotes (postgate)
from conversations. Uses @atproto/lex SDK as a dev dependency.

Jim Ray 56957300 48b7b318

+278
+14
.env.example
··· 1 + # AT Protocol credentials for the hide-reply moderation script. 2 + # 3 + # Copy this file to .env and fill in your credentials. 4 + # NEVER commit .env to version control. 5 + 6 + # Your Bluesky handle (e.g., yourname.bsky.social or custom domain) 7 + ATPROTO_HANDLE= 8 + 9 + # App password from Bluesky settings (Settings > App Passwords) 10 + # Create a new app password specifically for this use case 11 + ATPROTO_APP_PASSWORD= 12 + 13 + # Optional: PDS URL (defaults to https://bsky.social) 14 + # ATPROTO_PDS_URL=https://bsky.social
+2
.gitignore
··· 1 + .env 2 + node_modules/
+7
package.json
··· 5 5 "type": "module", 6 6 "main": "bsky-conversation.js", 7 7 "module": "bsky-conversation.js", 8 + "scripts": { 9 + "hide-reply": "node --env-file=.env scripts/hide-reply.mjs" 10 + }, 8 11 "exports": { 9 12 ".": { 10 13 "import": "./bsky-conversation.js", ··· 21 24 "conversation", 22 25 "comments" 23 26 ], 27 + "devDependencies": { 28 + "@atproto/lex": "^0.0.11", 29 + "@atproto/lex-password-session": "^0.0.2" 30 + }, 24 31 "license": "MIT", 25 32 "repository": { 26 33 "type": "git",
+255
scripts/hide-reply.mjs
··· 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 + 25 + import { Client } from '@atproto/lex' 26 + import { PasswordSession } from '@atproto/lex-password-session' 27 + 28 + const PUBLIC_API = 'https://public.api.bsky.app/xrpc' 29 + 30 + /** 31 + * Convert a bsky.app URL to an AT URI. 32 + */ 33 + function 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 + */ 42 + function 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 + */ 51 + function 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 + 68 + async 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 + */ 146 + async 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 { 182 + console.log(' No existing threadgate — will create one') 183 + } 184 + 185 + const record = existingValue 186 + ? { 187 + ...existingValue, 188 + hiddenReplies: [...(existingValue.hiddenReplies || []), replyUri], 189 + } 190 + : { 191 + $type: 'app.bsky.feed.threadgate', 192 + post: rootUri, 193 + createdAt: new Date().toISOString(), 194 + hiddenReplies: [replyUri], 195 + } 196 + 197 + console.log('\n📤 Updating threadgate...') 198 + const result = await client.putRecord(record, rootRkey) 199 + 200 + console.log('\n✅ Reply hidden successfully!') 201 + console.log(` URI: ${result.uri}`) 202 + console.log(` Hidden replies: ${record.hiddenReplies.length}`) 203 + } 204 + 205 + /** 206 + * Detach a quote post by adding it to the root post's postgate detachedEmbeddingUris. 207 + */ 208 + async function detachQuote(client, session, quotedUri, quoteUri) { 209 + const quotedRkey = rkey(quotedUri) 210 + 211 + console.log(`\n Type: Quote post`) 212 + console.log(` Quotes: ${quotedUri}`) 213 + 214 + // Get existing postgate 215 + console.log('\n📋 Checking for existing postgate...') 216 + 217 + let existingValue = null 218 + try { 219 + const res = await client.getRecord('app.bsky.feed.postgate', quotedRkey) 220 + existingValue = res.payload.body.value 221 + const detached = existingValue.detachedEmbeddingUris || [] 222 + console.log(` Found postgate with ${detached.length} detached quotes`) 223 + 224 + if (detached.includes(quoteUri)) { 225 + console.log('\n⚠️ This quote is already detached. Nothing to do.') 226 + process.exit(0) 227 + } 228 + } catch { 229 + console.log(' No existing postgate — will create one') 230 + } 231 + 232 + const record = existingValue 233 + ? { 234 + ...existingValue, 235 + detachedEmbeddingUris: [...(existingValue.detachedEmbeddingUris || []), quoteUri], 236 + } 237 + : { 238 + $type: 'app.bsky.feed.postgate', 239 + post: quotedUri, 240 + createdAt: new Date().toISOString(), 241 + detachedEmbeddingUris: [quoteUri], 242 + } 243 + 244 + console.log('\n📤 Updating postgate...') 245 + const result = await client.putRecord(record, quotedRkey) 246 + 247 + console.log('\n✅ Quote detached successfully!') 248 + console.log(` URI: ${result.uri}`) 249 + console.log(` Detached quotes: ${record.detachedEmbeddingUris.length}`) 250 + } 251 + 252 + main().catch((err) => { 253 + console.error('Unexpected error:', err) 254 + process.exit(1) 255 + })