Barazo AppView backend
barazo.forum
1import { eq } from 'drizzle-orm'
2import type { PdsClient } from '../lib/pds-client.js'
3import type { Logger } from '../lib/logger.js'
4import type { Database } from '../db/index.js'
5import type { NotificationService } from './notification.js'
6import { generateOgImage } from './og-image.js'
7import { crossPosts } from '../db/schema/cross-posts.js'
8import { userPreferences } from '../db/schema/user-preferences.js'
9import { extractRkey } from '../lib/at-uri.js'
10
11// ---------------------------------------------------------------------------
12// Constants
13// ---------------------------------------------------------------------------
14
15/** Maximum grapheme length for Bluesky post text. */
16const BLUESKY_TEXT_LIMIT = 300
17
18/** Maximum length for the Bluesky embed description. */
19const EMBED_DESCRIPTION_LIMIT = 300
20
21/** AT Protocol collection for Bluesky posts. */
22const BLUESKY_COLLECTION = 'app.bsky.feed.post'
23
24/** AT Protocol collection for Frontpage link submissions. */
25const FRONTPAGE_COLLECTION = 'fyi.frontpage.post'
26
27// ---------------------------------------------------------------------------
28// Types
29// ---------------------------------------------------------------------------
30
31export interface CrossPostParams {
32 did: string
33 handle: string
34 topicUri: string
35 title: string
36 content: string
37 category: string
38 communityDid: string
39}
40
41export interface CrossPostService {
42 crossPostTopic(params: CrossPostParams): Promise<void>
43 deleteCrossPosts(topicUri: string, did: string): Promise<void>
44}
45
46export interface CrossPostConfig {
47 blueskyEnabled: boolean
48 frontpageEnabled: boolean
49 publicUrl: string
50 communityName: string
51}
52
53// ---------------------------------------------------------------------------
54// Helpers
55// ---------------------------------------------------------------------------
56
57/**
58 * Truncate text to a maximum number of characters, appending ellipsis if needed.
59 */
60function truncate(text: string, maxLength: number): string {
61 if (text.length <= maxLength) {
62 return text
63 }
64 return text.slice(0, maxLength - 1) + '\u2026'
65}
66
67/**
68 * Build the Bluesky post text from topic title and content.
69 * Format: "{title}\n\n{truncated content}" (fitting within BLUESKY_TEXT_LIMIT).
70 */
71function buildBlueskyPostText(title: string, content: string): string {
72 const prefix = title + '\n\n'
73 const remainingChars = BLUESKY_TEXT_LIMIT - prefix.length
74
75 if (remainingChars <= 0) {
76 return truncate(title, BLUESKY_TEXT_LIMIT)
77 }
78
79 return prefix + truncate(content, remainingChars)
80}
81
82/**
83 * Build the public URL for a topic using AT Protocol-style format.
84 */
85function buildTopicUrl(publicUrl: string, handle: string, topicUri: string): string {
86 const rkey = extractRkey(topicUri)
87 return `${publicUrl}/${handle}/${rkey}`
88}
89
90// ---------------------------------------------------------------------------
91// Factory
92// ---------------------------------------------------------------------------
93
94/**
95 * Create a cross-posting service that publishes topics to external platforms
96 * (Bluesky, Frontpage) via the user's PDS.
97 *
98 * Cross-posts are fire-and-forget: failures are logged and the user is
99 * notified, but they do not block topic creation. Each service is
100 * independent -- a failure in one does not prevent the other from succeeding.
101 *
102 * Bluesky cross-posts include a branded OG image as a thumbnail in the
103 * embed card (community name + category + topic title).
104 */
105export function createCrossPostService(
106 pdsClient: PdsClient,
107 db: Database,
108 logger: Logger,
109 config: CrossPostConfig,
110 notificationService: NotificationService
111): CrossPostService {
112 /**
113 * Generate and upload an OG image for use as a Bluesky embed thumbnail.
114 * Returns the blob reference on success, or undefined on failure (best-effort).
115 */
116 async function generateAndUploadThumb(params: CrossPostParams): Promise<unknown> {
117 try {
118 const pngBuffer = await generateOgImage({
119 title: params.title,
120 category: params.category,
121 communityName: config.communityName,
122 })
123
124 return await pdsClient.uploadBlob(params.did, pngBuffer, 'image/png')
125 } catch (err: unknown) {
126 logger.warn(
127 { err, topicUri: params.topicUri },
128 'Failed to generate or upload OG image for cross-post thumbnail'
129 )
130 return undefined
131 }
132 }
133
134 /**
135 * Cross-post a topic to Bluesky as an `app.bsky.feed.post` record
136 * with an `app.bsky.embed.external` embed containing a link back
137 * to the forum topic and a branded OG image thumbnail.
138 */
139 async function crossPostToBluesky(params: CrossPostParams, thumb: unknown): Promise<void> {
140 const topicUrl = buildTopicUrl(config.publicUrl, params.handle, params.topicUri)
141 const postText = buildBlueskyPostText(params.title, params.content)
142
143 const external: Record<string, unknown> = {
144 uri: topicUrl,
145 title: params.title,
146 description: truncate(params.content, EMBED_DESCRIPTION_LIMIT),
147 }
148
149 if (thumb !== undefined) {
150 external.thumb = thumb
151 }
152
153 const record: Record<string, unknown> = {
154 $type: BLUESKY_COLLECTION,
155 text: postText,
156 createdAt: new Date().toISOString(),
157 embed: {
158 $type: 'app.bsky.embed.external',
159 external,
160 },
161 langs: ['en'],
162 }
163
164 let result: { uri: string; cid: string }
165 try {
166 result = await pdsClient.createRecord(params.did, BLUESKY_COLLECTION, record)
167 } catch (err: unknown) {
168 if (isScopeError(err)) {
169 await handleScopeRevocation(params.did, params.communityDid)
170 }
171 throw err
172 }
173
174 await db.insert(crossPosts).values({
175 topicUri: params.topicUri,
176 service: 'bluesky',
177 crossPostUri: result.uri,
178 crossPostCid: result.cid,
179 authorDid: params.did,
180 })
181
182 logger.info(
183 { topicUri: params.topicUri, crossPostUri: result.uri },
184 'Cross-posted topic to Bluesky'
185 )
186 }
187
188 /**
189 * Cross-post a topic to Frontpage as an `fyi.frontpage.post` record
190 * (link submission pointing back to the forum topic).
191 */
192 async function crossPostToFrontpage(params: CrossPostParams): Promise<void> {
193 const topicUrl = buildTopicUrl(config.publicUrl, params.handle, params.topicUri)
194
195 const record: Record<string, unknown> = {
196 title: params.title,
197 url: topicUrl,
198 createdAt: new Date().toISOString(),
199 }
200
201 let result: { uri: string; cid: string }
202 try {
203 result = await pdsClient.createRecord(params.did, FRONTPAGE_COLLECTION, record)
204 } catch (err: unknown) {
205 if (isScopeError(err)) {
206 await handleScopeRevocation(params.did, params.communityDid)
207 }
208 throw err
209 }
210
211 await db.insert(crossPosts).values({
212 topicUri: params.topicUri,
213 service: 'frontpage',
214 crossPostUri: result.uri,
215 crossPostCid: result.cid,
216 authorDid: params.did,
217 })
218
219 logger.info(
220 { topicUri: params.topicUri, crossPostUri: result.uri },
221 'Cross-posted topic to Frontpage'
222 )
223 }
224
225 /**
226 * Detect whether an error from the PDS indicates insufficient scope (403).
227 */
228 function isScopeError(err: unknown): boolean {
229 if (err !== null && typeof err === 'object' && 'status' in err) {
230 return (err as { status: number }).status === 403
231 }
232 return false
233 }
234
235 /**
236 * Reset the cross-post scopes flag and notify the user when the PDS
237 * rejects a cross-post due to insufficient scope / revoked authorization.
238 */
239 async function handleScopeRevocation(did: string, communityDid: string): Promise<void> {
240 try {
241 await db
242 .update(userPreferences)
243 .set({ crossPostScopesGranted: false, updatedAt: new Date() })
244 .where(eq(userPreferences.did, did))
245
246 await notificationService.notifyOnCrossPostScopeRevoked({
247 authorDid: did,
248 communityDid,
249 })
250 } catch (revokeErr: unknown) {
251 logger.error({ err: revokeErr, did }, 'Failed to handle cross-post scope revocation')
252 }
253 }
254
255 return {
256 async crossPostTopic(params: CrossPostParams): Promise<void> {
257 // Check if user has cross-post scopes granted before attempting
258 const prefRows = await db
259 .select({ crossPostScopesGranted: userPreferences.crossPostScopesGranted })
260 .from(userPreferences)
261 .where(eq(userPreferences.did, params.did))
262
263 if (!(prefRows[0]?.crossPostScopesGranted ?? false)) {
264 logger.info(
265 { did: params.did, topicUri: params.topicUri },
266 'Skipping cross-post: user has not authorized cross-post scopes'
267 )
268 return
269 }
270
271 // Generate and upload OG image for Bluesky (only if Bluesky is enabled)
272 let thumb: unknown
273 if (config.blueskyEnabled) {
274 thumb = await generateAndUploadThumb(params)
275 }
276
277 const tasks: Promise<PromiseSettledResult<void>>[] = []
278
279 if (config.blueskyEnabled) {
280 tasks.push(
281 crossPostToBluesky(params, thumb)
282 .then<PromiseSettledResult<void>>(() => ({
283 status: 'fulfilled' as const,
284 value: undefined,
285 }))
286 .catch<PromiseSettledResult<void>>((err: unknown) => {
287 logger.error(
288 { err, topicUri: params.topicUri, service: 'bluesky' },
289 'Failed to cross-post to Bluesky'
290 )
291 notificationService
292 .notifyOnCrossPostFailure({
293 topicUri: params.topicUri,
294 authorDid: params.did,
295 service: 'bluesky',
296 communityDid: params.communityDid,
297 })
298 .catch((notifErr: unknown) => {
299 logger.error(
300 { err: notifErr, topicUri: params.topicUri },
301 'Failed to send cross-post failure notification'
302 )
303 })
304 return {
305 status: 'rejected' as const,
306 reason: err,
307 }
308 })
309 )
310 }
311
312 if (config.frontpageEnabled) {
313 tasks.push(
314 crossPostToFrontpage(params)
315 .then<PromiseSettledResult<void>>(() => ({
316 status: 'fulfilled' as const,
317 value: undefined,
318 }))
319 .catch<PromiseSettledResult<void>>((err: unknown) => {
320 logger.error(
321 { err, topicUri: params.topicUri, service: 'frontpage' },
322 'Failed to cross-post to Frontpage'
323 )
324 notificationService
325 .notifyOnCrossPostFailure({
326 topicUri: params.topicUri,
327 authorDid: params.did,
328 service: 'frontpage',
329 communityDid: params.communityDid,
330 })
331 .catch((notifErr: unknown) => {
332 logger.error(
333 { err: notifErr, topicUri: params.topicUri },
334 'Failed to send cross-post failure notification'
335 )
336 })
337 return {
338 status: 'rejected' as const,
339 reason: err,
340 }
341 })
342 )
343 }
344
345 await Promise.all(tasks)
346 },
347
348 async deleteCrossPosts(topicUri: string, did: string): Promise<void> {
349 const rows = await db.select().from(crossPosts).where(eq(crossPosts.topicUri, topicUri))
350
351 for (const row of rows) {
352 const rkey = extractRkey(row.crossPostUri)
353 const collection = row.service === 'bluesky' ? BLUESKY_COLLECTION : FRONTPAGE_COLLECTION
354
355 try {
356 await pdsClient.deleteRecord(did, collection, rkey)
357 logger.info(
358 { crossPostUri: row.crossPostUri, service: row.service },
359 'Deleted cross-post'
360 )
361 } catch (err: unknown) {
362 logger.warn(
363 { err, crossPostUri: row.crossPostUri, service: row.service },
364 'Failed to delete cross-post from PDS (best-effort)'
365 )
366 }
367 }
368
369 // Always clean up DB rows regardless of PDS delete success
370 await db.delete(crossPosts).where(eq(crossPosts.topicUri, topicUri))
371 },
372 }
373}