The Official Paul's Alf Posts Feed
lex-tap-feed-generator.ts
edited
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})