Atproto AMA app
0
fork

Configure Feed

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

at main 340 lines 8.9 kB view raw
1/** 2 * Source integration logic for handling questions and answers from different platforms 3 */ 4 5import { SOURCE_TYPES, type SourceType } from './shared-schemas' 6import { 7 createQuestionFromBlueSky, 8 createAnswerFromBlueSky, 9 createQuestionFromStandardSite, 10 detectSourceType 11} from './schema-bridge' 12import { db } from './db' 13import { users, questions } from './schema' 14import { eq } from 'drizzle-orm' 15 16/** 17 * Interface for external post data 18 */ 19export interface ExternalPost { 20 uri: string 21 content: string 22 authorDid: string 23 createdAt: string 24 platform: string 25 metadata?: Record<string, unknown> 26} 27 28/** 29 * Interface for Bluesky post data 30 */ 31export interface BlueSkyPost extends ExternalPost { 32 platform: 'bluesky' 33 bskyData: { 34 uri: string 35 cid: string 36 record: Record<string, unknown> 37 author: { 38 did: string 39 handle: string 40 displayName?: string 41 } 42 } 43} 44 45/** 46 * Interface for standard.site post data 47 */ 48export interface StandardSitePost extends ExternalPost { 49 platform: 'standard.site' 50 standardSiteData: { 51 url: string 52 schema: Record<string, unknown> 53 author: { 54 did: string 55 name?: string 56 } 57 } 58} 59 60/** 61 * Parse a Bluesky post to extract question content 62 */ 63export function parseBlueSkyQuestion(post: BlueSkyPost): { 64 content: string 65 targetDid?: string 66 isQuestion: boolean 67} { 68 const text = post.content 69 70 // Look for question patterns 71 const hasQuestionMark = text.includes('?') 72 const hasQuestionWords = /\b(what|how|why|when|where|who|which|can|could|would|should|is|are|do|does|did)\b/i.test(text) 73 74 // Look for mentions that could be the target 75 const mentionRegex = /@([a-zA-Z0-9.-]+)/g 76 const mentions = Array.from(text.matchAll(mentionRegex)) 77 78 // Extract target DID from mentions (this would need to be resolved via AT Protocol) 79 let targetDid: string | undefined 80 if (mentions.length > 0) { 81 // In a real implementation, you'd resolve the handle to a DID 82 // For now, we'll use a placeholder 83 targetDid = `did:plc:${mentions[0][1].replace('.', '')}` 84 } 85 86 return { 87 content: text, 88 targetDid, 89 isQuestion: hasQuestionMark || hasQuestionWords 90 } 91} 92 93/** 94 * Parse a standard.site post to extract question content 95 */ 96export function parseStandardSiteQuestion(post: StandardSitePost): { 97 content: string 98 targetDid?: string 99 isQuestion: boolean 100} { 101 const schema = post.standardSiteData.schema 102 103 // Check if it's a Question schema.org type 104 const isQuestion = schema['@type'] === 'Question' 105 106 let content = post.content 107 let targetDid: string | undefined 108 109 if (isQuestion && schema.text) { 110 content = schema.text as string 111 } 112 113 // Look for target in acceptedAnswer or other fields 114 if (schema.acceptedAnswer && typeof schema.acceptedAnswer === 'object') { 115 const answer = schema.acceptedAnswer as Record<string, unknown> 116 if (answer.author && typeof answer.author === 'object') { 117 const author = answer.author as Record<string, unknown> 118 if (author.identifier && typeof author.identifier === 'string') { 119 targetDid = author.identifier 120 } 121 } 122 } 123 124 return { 125 content, 126 targetDid, 127 isQuestion 128 } 129} 130 131/** 132 * Import a question from an external source 133 */ 134export async function importQuestionFromExternalPost( 135 post: ExternalPost, 136 targetDid: string 137): Promise<{ success: boolean; questionId?: string; error?: string }> { 138 try { 139 const sourceType = detectSourceType(post.uri, { platform: post.platform }) 140 141 // Ensure target user exists in our system 142 const targetUser = await db.query.users.findFirst({ 143 where: eq(users.did, targetDid) 144 }) 145 146 if (!targetUser) { 147 return { success: false, error: 'Target user not found in system' } 148 } 149 150 // Check if we already imported this post 151 const existingQuestion = await db.query.questions.findFirst({ 152 where: eq(questions.sourceUri, post.uri) 153 }) 154 155 if (existingQuestion) { 156 return { success: false, error: 'Question already imported' } 157 } 158 159 let result 160 161 switch (sourceType) { 162 case SOURCE_TYPES.BLUESKY: 163 const bskyPost = post as BlueSkyPost 164 result = createQuestionFromBlueSky({ 165 content: post.content, 166 targetDid, 167 authorDid: post.authorDid, 168 bskyUri: post.uri, 169 bskyPost: bskyPost.bskyData, 170 anonymous: false 171 }) 172 break 173 174 case SOURCE_TYPES.STANDARD_SITE: 175 const standardPost = post as StandardSitePost 176 result = createQuestionFromStandardSite({ 177 content: post.content, 178 targetDid, 179 authorDid: post.authorDid, 180 standardSiteUri: post.uri, 181 standardSiteData: standardPost.standardSiteData, 182 anonymous: false 183 }) 184 break 185 186 default: 187 // Generic import for other sources 188 result = { 189 lexRecord: null, 190 dbData: { 191 content: post.content, 192 targetDid, 193 authorDid: post.authorDid, 194 sourceType: sourceType, 195 anonymous: false, 196 sourceUri: post.uri, 197 sourceData: JSON.stringify(post.metadata || {}), 198 createdAt: new Date(post.createdAt) 199 } 200 } 201 } 202 203 // Insert into database 204 const [inserted] = await db.insert(questions).values({ 205 authorDid: result.dbData.authorDid!, 206 targetDid: result.dbData.targetDid!, 207 content: result.dbData.content!, 208 sourceType: result.dbData.sourceType!, 209 anonymous: result.dbData.anonymous!, 210 createdAt: result.dbData.createdAt!, 211 sourceUri: result.dbData.sourceUri, 212 sourceData: result.dbData.sourceData 213 }).returning() 214 215 return { success: true, questionId: inserted.id } 216 217 } catch (error) { 218 console.error('Failed to import question from external post:', error) 219 return { 220 success: false, 221 error: error instanceof Error ? error.message : 'Unknown error' 222 } 223 } 224} 225 226/** 227 * Monitor Bluesky for mentions and questions 228 */ 229export async function monitorBlueSkyMentions(userDid: string): Promise<BlueSkyPost[]> { 230 // This would integrate with the Bluesky API to monitor mentions 231 // For now, return empty array as placeholder 232 console.log(`Monitoring Bluesky mentions for ${userDid}`) 233 return [] 234} 235 236/** 237 * Monitor standard.site for questions 238 */ 239export async function monitorStandardSiteQuestions(userDid: string): Promise<StandardSitePost[]> { 240 // This would integrate with standard.site to monitor questions 241 // For now, return empty array as placeholder 242 console.log(`Monitoring standard.site questions for ${userDid}`) 243 return [] 244} 245 246/** 247 * Get source type display information 248 */ 249export function getSourceTypeInfo(sourceType: SourceType): { 250 name: string 251 icon: string 252 color: string 253 url?: string 254} { 255 switch (sourceType) { 256 case SOURCE_TYPES.BLUESKY: 257 return { 258 name: 'Bluesky', 259 icon: '🦋', 260 color: '#0085ff', 261 url: 'https://bsky.app' 262 } 263 case SOURCE_TYPES.STANDARD_SITE: 264 return { 265 name: 'Standard.site', 266 icon: '🌐', 267 color: '#6366f1', 268 url: 'https://standard.site' 269 } 270 case SOURCE_TYPES.MASTODON: 271 return { 272 name: 'Mastodon', 273 icon: '🐘', 274 color: '#6364ff' 275 } 276 case SOURCE_TYPES.NOSTR: 277 return { 278 name: 'Nostr', 279 icon: '⚡', 280 color: '#f59e0b' 281 } 282 case SOURCE_TYPES.ASKIMUT: 283 default: 284 return { 285 name: 'Askimut', 286 icon: '❓', 287 color: '#10b981' 288 } 289 } 290} 291 292/** 293 * Format source attribution text 294 */ 295export function formatSourceAttribution(sourceType: SourceType, sourceUri?: string): string { 296 const info = getSourceTypeInfo(sourceType) 297 298 if (sourceType === SOURCE_TYPES.ASKIMUT) { 299 return '' 300 } 301 302 if (sourceUri) { 303 return `Originally from ${info.name}` 304 } 305 306 return `Via ${info.name}` 307} 308 309/** 310 * Check if a source type supports real-time monitoring 311 */ 312export function supportsRealTimeMonitoring(sourceType: SourceType): boolean { 313 return [SOURCE_TYPES.BLUESKY, SOURCE_TYPES.STANDARD_SITE].includes(sourceType) 314} 315 316/** 317 * Get the original post URL if available 318 */ 319export function getOriginalPostUrl(sourceType: SourceType, sourceUri?: string, sourceData?: string): string | null { 320 if (!sourceUri) return null 321 322 switch (sourceType) { 323 case SOURCE_TYPES.BLUESKY: 324 // Convert AT-URI to web URL 325 if (sourceUri.startsWith('at://')) { 326 const parts = sourceUri.replace('at://', '').split('/') 327 if (parts.length >= 3) { 328 const [did, collection, rkey] = parts 329 return `https://bsky.app/profile/${did}/post/${rkey}` 330 } 331 } 332 return sourceUri 333 334 case SOURCE_TYPES.STANDARD_SITE: 335 return sourceUri 336 337 default: 338 return sourceUri 339 } 340}