···11+# Who's Alice
22+33+A custom feed for Bluesky featuring all the Alices.
44+55+Local URL: http://localhost:3000/xrpc/app.bsky.feed.getFeedSkeleton?feed=at://did:web:feeds.bsky.sh/app.bsky.feed.generator/whos-eepy
66+Prod URL: http://feeds.bsky.sh/xrpc/app.bsky.feed.getFeedSkeleton?feed=at://did:web:feeds.bsky.sh/app.bsky.feed.generator/whos-eepy
···11+/**
22+ * GENERATED CODE - DO NOT MODIFY
33+ */
44+import { ValidationResult, BlobRef } from '@atproto/lexicon'
55+import { lexicons } from '../../../../lexicons'
66+import { isObj, hasProp } from '../../../../util'
77+import { CID } from 'multiformats/cid'
88+99+/** Metadata tag on an atproto resource (eg, repo or record) */
1010+export interface Label {
1111+ /** DID of the actor who created this label */
1212+ src: string
1313+ /** AT URI of the record, repository (account), or other resource which this label applies to */
1414+ uri: string
1515+ /** optionally, CID specifying the specific version of 'uri' resource this label applies to */
1616+ cid?: string
1717+ /** the short string name of the value or type of this label */
1818+ val: string
1919+ /** if true, this is a negation label, overwriting a previous label */
2020+ neg?: boolean
2121+ /** timestamp when this label was created */
2222+ cts: string
2323+ [k: string]: unknown
2424+}
2525+2626+export function isLabel(v: unknown): v is Label {
2727+ return (
2828+ isObj(v) &&
2929+ hasProp(v, '$type') &&
3030+ v.$type === 'com.atproto.label.defs#label'
3131+ )
3232+}
3333+3434+export function validateLabel(v: unknown): ValidationResult {
3535+ return lexicons.validate('com.atproto.label.defs#label', v)
3636+}
···11+/**
22+ * GENERATED CODE - DO NOT MODIFY
33+ */
44+import express from 'express'
55+import { ValidationResult, BlobRef } from '@atproto/lexicon'
66+import { lexicons } from '../../../../lexicons'
77+import { isObj, hasProp } from '../../../../util'
88+import { CID } from 'multiformats/cid'
99+import { HandlerAuth } from '@atproto/xrpc-server'
1010+import * as ComAtprotoLabelDefs from './defs'
1111+1212+export interface QueryParams {
1313+ /** List of AT URI patterns to match (boolean 'OR'). Each may be a prefix (ending with '*'; will match inclusive of the string leading to '*'), or a full URI */
1414+ uriPatterns: string[]
1515+ /** Optional list of label sources (DIDs) to filter on */
1616+ sources?: string[]
1717+ limit: number
1818+ cursor?: string
1919+}
2020+2121+export type InputSchema = undefined
2222+2323+export interface OutputSchema {
2424+ cursor?: string
2525+ labels: ComAtprotoLabelDefs.Label[]
2626+ [k: string]: unknown
2727+}
2828+2929+export type HandlerInput = undefined
3030+3131+export interface HandlerSuccess {
3232+ encoding: 'application/json'
3333+ body: OutputSchema
3434+}
3535+3636+export interface HandlerError {
3737+ status: number
3838+ message?: string
3939+}
4040+4141+export type HandlerOutput = HandlerError | HandlerSuccess
4242+export type Handler<HA extends HandlerAuth = never> = (ctx: {
4343+ auth: HA
4444+ params: QueryParams
4545+ input: HandlerInput
4646+ req: express.Request
4747+ res: express.Response
4848+}) => Promise<HandlerOutput> | HandlerOutput
···11+/**
22+ * GENERATED CODE - DO NOT MODIFY
33+ */
44+export function isObj(v: unknown): v is Record<string, unknown> {
55+ return typeof v === 'object' && v !== null
66+}
77+88+export function hasProp<K extends PropertyKey>(
99+ data: object,
1010+ prop: K,
1111+): data is Record<K, unknown> {
1212+ return prop in data
1313+}
+16
src/methods/describe-generator.ts
···11+import { Server } from '../lexicon'
22+import { AppContext } from '../config'
33+import algos from '../algos'
44+55+export default function (server: Server, ctx: AppContext) {
66+ server.app.bsky.feed.describeFeedGenerator(async () => {
77+ const feeds = Object.keys(algos).map((uri) => ({ uri }))
88+ return {
99+ encoding: 'application/json',
1010+ body: {
1111+ did: ctx.cfg.serviceDid,
1212+ feeds,
1313+ },
1414+ }
1515+ })
1616+}
+31
src/methods/feed-generation.ts
···11+import { InvalidRequestError } from '@atproto/xrpc-server'
22+import { Server } from '../lexicon'
33+import { AppContext } from '../config'
44+import algos from '../algos'
55+66+export default function (server: Server, ctx: AppContext) {
77+ server.app.bsky.feed.getFeedSkeleton(async ({ params, req }) => {
88+ const algo = algos[params.feed.split('/').pop()!]
99+ if (!algo) {
1010+ throw new InvalidRequestError(
1111+ 'Unsupported algorithm',
1212+ 'UnsupportedAlgorithm',
1313+ )
1414+ }
1515+ /**
1616+ * Example of how to check auth if giving user-specific results:
1717+ *
1818+ * const requesterDid = await validateAuth(
1919+ * req,
2020+ * ctx.cfg.serviceDid,
2121+ * ctx.didResolver,
2222+ * )
2323+ */
2424+2525+ const body = await algo(ctx, params)
2626+ return {
2727+ encoding: 'application/json',
2828+ body: body,
2929+ }
3030+ })
3131+}
+106
src/server.ts
···11+import http from 'http'
22+import events from 'events'
33+import express from 'express'
44+import { DidResolver, MemoryCache } from '@atproto/did-resolver'
55+import { createServer } from './lexicon'
66+import feedGeneration from './methods/feed-generation'
77+import describeGenerator from './methods/describe-generator'
88+import { createDb, Database, migrateToLatest } from './db'
99+import { FirehoseSubscription } from './subscription'
1010+import { AppContext, Config } from './config'
1111+import wellKnown from './well-known'
1212+import BskyAgent, { AtpSessionData, AtpSessionEvent } from '@atproto/api'
1313+import * as process from 'node:process'
1414+1515+export class FeedGenerator {
1616+ public app: express.Application
1717+ public server?: http.Server
1818+ public db: Database
1919+ public firehose: FirehoseSubscription
2020+ public cfg: Config
2121+ public agent: BskyAgent
2222+ public didResolver: DidResolver
2323+ public session: string | null
2424+2525+ constructor(
2626+ app: express.Application,
2727+ db: Database,
2828+ firehose: FirehoseSubscription,
2929+ cfg: Config,
3030+ ) {
3131+ this.app = app
3232+ this.db = db
3333+ this.firehose = firehose
3434+ this.cfg = cfg
3535+ }
3636+3737+ static create(cfg: Config) {
3838+ const app = express()
3939+ const db = createDb(cfg.postgresConnectionString)
4040+ const firehose = new FirehoseSubscription(db, cfg.subscriptionEndpoint)
4141+4242+ const didCache = new MemoryCache()
4343+ const didResolver = new DidResolver(
4444+ { plcUrl: 'https://plc.directory' },
4545+ didCache,
4646+ )
4747+4848+ const server = createServer({
4949+ validateResponse: true,
5050+ payload: {
5151+ jsonLimit: 100 * 1024, // 100kb
5252+ textLimit: 100 * 1024, // 100kb
5353+ blobLimit: 5 * 1024 * 1024, // 5mb
5454+ },
5555+ })
5656+ const ctx: AppContext = {
5757+ db,
5858+ didResolver,
5959+ cfg,
6060+ }
6161+ feedGeneration(server, ctx)
6262+ describeGenerator(server, ctx)
6363+ app.use(server.xrpc.router)
6464+ app.use(wellKnown(ctx))
6565+6666+ return new FeedGenerator(app, db, firehose, cfg)
6767+ }
6868+6969+ async start(): Promise<http.Server> {
7070+ await migrateToLatest(this.db)
7171+ this.agent = new BskyAgent({
7272+ service: 'https://bsky.social',
7373+ persistSession: (evt: AtpSessionEvent, sess?: AtpSessionData) => {
7474+ // store the session-data for reuse
7575+ switch (evt) {
7676+ case 'create':
7777+ if (!sess) throw new Error('should be unreachable')
7878+ this.session = JSON.stringify(sess)
7979+ break
8080+ case 'create-failed':
8181+ this.session = null
8282+ console.error('Could not create session')
8383+ break
8484+ case 'update':
8585+ if (!sess) throw new Error('should be unreachable')
8686+ this.session = JSON.stringify(sess)
8787+ break
8888+ case 'expired':
8989+ this.session = null
9090+ break
9191+ }
9292+ },
9393+ })
9494+ await this.agent.login({
9595+ identifier: process.env.HANDLE!,
9696+ password: process.env.PASSWORD!,
9797+ })
9898+ console.log('🗝️ logged in 🗝️')
9999+ this.firehose.run(this.agent)
100100+ this.server = this.app.listen(this.cfg.port, '0.0.0.0')
101101+ await events.once(this.server, 'listening')
102102+ return this.server
103103+ }
104104+}
105105+106106+export default FeedGenerator