Add Bluesky replies, quotes, and reposts to any web page. Handy for adding a comments section anywhere.
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})