The Official Paul's Alf Posts Feed
lex-tap-feed-generator.ts edited
206 lines 7.1 kB view raw
1// this requires that you have an instance of `tap` running 2// bootstrap with `lex install app.bsky.feed.describeFeedGenerator app.bsky.feed.getFeedSkeleton && lex build` 3 4import { AtUriString, DidString, asDidString } from '@atproto/lex' 5import { LexError, LexRouter, serviceAuth } from '@atproto/lex-server' 6import { serve } from '@atproto/lex-server/nodejs' 7import { Tap, SimpleIndexer } from '@atproto/tap' 8import * as app from './lexicons/app.js' 9 10// ============================================================================= 11// Configuration 12// ============================================================================= 13 14interface FeedConfig { 15 publisherDid: DidString 16 feedName: string 17 searchTerms: string[] 18 maxPosts: number 19 port: number 20 tapUrl: string 21 tapPassword: string 22 initialRepos: string[] 23} 24 25const DEFAULT_REPO = 'did:plc:ragtjsm2j2vknwkz3zp4oxrd' // pfrazee.com 26 27const config: FeedConfig = { 28 publisherDid: asDidString(process.env.FEED_PUBLISHER_DID || 'did:example:alice'), 29 feedName: process.env.FEED_NAME || 'whats-alf', 30 searchTerms: (process.env.FEED_SEARCH_TERMS || 'alf').split(',').map(s => s.trim()), 31 maxPosts: parseInt(process.env.FEED_MAX_POSTS || '1000', 10), 32 port: parseInt(process.env.FEED_PORT || '3000', 10), 33 tapUrl: process.env.TAP_URL || 'http://localhost:2480', 34 tapPassword: process.env.TAP_PASSWORD || 'secret', 35 initialRepos: (process.env.FEED_INITIAL_REPOS || DEFAULT_REPO).split(',').map(s => s.trim()), 36} 37 38const FEED_URI: AtUriString = `at://${config.publisherDid}/app.bsky.feed.generator/${config.feedName}` as AtUriString 39 40// ============================================================================= 41// Post Index 42// ============================================================================= 43 44interface IndexedPost { 45 uri: AtUriString 46 indexedAt: number 47} 48 49const postIndex: IndexedPost[] = [] 50 51const searchPattern = new RegExp( 52 `\\b(${config.searchTerms.map(t => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})\\b`, 53 'i' 54) 55 56// ============================================================================= 57// Tap Indexer 58// ============================================================================= 59 60const tap = new Tap(config.tapUrl, { adminPassword: config.tapPassword }) 61const indexer = new SimpleIndexer() 62 63indexer.record(async (evt) => { 64 if (evt.collection !== 'app.bsky.feed.post') return 65 66 const uri = `at://${evt.did}/${evt.collection}/${evt.rkey}` as AtUriString 67 68 if (evt.action === 'delete') { 69 const idx = postIndex.findIndex(p => p.uri === uri) 70 if (idx !== -1) { 71 postIndex.splice(idx, 1) 72 console.log(`DELETE ${uri}`) 73 } 74 return 75 } 76 77 const text = (evt.record?.text as string) || '' 78 if (!searchPattern.test(text)) return 79 if (postIndex.some(p => p.uri === uri)) return 80 81 postIndex.unshift({ uri, indexedAt: Date.now() }) 82 if (postIndex.length > config.maxPosts) postIndex.pop() 83 84 const preview = text.substring(0, 60).replace(/\n/g, ' ') 85 console.log(`${evt.action.toUpperCase()} ${uri}`) 86 console.log(` "${preview}${text.length > 60 ? '...' : ''}"`) 87 console.log(` ⭐ Added to index (${postIndex.length} total)`) 88}) 89 90indexer.identity(async (evt) => { 91 if (evt.status === 'active') return 92 // Remove posts from disabled/deleted identities 93 const removed = postIndex.filter(p => p.uri.includes(evt.did)).length 94 if (removed > 0) { 95 postIndex.splice(0, postIndex.length, ...postIndex.filter(p => !p.uri.includes(evt.did))) 96 console.log(`Identity ${evt.did} (${evt.status}): removed ${removed} posts`) 97 } 98}) 99 100indexer.error((err) => console.error('Indexer error:', err)) 101 102const channel = tap.channel(indexer) 103 104// ============================================================================= 105// Feed Generator Server 106// ============================================================================= 107 108// Auth is optional for this demo since we only log the requester's DID. 109// In production, you may want to use credentials to personalize the feed. 110const auth = serviceAuth({ 111 audience: config.publisherDid, 112 unique: async () => true, 113}) 114 115const router = new LexRouter() 116 117router.add(app.bsky.feed.describeFeedGenerator, { 118 auth, 119 handler: (ctx) => { 120 console.log('describeFeedGenerator from', ctx.credentials?.did) 121 return { 122 body: { 123 did: config.publisherDid, 124 feeds: [app.bsky.feed.describeFeedGenerator.feed.$build({ uri: FEED_URI })], 125 links: { 126 privacyPolicy: 'https://example.com/privacy', 127 termsOfService: 'https://example.com/tos', 128 }, 129 }, 130 } 131 }, 132}) 133 134router.add(app.bsky.feed.getFeedSkeleton, { 135 auth, 136 handler: (ctx) => { 137 if (ctx.params.feed !== FEED_URI) { 138 throw new LexError('InvalidRequest', 'Feed not found') 139 } 140 console.log('getFeedSkeleton from', ctx.credentials?.did) 141 142 const limit = Math.min(ctx.params.limit ?? 50, 100) 143 const cursor = ctx.params.cursor as string | undefined 144 145 let startIdx = 0 146 if (cursor) { 147 const cursorTime = parseInt(cursor, 10) 148 startIdx = postIndex.findIndex(p => p.indexedAt < cursorTime) 149 if (startIdx === -1) startIdx = postIndex.length 150 } 151 152 const slice = postIndex.slice(startIdx, startIdx + limit) 153 const feed = slice.map(p => app.bsky.feed.defs.skeletonFeedPost.$build({ post: p.uri })) 154 const lastPost = slice.at(-1) 155 const nextCursor = lastPost && slice.length === limit && startIdx + limit < postIndex.length 156 ? lastPost.indexedAt.toString() 157 : undefined 158 159 return { body: { feed, cursor: nextCursor } } 160 }, 161}) 162 163// ============================================================================= 164// Start 165// ============================================================================= 166 167channel.start() 168console.log('Indexer connected to Tap server') 169 170if (config.initialRepos.length > 0) { 171 tap.addRepos(config.initialRepos).then(() => { 172 console.log(`Added ${config.initialRepos.length} repo(s) to follow\n`) 173 }) 174} 175 176serve(router, { port: config.port }).then((server) => { 177 const feedParam = encodeURIComponent(FEED_URI) 178 179 console.log(` 180Feed Generator Running 181 182Server: http://localhost:${config.port} 183Feed: ${config.feedName} 184Terms: ${config.searchTerms.join(', ')} 185Tap: ${config.tapUrl} 186Repos: ${config.initialRepos.length} 187 188To test (generate a JWT with goat): 189goat account service-auth --aud ${config.publisherDid} 190 191Then: 192curl -H "Authorization: Bearer <jwt>" "http://localhost:${config.port}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${feedParam}" 193 194Listening for posts matching: ${config.searchTerms.join(', ')} 195`) 196 197 const shutdown = async () => { 198 console.log('Shutting down...') 199 await channel.destroy() 200 await server.terminate() 201 process.exit(0) 202 } 203 204 process.on('SIGINT', shutdown) 205 process.on('SIGTERM', shutdown) 206})