Barazo AppView backend barazo.forum
at main 373 lines 12 kB view raw
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}